@kimdayoun/hwpx-mcp 0.3.0 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,9 +9,25 @@ const pako_1 = __importDefault(require("pako"));
9
9
  const HwpxParser_1 = require("./HwpxParser");
10
10
  const HangingIndentCalculator_1 = require("./HangingIndentCalculator");
11
11
  const MAX_UNDO_STACK_SIZE = 50;
12
+ /**
13
+ * HWPX 텍스트 노드 `<hp:t>` / `<hs:t>` 전용 매처.
14
+ *
15
+ * `<(hp|hs):t([^>]*)>` 처럼 태그명 뒤 경계를 두지 않으면 `<hp:tc>`·`<hp:tr>`·
16
+ * `<hp:tbl>` 같은 형제 태그의 접두사까지 삼킨다. 그 상태로 본문을 지우면
17
+ * 셀 구조가 통째로 사라지고 닫는 태그만 남아 한/글이 파일을 열지 못한다.
18
+ * 뒤에 오는 문자가 공백·`/`·`>` 중 하나임을 강제해 태그명을 정확히 끊는다.
19
+ */
20
+ const T_TAG_WITH_CONTENT = /<(hp|hs):t((?:\s[^>]*)?)>[\s\S]*?<\/\1:t>/g;
21
+ const T_TAG_EMPTY = /<(hp|hs):t((?:\s[^>]*)?)><\/\1:t>/g;
12
22
  class HwpxDocument {
13
23
  constructor(id, path, zip, content, format) {
14
24
  this._isDirty = false;
25
+ /**
26
+ * True once the section element list has changed shape since the XML was
27
+ * parsed (insert/delete/copy/move of paragraphs, tables or images).
28
+ * Parsed XML offsets are unusable from that point until the next save.
29
+ */
30
+ this._structureChanged = false;
15
31
  this._undoStack = [];
16
32
  this._redoStack = [];
17
33
  this._pendingTextReplacements = [];
@@ -21,7 +37,13 @@ class HwpxDocument {
21
37
  this._pendingImageInserts = [];
22
38
  this._pendingCellImageInserts = [];
23
39
  this._pendingTableInserts = [];
24
- this._tableInsertCounter = 0; // Counter for insertion order
40
+ /**
41
+ * Monotonic counter shared by paragraph and table inserts. Both kinds are
42
+ * replayed into XML in this order so each insert sees exactly the elements
43
+ * that existed when it was made. Replaying all tables before all paragraphs
44
+ * wrote "A, table, A-2, table" to disk as "A, A-2, table, table".
45
+ */
46
+ this._tableInsertCounter = 0;
25
47
  this._pendingImageDeletes = [];
26
48
  this._pendingTableDeletes = [];
27
49
  this._pendingParagraphDeletes = [];
@@ -127,7 +149,10 @@ class HwpxDocument {
127
149
  elements: [{
128
150
  type: 'paragraph',
129
151
  data: {
130
- id: Math.random().toString(36).substring(2, 11),
152
+ // Must match the id written into Contents/section0.xml below. Later
153
+ // inserts anchor on this id; a random value here pointed at a node
154
+ // that does not exist in the XML.
155
+ id: '0',
131
156
  runs: [{ text: '' }],
132
157
  },
133
158
  }],
@@ -227,10 +252,20 @@ class HwpxDocument {
227
252
  zip.file('Contents/section0.xml', `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><hs:sec xmlns:ha="http://www.hancom.co.kr/hwpml/2011/app" xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph" xmlns:hp10="http://www.hancom.co.kr/hwpml/2016/paragraph" xmlns:hs="http://www.hancom.co.kr/hwpml/2011/section" xmlns:hc="http://www.hancom.co.kr/hwpml/2011/core" xmlns:hh="http://www.hancom.co.kr/hwpml/2011/head" xmlns:hhs="http://www.hancom.co.kr/hwpml/2011/history" xmlns:hm="http://www.hancom.co.kr/hwpml/2011/master-page" xmlns:hpf="http://www.hancom.co.kr/schema/2011/hpf" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf/" xmlns:ooxmlchart="http://www.hancom.co.kr/hwpml/2016/ooxmlchart" xmlns:hwpunitchar="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar" xmlns:epub="http://www.idpf.org/2007/ops" xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0"><hp:p id="0" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0"><hp:run charPrIDRef="0"><hp:secPr id="" textDirection="HORIZONTAL" spaceColumns="1134" tabStop="8000" tabStopVal="4000" tabStopUnit="HWPUNIT" outlineShapeIDRef="1" memoShapeIDRef="0" textVerticalWidthHead="0" masterPageCnt="0"><hp:grid lineGrid="0" charGrid="0" wongoji="0"/><hp:startNum pageStartsOn="BOTH" page="0" pic="0" tbl="0" equation="0"/><hp:visibility hideFirstHeader="0" hideFirstFooter="0" hideFirstMasterPage="0" border="SHOW_ALL" fill="SHOW_ALL" hideFirstPageNum="0" hideFirstEmptyLine="0" showLineNumber="0"/><hp:pagePr landscape="0" width="59528" height="84188" gutterType="LEFT_ONLY"><hp:pageMar header="4252" footer="4252" left="8504" right="8504" top="5668" bottom="4252" gutter="0"/></hp:pagePr><hp:footNotePr><hp:autoNumFormat type="DIGIT"/><hp:noteLine length="-1" type="SOLID" width="0.12mm" color="#000000"/><hp:noteSpacing aboveLine="850" belowLine="567" betweenNotes="283"/><hp:numbering type="CONTINUOUS" newNum="1"/><hp:placement place="EACH_COLUMN" beneathText="0"/></hp:footNotePr><hp:endNotePr><hp:autoNumFormat type="DIGIT"/><hp:noteLine length="14692" type="SOLID" width="0.12mm" color="#000000"/><hp:noteSpacing aboveLine="850" belowLine="567" betweenNotes="0"/><hp:numbering type="CONTINUOUS" newNum="1"/><hp:placement place="END_OF_DOCUMENT" beneathText="0"/></hp:endNotePr></hp:secPr><hp:t></hp:t></hp:run></hp:p></hs:sec>`);
228
253
  // Create empty BinData folder
229
254
  zip.folder('BinData');
230
- return new HwpxDocument(id, 'new-document.hwpx', zip, content, 'hwpx');
255
+ // A new document has no location on disk yet. Seeding a bare filename here
256
+ // made save_document resolve it against the server process cwd, so callers
257
+ // could not find the file they had just written.
258
+ return new HwpxDocument(id, '', zip, content, 'hwpx');
231
259
  }
232
260
  get id() { return this._id; }
233
261
  get path() { return this._path; }
262
+ /** True once the document has a real location on disk. */
263
+ get hasPath() { return this._path.length > 0; }
264
+ /**
265
+ * Record where the document now lives after a successful write, so the next
266
+ * save without an explicit path targets the same file.
267
+ */
268
+ setPath(newPath) { this._path = newPath; }
234
269
  get format() { return this._format; }
235
270
  get isDirty() { return this._isDirty; }
236
271
  get zip() { return this._zip; }
@@ -256,6 +291,8 @@ class HwpxDocument {
256
291
  const parsed = JSON.parse(state);
257
292
  this._content.sections = parsed.sections;
258
293
  this._content.metadata = parsed.metadata;
294
+ // Undo/redo swaps in a whole element list; parse-time offsets no longer apply.
295
+ this.markStructureChanged();
259
296
  }
260
297
  canUndo() { return this._undoStack.length > 0; }
261
298
  canRedo() { return this._redoStack.length > 0; }
@@ -322,6 +359,60 @@ class HwpxDocument {
322
359
  this._isDirty = true;
323
360
  this.invalidateReadingCache();
324
361
  }
362
+ /**
363
+ * Record that the section element list changed shape. Call from every method
364
+ * that inserts, removes, copies or moves a section-level element. Once set,
365
+ * paragraph edits stop trusting offsets cached at parse time and locate their
366
+ * target in the current XML instead.
367
+ */
368
+ markStructureChanged() {
369
+ this._structureChanged = true;
370
+ }
371
+ /**
372
+ * Resolve "after element N" into an id-based anchor using the memory model
373
+ * as it is right now (before the new element is spliced in).
374
+ *
375
+ * Returns null for "before everything" (N < 0). Elements without an XML
376
+ * paragraph/table of their own (images, shapes) are skipped backwards to the
377
+ * nearest paragraph or table, which is what the XML placement needs.
378
+ *
379
+ * The parser turns a paragraph that is only a line of ─/━/═ into an 'hr'
380
+ * element with a fresh id. That paragraph is still in the XML, so it still
381
+ * takes an occurrence slot there: skipping it here put later anchors one
382
+ * paragraph early (measured on Hancom files with divider lines).
383
+ */
384
+ resolveElementAnchor(sectionIndex, afterElementIndex) {
385
+ const elements = this._content.sections[sectionIndex]?.elements ?? [];
386
+ for (let i = Math.min(afterElementIndex, elements.length - 1); i >= 0; i--) {
387
+ const key = this.anchorKeyOf(elements[i]);
388
+ if (!key)
389
+ continue;
390
+ let occurrence = 0;
391
+ for (let j = 0; j < i; j++) {
392
+ const other = this.anchorKeyOf(elements[j]);
393
+ if (other && other.kind === key.kind && other.id === key.id)
394
+ occurrence++;
395
+ }
396
+ return { ...key, occurrence };
397
+ }
398
+ return null;
399
+ }
400
+ /**
401
+ * The XML node a memory element stands for, or null if it has none of its
402
+ * own. An 'hr' parsed from a divider paragraph stands for that paragraph.
403
+ */
404
+ anchorKeyOf(el) {
405
+ if (!el)
406
+ return null;
407
+ if (el.type === 'hr') {
408
+ const src = el.data.sourceParagraphId;
409
+ return src ? { kind: 'paragraph', id: String(src) } : null;
410
+ }
411
+ if (el.type !== 'paragraph' && el.type !== 'table')
412
+ return null;
413
+ const id = String(el.data.id ?? '');
414
+ return id ? { kind: el.type, id } : null;
415
+ }
325
416
  // ============================================================
326
417
  // Content Access
327
418
  // ============================================================
@@ -389,6 +480,8 @@ class HwpxDocument {
389
480
  index: ei,
390
481
  text: el.data.runs.map(r => r.text).join(''),
391
482
  style: el.data.paraStyle,
483
+ paraPrIDRef: el.data.paraPrId,
484
+ charPrIDRef: el.data.runs.find(r => r.charPrIDRef !== undefined)?.charPrIDRef,
392
485
  });
393
486
  }
394
487
  });
@@ -403,6 +496,9 @@ class HwpxDocument {
403
496
  text: para.runs.map(r => r.text).join(''),
404
497
  runs: para.runs,
405
498
  style: para.paraStyle,
499
+ // Raw header.xml references, so callers can build XML without scraping it.
500
+ paraPrIDRef: para.paraPrId,
501
+ charPrIDRef: para.runs.find(r => r.charPrIDRef !== undefined)?.charPrIDRef,
406
502
  };
407
503
  }
408
504
  updateParagraphText(sectionIndex, elementIndex, runIndex, text) {
@@ -436,6 +532,7 @@ class HwpxDocument {
436
532
  elementIndex,
437
533
  paragraphId: paragraph.id || '', // Use stable paragraph ID for reliable identification
438
534
  paragraphOccurrence,
535
+ paragraph,
439
536
  runIndex,
440
537
  oldText,
441
538
  newText: text
@@ -450,6 +547,7 @@ class HwpxDocument {
450
547
  elementIndex,
451
548
  paragraphId: paragraph.id || '',
452
549
  paragraphOccurrence,
550
+ paragraph,
453
551
  runIndex: i,
454
552
  oldText: otherOldText,
455
553
  newText: '' // Clear other runs
@@ -530,6 +628,7 @@ class HwpxDocument {
530
628
  elementIndex,
531
629
  paragraphId: paragraph.id || '',
532
630
  paragraphOccurrence,
631
+ paragraph,
533
632
  runIndex: i,
534
633
  oldText: oldText || '',
535
634
  newText: run.text
@@ -549,12 +648,17 @@ class HwpxDocument {
549
648
  id: paragraphId,
550
649
  runs: [{ text }],
551
650
  };
651
+ // Resolve the XML position before the new paragraph joins the element list.
652
+ const anchor = this.resolveElementAnchor(sectionIndex, afterElementIndex);
552
653
  const newElement = { type: 'paragraph', data: newParagraph };
553
654
  section.elements.splice(afterElementIndex + 1, 0, newElement);
655
+ this.markStructureChanged();
554
656
  // Add to pending list for XML sync
555
657
  this._pendingParagraphInserts.push({
556
658
  sectionIndex,
557
659
  afterElementIndex,
660
+ anchor,
661
+ insertOrder: this._tableInsertCounter++,
558
662
  paragraphId,
559
663
  text,
560
664
  });
@@ -578,6 +682,7 @@ class HwpxDocument {
578
682
  });
579
683
  // Remove from memory
580
684
  section.elements.splice(elementIndex, 1);
685
+ this.markStructureChanged();
581
686
  this.markModified();
582
687
  this.invalidateReadingCache();
583
688
  return true;
@@ -600,6 +705,7 @@ class HwpxDocument {
600
705
  elementIndex,
601
706
  paragraphId: paragraph.id || '', // Use stable paragraph ID
602
707
  paragraphOccurrence,
708
+ paragraph,
603
709
  runIndex: lastRunIndex,
604
710
  oldText,
605
711
  newText
@@ -618,6 +724,7 @@ class HwpxDocument {
618
724
  elementIndex,
619
725
  paragraphId: paragraph.id || '', // Use stable paragraph ID
620
726
  paragraphOccurrence,
727
+ paragraph,
621
728
  runIndex: 0,
622
729
  oldText: '',
623
730
  newText: text
@@ -1391,8 +1498,15 @@ class HwpxDocument {
1391
1498
  // Get previous value
1392
1499
  const cellData = this.getTableCell(tableInfo.section_index, tableInfo.local_index, position.row, position.col);
1393
1500
  const previousValue = cellData?.text || '';
1394
- // Update the cell
1395
- const updated = this.updateTableCell(tableInfo.section_index, tableInfo.local_index, position.row, position.col, value);
1501
+ // Update the cell. A cell covered by a merge throws; treat it as a failed
1502
+ // path rather than aborting the remaining entries.
1503
+ let updated = false;
1504
+ try {
1505
+ updated = this.updateTableCell(tableInfo.section_index, tableInfo.local_index, position.row, position.col, value);
1506
+ }
1507
+ catch {
1508
+ updated = false;
1509
+ }
1396
1510
  if (updated) {
1397
1511
  result.success++;
1398
1512
  result.details.push({
@@ -1531,6 +1645,7 @@ class HwpxDocument {
1531
1645
  const result = {
1532
1646
  success: 0,
1533
1647
  outOfBounds: [],
1648
+ failed: [],
1534
1649
  updated: [],
1535
1650
  };
1536
1651
  const tableInfo = this.convertGlobalToLocalTableIndex(tableIndex);
@@ -1555,8 +1670,16 @@ class HwpxDocument {
1555
1670
  // Get previous value
1556
1671
  const cellData = this.getTableCell(tableInfo.section_index, tableInfo.local_index, targetRow, targetCol);
1557
1672
  const previousValue = cellData?.text || '';
1558
- // Update cell
1559
- const updated = this.updateTableCell(tableInfo.section_index, tableInfo.local_index, targetRow, targetCol, value);
1673
+ // Update cell. A cell covered by a merge throws; record it and keep
1674
+ // going so one merged position does not discard the whole batch.
1675
+ let updated = false;
1676
+ let failure = '';
1677
+ try {
1678
+ updated = this.updateTableCell(tableInfo.section_index, tableInfo.local_index, targetRow, targetCol, value);
1679
+ }
1680
+ catch (err) {
1681
+ failure = err instanceof Error ? err.message : String(err);
1682
+ }
1560
1683
  if (updated) {
1561
1684
  result.success++;
1562
1685
  result.updated.push({
@@ -1566,6 +1689,14 @@ class HwpxDocument {
1566
1689
  newValue: value,
1567
1690
  });
1568
1691
  }
1692
+ else {
1693
+ result.failed.push({
1694
+ row: targetRow,
1695
+ col: targetCol,
1696
+ value,
1697
+ error: failure || 'Cell update failed',
1698
+ });
1699
+ }
1569
1700
  }
1570
1701
  }
1571
1702
  return result;
@@ -1990,6 +2121,43 @@ class HwpxDocument {
1990
2121
  cell,
1991
2122
  };
1992
2123
  }
2124
+ /**
2125
+ * Find the merged cell that covers (row, col), if that position is not itself
2126
+ * a master cell. A covered cell has no <hp:tc> of its own in the saved XML,
2127
+ * so writing to it succeeds in memory and then silently vanishes on save.
2128
+ */
2129
+ findCoveringMergedCell(table, row, col) {
2130
+ const rows = table.rows;
2131
+ if (!rows)
2132
+ return null;
2133
+ const target = rows[row]?.cells?.[col];
2134
+ if (target && ((target.colSpan ?? 1) > 1 || (target.rowSpan ?? 1) > 1)) {
2135
+ return null; // the position is a master cell
2136
+ }
2137
+ // Merged cells can only originate at or before (row, col), and a table with
2138
+ // no spans at all — the common case — exits on the first row scan.
2139
+ for (let r = 0; r <= row && r < rows.length; r++) {
2140
+ const cells = rows[r]?.cells;
2141
+ if (!cells)
2142
+ continue;
2143
+ const lastCol = Math.min(col, cells.length - 1);
2144
+ for (let c = 0; c <= lastCol; c++) {
2145
+ const cell = cells[c];
2146
+ if (!cell)
2147
+ continue;
2148
+ const rowSpan = cell.rowSpan ?? 1;
2149
+ const colSpan = cell.colSpan ?? 1;
2150
+ if (rowSpan <= 1 && colSpan <= 1)
2151
+ continue;
2152
+ if (r === row && c === col)
2153
+ continue;
2154
+ if (row < r + rowSpan && col < c + colSpan) {
2155
+ return { row: r, col: c };
2156
+ }
2157
+ }
2158
+ }
2159
+ return null;
2160
+ }
1993
2161
  updateTableCell(sectionIndex, tableIndex, row, col, text, charShapeId) {
1994
2162
  const table = this.findTable(sectionIndex, tableIndex);
1995
2163
  if (!table)
@@ -1997,6 +2165,12 @@ class HwpxDocument {
1997
2165
  const cell = table.rows[row]?.cells[col];
1998
2166
  if (!cell)
1999
2167
  return false;
2168
+ // Refuse instead of reporting success and losing the text at save time.
2169
+ const covering = this.findCoveringMergedCell(table, row, col);
2170
+ if (covering) {
2171
+ throw new Error(`Cell (${row}, ${col}) is covered by the merged cell at ` +
2172
+ `(${covering.row}, ${covering.col}); write to the master cell instead`);
2173
+ }
2000
2174
  // Track cell update for XML sync (works for both empty and non-empty cells)
2001
2175
  // Store table ID for reliable XML matching
2002
2176
  // charShapeId is optional - if provided, it will override the existing charPrIDRef
@@ -2099,6 +2273,7 @@ class HwpxDocument {
2099
2273
  });
2100
2274
  // Remove from memory model
2101
2275
  section.elements.splice(elementIndex, 1);
2276
+ this.markStructureChanged();
2102
2277
  this.markModified();
2103
2278
  return true;
2104
2279
  }
@@ -2389,15 +2564,24 @@ class HwpxDocument {
2389
2564
  const srcElement = srcSection.elements[sourceParagraph];
2390
2565
  if (!srcElement || srcElement.type !== 'paragraph')
2391
2566
  return false;
2567
+ const source = this.resolveElementAnchor(sourceSection, sourceParagraph);
2568
+ if (!source)
2569
+ return false;
2570
+ const anchor = this.resolveElementAnchor(targetSection, targetAfter);
2392
2571
  this.saveState();
2393
2572
  const copy = JSON.parse(JSON.stringify(srcElement));
2394
- copy.data.id = Math.random().toString(36).substring(2, 11);
2573
+ const paragraphId = Math.random().toString(36).substring(2, 11);
2574
+ copy.data.id = paragraphId;
2575
+ delete copy.data._xmlPosition;
2395
2576
  tgtSection.elements.splice(targetAfter + 1, 0, copy);
2577
+ this.markStructureChanged();
2396
2578
  this._pendingParagraphCopies.push({
2397
2579
  sourceSection,
2398
- sourceParagraph,
2399
2580
  targetSection,
2400
- targetAfter,
2581
+ source,
2582
+ anchor,
2583
+ paragraphId,
2584
+ insertOrder: this._tableInsertCounter++,
2401
2585
  });
2402
2586
  this.markModified();
2403
2587
  return true;
@@ -2410,6 +2594,9 @@ class HwpxDocument {
2410
2594
  const srcElement = srcSection.elements[sourceParagraph];
2411
2595
  if (!srcElement || srcElement.type !== 'paragraph')
2412
2596
  return false;
2597
+ const source = this.resolveElementAnchor(sourceSection, sourceParagraph);
2598
+ if (!source)
2599
+ return false;
2413
2600
  this.saveState();
2414
2601
  srcSection.elements.splice(sourceParagraph, 1);
2415
2602
  // Fix same-section index shift: if source was before target, adjust target down
@@ -2417,12 +2604,17 @@ class HwpxDocument {
2417
2604
  if (sourceSection === targetSection && sourceParagraph < targetAfter) {
2418
2605
  adjustedTargetAfter -= 1;
2419
2606
  }
2607
+ // Resolve the destination now that the paragraph has left its old slot,
2608
+ // matching the XML at replay time (source node removed, then re-inserted).
2609
+ const anchor = this.resolveElementAnchor(targetSection, adjustedTargetAfter);
2420
2610
  tgtSection.elements.splice(adjustedTargetAfter + 1, 0, srcElement);
2611
+ this.markStructureChanged();
2421
2612
  this._pendingParagraphMoves.push({
2422
2613
  sourceSection,
2423
- sourceParagraph,
2424
2614
  targetSection,
2425
- targetAfter,
2615
+ source,
2616
+ anchor,
2617
+ insertOrder: this._tableInsertCounter++,
2426
2618
  });
2427
2619
  this.markModified();
2428
2620
  return true;
@@ -2587,8 +2779,11 @@ class HwpxDocument {
2587
2779
  rows: tableRows,
2588
2780
  width: defaultWidth,
2589
2781
  };
2782
+ // Resolve the XML position before the new table joins the element list.
2783
+ const anchor = this.resolveElementAnchor(sectionIndex, afterElementIndex);
2590
2784
  const newElement = { type: 'table', data: newTable };
2591
2785
  section.elements.splice(afterElementIndex + 1, 0, newElement);
2786
+ this.markStructureChanged();
2592
2787
  // Calculate table index
2593
2788
  let tableIndex = 0;
2594
2789
  for (let i = 0; i <= afterElementIndex + 1; i++) {
@@ -2599,10 +2794,10 @@ class HwpxDocument {
2599
2794
  }
2600
2795
  }
2601
2796
  // Add to pending table inserts for XML generation
2602
- // Store the original afterElementIndex and insertOrder for proper sequencing
2603
2797
  this._pendingTableInserts.push({
2604
2798
  sectionIndex,
2605
2799
  afterElementIndex,
2800
+ anchor,
2606
2801
  rows,
2607
2802
  cols,
2608
2803
  width: defaultWidth,
@@ -3115,6 +3310,7 @@ class HwpxDocument {
3115
3310
  // Add image element to section
3116
3311
  const newElement = { type: 'image', data: newImage };
3117
3312
  section.elements.splice(afterElementIndex + 1, 0, newElement);
3313
+ this.markStructureChanged();
3118
3314
  // Add to pending inserts for XML sync
3119
3315
  this._pendingImageInserts.push({
3120
3316
  sectionIndex,
@@ -3295,6 +3491,7 @@ class HwpxDocument {
3295
3491
  const index = section.elements.findIndex(el => el.type === 'image' && el.data.id === imageId);
3296
3492
  if (index !== -1) {
3297
3493
  section.elements.splice(index, 1);
3494
+ this.markStructureChanged();
3298
3495
  break;
3299
3496
  }
3300
3497
  }
@@ -3321,6 +3518,7 @@ class HwpxDocument {
3321
3518
  };
3322
3519
  const newElement = { type: 'line', data: newLine };
3323
3520
  section.elements.push(newElement);
3521
+ this.markStructureChanged();
3324
3522
  this.markModified();
3325
3523
  return { id: lineId };
3326
3524
  }
@@ -3341,6 +3539,7 @@ class HwpxDocument {
3341
3539
  };
3342
3540
  const newElement = { type: 'rect', data: newRect };
3343
3541
  section.elements.push(newElement);
3542
+ this.markStructureChanged();
3344
3543
  this.markModified();
3345
3544
  return { id: rectId };
3346
3545
  }
@@ -3361,6 +3560,7 @@ class HwpxDocument {
3361
3560
  };
3362
3561
  const newElement = { type: 'ellipse', data: newEllipse };
3363
3562
  section.elements.push(newElement);
3563
+ this.markStructureChanged();
3364
3564
  this.markModified();
3365
3565
  return { id: ellipseId };
3366
3566
  }
@@ -3381,6 +3581,7 @@ class HwpxDocument {
3381
3581
  };
3382
3582
  const newElement = { type: 'equation', data: newEquation };
3383
3583
  section.elements.splice(afterElementIndex + 1, 0, newElement);
3584
+ this.markStructureChanged();
3384
3585
  this.markModified();
3385
3586
  return { id: equationId };
3386
3587
  }
@@ -3643,10 +3844,20 @@ class HwpxDocument {
3643
3844
  async syncContentToZip() {
3644
3845
  if (!this._zip)
3645
3846
  return;
3646
- // Apply table inserts FIRST (other operations depend on tables existing in XML)
3647
- if (this._pendingTableInserts && this._pendingTableInserts.length > 0) {
3648
- await this.applyTableInsertsToXml();
3847
+ // Replay paragraph/table inserts and paragraph copies/moves together, in
3848
+ // call order, before any text update. Text updates resolve their target in
3849
+ // the current XML, and other operations locate tables by index, so the
3850
+ // structure must already match the memory model.
3851
+ const hasStructuralEdits = this._pendingTableInserts.length > 0 ||
3852
+ this._pendingParagraphInserts.length > 0 ||
3853
+ this._pendingParagraphCopies.length > 0 ||
3854
+ this._pendingParagraphMoves.length > 0;
3855
+ if (hasStructuralEdits) {
3856
+ await this.applyStructuralInsertsToXml();
3649
3857
  this._pendingTableInserts = [];
3858
+ this._pendingParagraphInserts = [];
3859
+ this._pendingParagraphCopies = [];
3860
+ this._pendingParagraphMoves = [];
3650
3861
  }
3651
3862
  // Apply table deletes
3652
3863
  if (this._pendingTableDeletes && this._pendingTableDeletes.length > 0) {
@@ -3663,11 +3874,6 @@ class HwpxDocument {
3663
3874
  await this.applyTableMovesToXml();
3664
3875
  this._pendingTableMoves = [];
3665
3876
  }
3666
- // Apply paragraph inserts
3667
- if (this._pendingParagraphInserts && this._pendingParagraphInserts.length > 0) {
3668
- await this.applyParagraphInsertsToXml();
3669
- this._pendingParagraphInserts = [];
3670
- }
3671
3877
  // Apply table cell updates (preserves original XML structure)
3672
3878
  if (this._pendingTableCellUpdates && this._pendingTableCellUpdates.length > 0) {
3673
3879
  await this.applyTableCellUpdatesToXml();
@@ -3753,16 +3959,6 @@ class HwpxDocument {
3753
3959
  await this.applyTableColumnDeletesToXml();
3754
3960
  this._pendingTableColumnDeletes = [];
3755
3961
  }
3756
- // Apply paragraph copies
3757
- if (this._pendingParagraphCopies && this._pendingParagraphCopies.length > 0) {
3758
- await this.applyParagraphCopiesToXml();
3759
- this._pendingParagraphCopies = [];
3760
- }
3761
- // Apply paragraph moves
3762
- if (this._pendingParagraphMoves && this._pendingParagraphMoves.length > 0) {
3763
- await this.applyParagraphMovesToXml();
3764
- this._pendingParagraphMoves = [];
3765
- }
3766
3962
  // Apply header/footer updates
3767
3963
  if (this._pendingHeaderUpdates && this._pendingHeaderUpdates.length > 0 ||
3768
3964
  this._pendingFooterUpdates && this._pendingFooterUpdates.length > 0) {
@@ -3817,6 +4013,13 @@ class HwpxDocument {
3817
4013
  * The cached positions are populated during parsing in HwpxParser.parseSection().
3818
4014
  */
3819
4015
  getCachedXmlPosition(sectionIndex, elementIndex) {
4016
+ // Cached offsets point into the section XML as it was when parsed. Once any
4017
+ // element has been inserted, removed, copied or moved, the element index no
4018
+ // longer names the same XML node and every earlier offset may have shifted.
4019
+ // Using the cache then rewrites the wrong paragraph — measured: after
4020
+ // copyParagraph on a reopened document, the edit landed on the original.
4021
+ if (this._structureChanged)
4022
+ return undefined;
3820
4023
  const section = this._content?.sections?.[sectionIndex];
3821
4024
  if (!section)
3822
4025
  return undefined;
@@ -4054,143 +4257,345 @@ class HwpxDocument {
4054
4257
  }
4055
4258
  }
4056
4259
  /**
4057
- * Apply table inserts to XML.
4058
- * Inserts new tables into the section XML.
4260
+ * Find the end offset of the section-level element an insert anchors to.
4261
+ *
4262
+ * Paragraphs are matched by their own <hp:p id>. Tables are matched by
4263
+ * <hp:tbl id>, either wrapped in a paragraph (the insert goes after that
4264
+ * paragraph) or placed directly in the section (move_table writes them that
4265
+ * way).
4266
+ *
4267
+ * The returned offset is always the end of a TOP-LEVEL element, because that
4268
+ * is the only place a new section-level element may go. But paragraph
4269
+ * occurrences are counted over exactly the paragraphs the parser puts in the
4270
+ * memory model (see parsedParagraphStarts), so they agree with the occurrence
4271
+ * resolveElementAnchor recorded. The parser also lifts paragraphs out of
4272
+ * headers, text boxes and shapes; Hancom reuses id="0" / id="2147483648"
4273
+ * there too. Counting only top-level paragraphs put 47 of 131 sampled Hancom
4274
+ * files' copies and moves on the wrong paragraph.
4275
+ *
4276
+ * Returns -1 if the anchor is not present in the current XML.
4277
+ */
4278
+ findAnchorEnd(xml, anchor) {
4279
+ const topLevel = this.findTopLevelFullElements(xml);
4280
+ const idOf = (fragment) => fragment.slice(0, fragment.indexOf('>') + 1).match(/\bid="([^"]*)"/)?.[1];
4281
+ if (anchor.kind === 'paragraph') {
4282
+ const hit = this.findParsedParagraph(xml, anchor);
4283
+ if (!hit)
4284
+ return -1;
4285
+ // The anchor may sit inside a header/shape; new content goes after the
4286
+ // top-level element that contains it.
4287
+ const owner = topLevel.find(el => el.startIndex <= hit.start && hit.start < el.endIndex);
4288
+ return owner ? owner.endIndex : -1;
4289
+ }
4290
+ let seen = 0;
4291
+ for (const el of topLevel) {
4292
+ if (el.type === 'tbl') {
4293
+ if (idOf(el.xml) !== anchor.id)
4294
+ continue;
4295
+ }
4296
+ else if (!this.wrapsTopLevelTable(el.xml, anchor.id)) {
4297
+ continue;
4298
+ }
4299
+ if (seen === anchor.occurrence)
4300
+ return el.endIndex;
4301
+ seen++;
4302
+ }
4303
+ return -1;
4304
+ }
4305
+ /**
4306
+ * The exact XML range of the memory paragraph a paragraph anchor names,
4307
+ * found by id + occurrence among parsedParagraphStarts. For a paragraph in a
4308
+ * header or text box this is that paragraph alone, not its container.
4059
4309
  */
4060
- async applyTableInsertsToXml() {
4061
- if (!this._zip)
4062
- return;
4063
- // Group inserts by section
4064
- const insertsBySection = new Map();
4065
- for (const insert of this._pendingTableInserts) {
4066
- const sectionInserts = insertsBySection.get(insert.sectionIndex) || [];
4067
- sectionInserts.push({
4068
- afterElementIndex: insert.afterElementIndex,
4069
- rows: insert.rows,
4070
- cols: insert.cols,
4071
- width: insert.width,
4072
- cellWidth: insert.cellWidth,
4073
- insertOrder: insert.insertOrder,
4074
- tableId: insert.tableId,
4075
- });
4076
- insertsBySection.set(insert.sectionIndex, sectionInserts);
4310
+ findParsedParagraph(xml, anchor) {
4311
+ let seen = 0;
4312
+ for (const start of this.parsedParagraphStarts(xml)) {
4313
+ const openEnd = xml.indexOf('>', start) + 1;
4314
+ const id = xml.slice(start, openEnd).match(/\bid="([^"]*)"/)?.[1];
4315
+ if (id !== anchor.id)
4316
+ continue;
4317
+ if (seen === anchor.occurrence) {
4318
+ const end = this.findBalancedParagraphEnd(xml, start);
4319
+ return end === -1 ? null : { start, end };
4320
+ }
4321
+ seen++;
4077
4322
  }
4078
- // Process each section
4079
- for (const [sectionIndex, inserts] of insertsBySection) {
4080
- const sectionPath = `Contents/section${sectionIndex}.xml`;
4081
- const file = this._zip.file(sectionPath);
4082
- if (!file)
4323
+ return null;
4324
+ }
4325
+ /**
4326
+ * Start offsets (in `xml`) of the paragraphs HwpxParser turns into memory
4327
+ * paragraphs, in document order. Mirrors HwpxParser.parseSection:
4328
+ *
4329
+ * - MEMO fields, footnotes and endnotes are ignored;
4330
+ * - paragraphs inside any table are skipped;
4331
+ * - a paragraph that holds a table is kept only if it still has <hp:t>
4332
+ * once its tables are removed.
4333
+ *
4334
+ * Offsets are mapped back to the original XML, so callers can slice it.
4335
+ */
4336
+ parsedParagraphStarts(xml) {
4337
+ // Ranges the parser strips before it looks for paragraphs.
4338
+ const hidden = [];
4339
+ const hide = (re) => {
4340
+ for (const m of xml.matchAll(re))
4341
+ hidden.push([m.index, m.index + m[0].length]);
4342
+ };
4343
+ hide(/<hp:fieldBegin[^>]*type="MEMO"[^>]*>[\s\S]*?<\/hp:fieldBegin>/gi);
4344
+ hide(/<hp:footNote\b[^>]*>[\s\S]*?<\/hp:footNote>/gi);
4345
+ hide(/<hp:endNote\b[^>]*>[\s\S]*?<\/hp:endNote>/gi);
4346
+ const isHidden = (pos) => hidden.some(([a, b]) => pos >= a && pos < b);
4347
+ const tables = this.findAllTablesDeep(xml).filter(t => !isHidden(t.startIndex));
4348
+ const inTable = (pos) => tables.some(t => pos > t.startIndex && pos < t.endIndex);
4349
+ const starts = [];
4350
+ for (const m of xml.matchAll(/<hp:p\b(?=[\s>])[^>]*>/g)) {
4351
+ const start = m.index;
4352
+ if (isHidden(start) || inTable(start))
4083
4353
  continue;
4084
- let xml = await file.async('string');
4085
- // Get maximum id and instid for generating new ones
4086
- const idMatches = xml.matchAll(/id="(\d+)"/g);
4087
- let maxId = 0;
4088
- for (const m of idMatches) {
4089
- maxId = Math.max(maxId, parseInt(m[1], 10));
4090
- }
4091
- // Sort inserts by insertOrder (ascending) - process in the order they were added
4092
- // This ensures tables are inserted sequentially, building on each other
4093
- const sortedInserts = [...inserts].sort((a, b) => a.insertOrder - b.insertOrder);
4094
- for (const insert of sortedInserts) {
4095
- // Use the in-memory table ID for consistency with updateTableCell operations
4096
- const tableId = insert.tableId;
4097
- // Calculate row height based on standard settings
4098
- const rowHeight = 1000; // Default row height in hwpunit
4099
- const tableHeight = rowHeight * insert.rows;
4100
- // Build table XML
4101
- let tableXml = `<hp:tbl id="${tableId}" zOrder="0" numberingType="TABLE" textWrap="TOP_AND_BOTTOM" textFlow="BOTH_SIDES" lock="0" dropcapstyle="None" pageBreak="CELL" repeatHeader="0" rowCnt="${insert.rows}" colCnt="${insert.cols}" cellSpacing="0" borderFillIDRef="2" noAdjust="0">`;
4102
- tableXml += `<hp:sz width="${insert.width}" widthRelTo="ABSOLUTE" height="${tableHeight}" heightRelTo="ABSOLUTE" protect="0"/>`;
4103
- tableXml += `<hp:pos treatAsChar="1" affectLSpacing="0" flowWithText="1" allowOverlap="0" holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="PARA" vertAlign="TOP" horzAlign="LEFT" vertOffset="0" horzOffset="0"/>`;
4104
- tableXml += `<hp:outMargin left="141" right="141" top="141" bottom="141"/>`;
4105
- tableXml += `<hp:inMargin left="0" right="0" top="0" bottom="0"/>`;
4106
- // Generate rows
4107
- for (let r = 0; r < insert.rows; r++) {
4108
- tableXml += `<hp:tr>`;
4109
- for (let c = 0; c < insert.cols; c++) {
4110
- maxId++;
4111
- const cellParaId = maxId;
4112
- tableXml += `<hp:tc name="" header="0" hasMargin="0" protect="0" editable="0" dirty="0" borderFillIDRef="2">`;
4113
- tableXml += `<hp:subList id="" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="CENTER" linkListIDRef="0" linkListNextIDRef="0" textWidth="0" textHeight="0" hasTextRef="0" hasNumRef="0">`;
4114
- tableXml += `<hp:p id="${cellParaId}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">`;
4115
- tableXml += `<hp:run charPrIDRef="0"><hp:t></hp:t></hp:run>`;
4116
- tableXml += `</hp:p>`;
4117
- tableXml += `</hp:subList>`;
4118
- tableXml += `<hp:cellAddr colAddr="${c}" rowAddr="${r}"/>`;
4119
- tableXml += `<hp:cellSpan colSpan="1" rowSpan="1"/>`;
4120
- tableXml += `<hp:cellSz width="${insert.cellWidth}" height="${rowHeight}"/>`;
4121
- tableXml += `<hp:cellMargin left="141" right="141" top="141" bottom="141"/>`;
4122
- tableXml += `</hp:tc>`;
4123
- }
4124
- tableXml += `</hp:tr>`;
4125
- }
4126
- tableXml += `</hp:tbl>`;
4127
- // Find the position to insert the table
4128
- // We need to insert after a paragraph element
4129
- // Find all <hp:p> elements at the root level (not inside tables)
4130
- const paragraphMatches = [...xml.matchAll(/<hp:p\s[^>]*>.*?<\/hp:p>/gs)];
4131
- // Filter to find only top-level paragraphs (not inside <hp:tbl> or <hp:subList>)
4132
- // For simplicity, insert after the first paragraph if afterElementIndex is 0
4133
- // or find the appropriate position
4134
- let insertPosition = -1;
4135
- let elementCount = -1;
4136
- let searchPos = 0;
4137
- // Find paragraphs and tables at root level using balanced bracket matching
4138
- while (searchPos < xml.length) {
4139
- // Look for next <hp:p or <hp:tbl
4140
- const nextP = xml.indexOf('<hp:p ', searchPos);
4141
- const nextTbl = xml.indexOf('<hp:tbl ', searchPos);
4142
- let nextPos = -1;
4143
- let isTable = false;
4144
- if (nextP !== -1 && (nextTbl === -1 || nextP < nextTbl)) {
4145
- nextPos = nextP;
4146
- isTable = false;
4147
- }
4148
- else if (nextTbl !== -1) {
4149
- nextPos = nextTbl;
4150
- isTable = true;
4151
- }
4152
- if (nextPos === -1)
4153
- break;
4154
- // Check if this is inside a subList (nested)
4155
- const beforeText = xml.substring(Math.max(0, nextPos - HwpxDocument.NESTED_CHECK_LOOKBACK), nextPos);
4156
- const subListOpen = beforeText.lastIndexOf('<hp:subList');
4157
- const subListClose = beforeText.lastIndexOf('</hp:subList>');
4158
- const isNested = subListOpen > subListClose;
4159
- if (!isNested) {
4160
- elementCount++;
4161
- // Find the end of this element using balanced bracket matching
4162
- const endPos = isTable
4163
- ? HwpxDocument.findClosingTagPosition(xml, nextPos + 1, '<hp:tbl', '</hp:tbl>')
4164
- : HwpxDocument.findClosingTagPosition(xml, nextPos + 1, '<hp:p ', '</hp:p>');
4165
- if (endPos === -1) {
4166
- searchPos = nextPos + HwpxDocument.SEARCH_SKIP_OFFSET;
4167
- continue;
4168
- }
4169
- if (elementCount === insert.afterElementIndex) {
4170
- insertPosition = endPos;
4171
- break;
4172
- }
4173
- searchPos = endPos;
4174
- }
4175
- else {
4176
- searchPos = nextPos + HwpxDocument.SEARCH_SKIP_OFFSET;
4177
- }
4354
+ const end = this.findBalancedParagraphEnd(xml, start);
4355
+ if (end === -1)
4356
+ continue;
4357
+ // Remove only the outermost tables in this paragraph. A nested table
4358
+ // is already inside one of them; cutting it again with its original
4359
+ // offsets would slice the wrong text out of the shortened string.
4360
+ const own = tables.filter(t => t.startIndex >= start && t.endIndex <= end &&
4361
+ !tables.some(o => o !== t && o.startIndex >= start && o.startIndex < t.startIndex && o.endIndex > t.endIndex));
4362
+ if (own.length > 0) {
4363
+ let rest = xml.slice(start, end);
4364
+ for (const t of [...own].sort((a, b) => b.startIndex - a.startIndex)) {
4365
+ rest = rest.slice(0, t.startIndex - start) + rest.slice(t.endIndex - start);
4366
+ }
4367
+ if (!/<hp:t\b[^>]*>/.test(rest))
4368
+ continue;
4369
+ }
4370
+ starts.push(start);
4371
+ }
4372
+ return starts;
4373
+ }
4374
+ /** Every <hp:tbl> range at any depth (outer tables before their nested ones). */
4375
+ findAllTablesDeep(xml) {
4376
+ const out = [];
4377
+ for (const m of xml.matchAll(/<hp:tbl\b/g)) {
4378
+ let depth = 1;
4379
+ let pos = m.index + 7;
4380
+ while (depth > 0 && pos < xml.length) {
4381
+ const nextOpen = xml.indexOf('<hp:tbl', pos);
4382
+ const nextClose = xml.indexOf('</hp:tbl>', pos);
4383
+ if (nextClose === -1)
4384
+ break;
4385
+ if (nextOpen !== -1 && nextOpen < nextClose) {
4386
+ depth++;
4387
+ pos = nextOpen + 7;
4178
4388
  }
4179
- // If position not found, insert at end of section (before </hs:sec>)
4180
- if (insertPosition === -1) {
4181
- const secEnd = xml.lastIndexOf('</hs:sec>');
4182
- if (secEnd !== -1) {
4183
- insertPosition = secEnd;
4184
- }
4389
+ else {
4390
+ depth--;
4391
+ pos = nextClose + 9;
4392
+ }
4393
+ }
4394
+ if (depth === 0)
4395
+ out.push({ startIndex: m.index, endIndex: pos });
4396
+ }
4397
+ return out;
4398
+ }
4399
+ /** End offset of the paragraph opening at `start`, counting nested <hp:p>. */
4400
+ findBalancedParagraphEnd(xml, start) {
4401
+ const openRe = /<hp:p\b(?=[\s>/])[^>]*>/g;
4402
+ let depth = 0;
4403
+ let pos = start;
4404
+ while (pos < xml.length) {
4405
+ openRe.lastIndex = pos;
4406
+ const open = openRe.exec(xml);
4407
+ const close = xml.indexOf('</hp:p>', pos);
4408
+ if (close === -1)
4409
+ return -1;
4410
+ if (open && open.index < close) {
4411
+ if (!open[0].endsWith('/>'))
4412
+ depth++;
4413
+ pos = open.index + open[0].length;
4414
+ }
4415
+ else {
4416
+ depth--;
4417
+ pos = close + 7;
4418
+ if (depth === 0)
4419
+ return pos;
4420
+ }
4421
+ }
4422
+ return -1;
4423
+ }
4424
+ /** True if this paragraph directly (not via a nested table) holds <hp:tbl id>. */
4425
+ wrapsTopLevelTable(paragraphXml, tableId) {
4426
+ const escaped = tableId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
4427
+ const match = paragraphXml.match(new RegExp(`<(?:hp|hs|hc):tbl\\b[^>]*\\bid="${escaped}"`));
4428
+ if (!match || match.index === undefined)
4429
+ return false;
4430
+ const before = paragraphXml.slice(0, match.index);
4431
+ const opens = (before.match(/<(?:hp|hs|hc):tbl\b/g) || []).length;
4432
+ const closes = (before.match(/<\/(?:hp|hs|hc):tbl>/g) || []).length;
4433
+ return opens === closes;
4434
+ }
4435
+ /**
4436
+ * Offset for an insert with no anchor ("before everything"). The first
4437
+ * paragraph carries <hp:secPr> (page and section settings) and must stay
4438
+ * first, so new content goes right after it.
4439
+ */
4440
+ findSectionHeadEnd(xml) {
4441
+ const topLevel = this.findTopLevelFullElements(xml);
4442
+ const first = topLevel.find(el => el.type === 'p');
4443
+ if (first)
4444
+ return first.endIndex;
4445
+ const secOpen = xml.match(/<(?:hs|hp):sec[^>]*>/);
4446
+ return secOpen && secOpen.index !== undefined ? secOpen.index + secOpen[0].length : -1;
4447
+ }
4448
+ /** Build the XML for a table inserted by insertTable, wrapped in its own paragraph. */
4449
+ buildInsertedTableXml(insert, nextId) {
4450
+ const rowHeight = 1000; // hwpunit
4451
+ const tableHeight = rowHeight * insert.rows;
4452
+ let tableXml = `<hp:tbl id="${insert.tableId}" zOrder="0" numberingType="TABLE" textWrap="TOP_AND_BOTTOM" textFlow="BOTH_SIDES" lock="0" dropcapstyle="None" pageBreak="CELL" repeatHeader="0" rowCnt="${insert.rows}" colCnt="${insert.cols}" cellSpacing="0" borderFillIDRef="2" noAdjust="0">`;
4453
+ tableXml += `<hp:sz width="${insert.width}" widthRelTo="ABSOLUTE" height="${tableHeight}" heightRelTo="ABSOLUTE" protect="0"/>`;
4454
+ tableXml += `<hp:pos treatAsChar="1" affectLSpacing="0" flowWithText="1" allowOverlap="0" holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="PARA" vertAlign="TOP" horzAlign="LEFT" vertOffset="0" horzOffset="0"/>`;
4455
+ tableXml += `<hp:outMargin left="141" right="141" top="141" bottom="141"/>`;
4456
+ // Cells below use hasMargin="0", which tells Hancom to pad them with this
4457
+ // table-level inMargin and ignore their own cellMargin. A zero inMargin put
4458
+ // text flush against the cell border (measured 0pt). 510/510/141/141 is the
4459
+ // most common value in Hancom-saved tables (1,868 surveyed).
4460
+ tableXml += `<hp:inMargin left="510" right="510" top="141" bottom="141"/>`;
4461
+ for (let r = 0; r < insert.rows; r++) {
4462
+ tableXml += `<hp:tr>`;
4463
+ for (let c = 0; c < insert.cols; c++) {
4464
+ tableXml += `<hp:tc name="" header="0" hasMargin="0" protect="0" editable="0" dirty="0" borderFillIDRef="2">`;
4465
+ tableXml += `<hp:subList id="" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="CENTER" linkListIDRef="0" linkListNextIDRef="0" textWidth="0" textHeight="0" hasTextRef="0" hasNumRef="0">`;
4466
+ tableXml += `<hp:p id="${nextId()}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">`;
4467
+ tableXml += `<hp:run charPrIDRef="0"><hp:t></hp:t></hp:run>`;
4468
+ tableXml += `</hp:p>`;
4469
+ tableXml += `</hp:subList>`;
4470
+ tableXml += `<hp:cellAddr colAddr="${c}" rowAddr="${r}"/>`;
4471
+ tableXml += `<hp:cellSpan colSpan="1" rowSpan="1"/>`;
4472
+ tableXml += `<hp:cellSz width="${insert.cellWidth}" height="${rowHeight}"/>`;
4473
+ tableXml += `<hp:cellMargin left="510" right="510" top="141" bottom="141"/>`;
4474
+ tableXml += `</hp:tc>`;
4475
+ }
4476
+ tableXml += `</hp:tr>`;
4477
+ }
4478
+ tableXml += `</hp:tbl>`;
4479
+ return `<hp:p id="${nextId()}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0"><hp:run charPrIDRef="0">${tableXml}<hp:t></hp:t></hp:run></hp:p>`;
4480
+ }
4481
+ /**
4482
+ * Replay structural edits — paragraph/table inserts and paragraph
4483
+ * copies/moves — into the section XML in the order the calls were made.
4484
+ *
4485
+ * Every edit carries id-based anchors resolved at call time, so it lands
4486
+ * after the same element in the XML that it followed in memory. Replaying in
4487
+ * call order means each anchor already exists (or has already moved) by the
4488
+ * time a later edit needs it. Copies/moves may cross sections, so all
4489
+ * touched sections are held in memory and written once at the end.
4490
+ */
4491
+ async applyStructuralInsertsToXml() {
4492
+ if (!this._zip)
4493
+ return;
4494
+ const edits = [
4495
+ ...this._pendingParagraphInserts.map(i => ({
4496
+ kind: 'paragraph', order: i.insertOrder, sectionIndex: i.sectionIndex,
4497
+ anchor: i.anchor, paragraphId: i.paragraphId, text: i.text,
4498
+ })),
4499
+ ...this._pendingTableInserts.map(i => ({
4500
+ kind: 'table', order: i.insertOrder, sectionIndex: i.sectionIndex,
4501
+ anchor: i.anchor, rows: i.rows, cols: i.cols, width: i.width,
4502
+ cellWidth: i.cellWidth, tableId: i.tableId,
4503
+ })),
4504
+ ...this._pendingParagraphCopies.map(c => ({
4505
+ kind: 'copy', order: c.insertOrder, sectionIndex: c.targetSection,
4506
+ sourceSection: c.sourceSection, source: c.source, anchor: c.anchor, paragraphId: c.paragraphId,
4507
+ })),
4508
+ ...this._pendingParagraphMoves.map(m => ({
4509
+ kind: 'move', order: m.insertOrder, sectionIndex: m.targetSection,
4510
+ sourceSection: m.sourceSection, source: m.source, anchor: m.anchor,
4511
+ })),
4512
+ ].sort((a, b) => a.order - b.order);
4513
+ if (edits.length === 0)
4514
+ return;
4515
+ const sections = new Map();
4516
+ const load = async (index) => {
4517
+ if (sections.has(index))
4518
+ return sections.get(index);
4519
+ const file = this._zip.file(`Contents/section${index}.xml`);
4520
+ if (!file)
4521
+ return undefined;
4522
+ const xml = await file.async('string');
4523
+ sections.set(index, xml);
4524
+ return xml;
4525
+ };
4526
+ // Numeric ids for generated cell/wrapper paragraphs must not collide.
4527
+ const maxIdBySection = new Map();
4528
+ const nextIdFor = (index, xml) => {
4529
+ if (!maxIdBySection.has(index)) {
4530
+ let maxId = 0;
4531
+ for (const m of xml.matchAll(/\bid="(\d+)"/g)) {
4532
+ const n = parseInt(m[1], 10);
4533
+ if (n < 2147483648 && n > maxId)
4534
+ maxId = n;
4535
+ }
4536
+ maxIdBySection.set(index, maxId);
4537
+ }
4538
+ return () => {
4539
+ const next = maxIdBySection.get(index) + 1;
4540
+ maxIdBySection.set(index, next);
4541
+ return next;
4542
+ };
4543
+ };
4544
+ const placeAfter = (xml, anchor) => {
4545
+ const position = anchor ? this.findAnchorEnd(xml, anchor) : this.findSectionHeadEnd(xml);
4546
+ if (position !== -1)
4547
+ return position;
4548
+ // The anchor vanished (e.g. deleted later in the same session) —
4549
+ // append rather than drop the user's content.
4550
+ return Math.max(xml.lastIndexOf('</hs:sec>'), xml.lastIndexOf('</hp:sec>'));
4551
+ };
4552
+ for (const edit of edits) {
4553
+ if (edit.kind === 'copy' || edit.kind === 'move') {
4554
+ const srcXml = await load(edit.sourceSection);
4555
+ if (srcXml === undefined)
4556
+ continue;
4557
+ // Only a section-level paragraph can be copied or moved as a unit; a
4558
+ // paragraph inside a header or text box would drag its container along.
4559
+ const found = this.findParsedParagraph(srcXml, edit.source);
4560
+ if (!found)
4561
+ continue;
4562
+ const srcEl = this.findTopLevelFullElements(srcXml)
4563
+ .find(el => el.type === 'p' && el.startIndex === found.start && el.endIndex === found.end);
4564
+ if (!srcEl)
4565
+ continue;
4566
+ let fragment = srcEl.xml;
4567
+ if (edit.kind === 'copy') {
4568
+ // Same id as the memory copy, so later edits anchored on it find it.
4569
+ fragment = fragment.replace(/^<(hp|hs):p\b([^>]*?)\bid="[^"]*"/, `<$1:p$2id="${edit.paragraphId}"`);
4570
+ // The clone inherits the source's fixed <hp:lineseg> geometry; reset
4571
+ // it so replacement text of a different length does not overlap.
4572
+ fragment = this.resetLinesegInXml(fragment);
4185
4573
  }
4186
- if (insertPosition !== -1) {
4187
- // Wrap table in a paragraph for proper positioning
4188
- const wrapperXml = `<hp:p id="${maxId + 1}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0"><hp:run charPrIDRef="0">${tableXml}<hp:t></hp:t></hp:run></hp:p>`;
4189
- maxId++;
4190
- xml = xml.substring(0, insertPosition) + wrapperXml + xml.substring(insertPosition);
4574
+ else {
4575
+ sections.set(edit.sourceSection, srcXml.slice(0, srcEl.startIndex) + srcXml.slice(srcEl.endIndex));
4191
4576
  }
4577
+ const tgtXml = await load(edit.sectionIndex);
4578
+ if (tgtXml === undefined)
4579
+ continue;
4580
+ const position = placeAfter(tgtXml, edit.anchor);
4581
+ if (position === -1)
4582
+ continue;
4583
+ sections.set(edit.sectionIndex, tgtXml.slice(0, position) + fragment + tgtXml.slice(position));
4584
+ continue;
4192
4585
  }
4193
- this._zip.file(sectionPath, xml);
4586
+ const xml = await load(edit.sectionIndex);
4587
+ if (xml === undefined)
4588
+ continue;
4589
+ const position = placeAfter(xml, edit.anchor);
4590
+ if (position === -1)
4591
+ continue;
4592
+ const newXml = edit.kind === 'paragraph'
4593
+ ? `<hp:p id="${edit.paragraphId}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0"><hp:run charPrIDRef="0"><hp:t>${this.escapeXml(edit.text)}</hp:t></hp:run></hp:p>`
4594
+ : this.buildInsertedTableXml(edit, nextIdFor(edit.sectionIndex, xml));
4595
+ sections.set(edit.sectionIndex, xml.slice(0, position) + newXml + xml.slice(position));
4596
+ }
4597
+ for (const [index, xml] of sections) {
4598
+ this._zip.file(`Contents/section${index}.xml`, xml);
4194
4599
  }
4195
4600
  }
4196
4601
  /**
@@ -4330,111 +4735,6 @@ class HwpxDocument {
4330
4735
  // Return position after the element at afterIndex
4331
4736
  return elements[afterIndex].end;
4332
4737
  }
4333
- /**
4334
- * Apply paragraph inserts to XML.
4335
- * Inserts new paragraphs at the specified positions.
4336
- */
4337
- async applyParagraphInsertsToXml() {
4338
- if (!this._zip)
4339
- return;
4340
- // Group inserts by section
4341
- const insertsBySection = new Map();
4342
- for (const insert of this._pendingParagraphInserts) {
4343
- const sectionInserts = insertsBySection.get(insert.sectionIndex) || [];
4344
- sectionInserts.push({
4345
- afterElementIndex: insert.afterElementIndex,
4346
- paragraphId: insert.paragraphId,
4347
- text: insert.text,
4348
- });
4349
- insertsBySection.set(insert.sectionIndex, sectionInserts);
4350
- }
4351
- // Process each section
4352
- for (const [sectionIndex, inserts] of insertsBySection) {
4353
- const sectionPath = `Contents/section${sectionIndex}.xml`;
4354
- const file = this._zip.file(sectionPath);
4355
- if (!file)
4356
- continue;
4357
- let xml = await file.async('string');
4358
- // Sort inserts by afterElementIndex in ascending order
4359
- // This ensures each insert happens at the correct position as XML grows
4360
- const sortedInserts = [...inserts].sort((a, b) => a.afterElementIndex - b.afterElementIndex);
4361
- for (const insert of sortedInserts) {
4362
- // Escape text for XML
4363
- const escapedText = this.escapeXml(insert.text);
4364
- // Build paragraph XML
4365
- const paragraphXml = `<hp:p id="${insert.paragraphId}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0"><hp:run charPrIDRef="0"><hp:t>${escapedText}</hp:t></hp:run></hp:p>`;
4366
- // Find the position to insert
4367
- let insertPosition = -1;
4368
- let elementCount = -1;
4369
- let searchPos = 0;
4370
- // Find paragraphs and tables at root level using balanced bracket matching
4371
- while (searchPos < xml.length) {
4372
- // Look for next <hp:p or <hp:tbl
4373
- const nextP = xml.indexOf('<hp:p ', searchPos);
4374
- const nextTbl = xml.indexOf('<hp:tbl ', searchPos);
4375
- let nextPos = -1;
4376
- let isTable = false;
4377
- if (nextP !== -1 && (nextTbl === -1 || nextP < nextTbl)) {
4378
- nextPos = nextP;
4379
- isTable = false;
4380
- }
4381
- else if (nextTbl !== -1) {
4382
- nextPos = nextTbl;
4383
- isTable = true;
4384
- }
4385
- if (nextPos === -1)
4386
- break;
4387
- // Check if this is inside a subList (nested)
4388
- const beforeText = xml.substring(Math.max(0, nextPos - HwpxDocument.NESTED_CHECK_LOOKBACK), nextPos);
4389
- const subListOpen = beforeText.lastIndexOf('<hp:subList');
4390
- const subListClose = beforeText.lastIndexOf('</hp:subList>');
4391
- const isNested = subListOpen > subListClose;
4392
- if (!isNested) {
4393
- elementCount++;
4394
- // Find the end of this element using balanced bracket matching
4395
- const endPos = isTable
4396
- ? HwpxDocument.findClosingTagPosition(xml, nextPos + 1, '<hp:tbl', '</hp:tbl>')
4397
- : HwpxDocument.findClosingTagPosition(xml, nextPos + 1, '<hp:p ', '</hp:p>');
4398
- if (endPos === -1) {
4399
- searchPos = nextPos + HwpxDocument.SEARCH_SKIP_OFFSET;
4400
- continue;
4401
- }
4402
- if (elementCount === insert.afterElementIndex) {
4403
- insertPosition = endPos;
4404
- break;
4405
- }
4406
- searchPos = endPos;
4407
- }
4408
- else {
4409
- searchPos = nextPos + HwpxDocument.SEARCH_SKIP_OFFSET;
4410
- }
4411
- }
4412
- // If afterElementIndex is -1, insert after the first paragraph (which contains secPr)
4413
- // IMPORTANT: <hp:secPr> must remain in the first paragraph for the document to be valid
4414
- if (insert.afterElementIndex === -1) {
4415
- // Find the end of the first <hp:p> element (which contains <hp:secPr>)
4416
- const firstPStart = xml.indexOf('<hp:p');
4417
- if (firstPStart !== -1) {
4418
- const firstPEnd = xml.indexOf('</hp:p>', firstPStart);
4419
- if (firstPEnd !== -1) {
4420
- insertPosition = firstPEnd + '</hp:p>'.length;
4421
- }
4422
- }
4423
- }
4424
- // If position not found, insert at end of section (before </hs:sec>)
4425
- if (insertPosition === -1) {
4426
- const secEnd = xml.lastIndexOf('</hs:sec>');
4427
- if (secEnd !== -1) {
4428
- insertPosition = secEnd;
4429
- }
4430
- }
4431
- if (insertPosition !== -1) {
4432
- xml = xml.substring(0, insertPosition) + paragraphXml + xml.substring(insertPosition);
4433
- }
4434
- }
4435
- this._zip.file(sectionPath, xml);
4436
- }
4437
- }
4438
4738
  /**
4439
4739
  * Apply nested table inserts to XML.
4440
4740
  * Inserts a new table inside a cell of an existing table.
@@ -4506,8 +4806,11 @@ class HwpxDocument {
4506
4806
  if (insert.col >= cells.length)
4507
4807
  continue;
4508
4808
  const cellXml = cells[insert.col].xml;
4509
- // Generate nested table XML
4510
- const nestedTableXml = this.generateNestedTableXml(insert.nestedRows, insert.nestedCols, insert.data);
4809
+ // Size the nested table to the parent cell. A fixed per-cell width
4810
+ // ignored the parent and pushed columns past its border (measured:
4811
+ // 3 × 8000 = 24000 inside a 21260-wide cell).
4812
+ const innerWidth = this.getCellInnerWidth(cellXml, tableXml);
4813
+ const nestedTableXml = this.generateNestedTableXml(insert.nestedRows, insert.nestedCols, insert.data, innerWidth);
4511
4814
  // Insert nested table into cell
4512
4815
  const updatedCellXml = this.insertNestedTableIntoCell(cellXml, nestedTableXml);
4513
4816
  // Update the row with the new cell
@@ -4538,14 +4841,54 @@ class HwpxDocument {
4538
4841
  /**
4539
4842
  * Generate XML for a nested table.
4540
4843
  */
4541
- generateNestedTableXml(rows, cols, data) {
4844
+ /**
4845
+ * Usable width inside a table cell, in hwpunit.
4846
+ *
4847
+ * A cell with hasMargin="0" takes its padding from the table's inMargin, so
4848
+ * the cell's own cellMargin is only authoritative when hasMargin="1".
4849
+ *
4850
+ * Every lookup is scoped to the cell's (or table's) own markup. A nested
4851
+ * table inside the cell carries its own cellSz/cellMargin/inMargin, and a
4852
+ * first-match regex over the whole cell would read those instead — which
4853
+ * sized a second nested table to the first one's column (7086 vs 21260).
4854
+ */
4855
+ getCellInnerWidth(cellXml, tableXml) {
4856
+ // hp:tc children are subList → cellAddr → cellSpan → cellSz → cellMargin,
4857
+ // so the cell's own properties are everything after its last </hp:subList>.
4858
+ const subListEnd = cellXml.lastIndexOf('</hp:subList>');
4859
+ const cellProps = subListEnd === -1 ? cellXml : cellXml.slice(subListEnd);
4860
+ const size = cellProps.match(/<hp:cellSz width="(\d+)"/);
4861
+ if (!size)
4862
+ return null;
4863
+ const width = parseInt(size[1], 10);
4864
+ const openTag = cellXml.slice(0, cellXml.indexOf('>') + 1);
4865
+ const usesOwnMargin = /\bhasMargin="1"/.test(openTag);
4866
+ // Table-level inMargin precedes the first row.
4867
+ const firstRow = tableXml.indexOf('<hp:tr');
4868
+ const tableHead = firstRow === -1 ? tableXml : tableXml.slice(0, firstRow);
4869
+ const margin = usesOwnMargin
4870
+ ? cellProps.match(/<hp:cellMargin left="(\d+)" right="(\d+)"/)
4871
+ : tableHead.match(/<hp:inMargin left="(\d+)" right="(\d+)"/);
4872
+ const padding = margin ? parseInt(margin[1], 10) + parseInt(margin[2], 10) : 0;
4873
+ return Math.max(width - padding, 0);
4874
+ }
4875
+ /**
4876
+ * Generate XML for a nested table.
4877
+ *
4878
+ * @param innerWidth Usable width of the parent cell in hwpunit. The nested
4879
+ * table is sized to fit it exactly; columns share the width evenly.
4880
+ */
4881
+ generateNestedTableXml(rows, cols, data, innerWidth = null) {
4542
4882
  // Generate unique ID
4543
4883
  const id = Math.floor(Math.random() * 2000000000) + 100000000;
4544
4884
  const zOrder = Math.floor(Math.random() * 100);
4545
- // Calculate sizes (in hwpunit, 1 hwpunit = 0.1mm)
4546
- const cellWidth = 8000; // ~80mm per cell
4885
+ // Calculate sizes (hwpunit, 100 = 1pt). Without a parent width fall back to
4886
+ // the previous fixed column width so standalone callers keep working.
4887
+ const tableWidth = innerWidth !== null && innerWidth > 0 ? innerWidth : 8000 * cols;
4888
+ const baseCellWidth = Math.floor(tableWidth / cols);
4889
+ // Give the rounding remainder to the last column so the columns sum to tableWidth.
4890
+ const cellWidthAt = (c) => (c === cols - 1 ? tableWidth - baseCellWidth * (cols - 1) : baseCellWidth);
4547
4891
  const cellHeight = 1400; // ~14mm per cell
4548
- const tableWidth = cellWidth * cols;
4549
4892
  const tableHeight = cellHeight * rows;
4550
4893
  let xml = `<hp:tbl id="${id}" zOrder="${zOrder}" numberingType="TABLE" textWrap="TOP_AND_BOTTOM" textFlow="BOTH_SIDES" lock="0" dropcapstyle="None" pageBreak="NONE" repeatHeader="0" rowCnt="${rows}" colCnt="${cols}" cellSpacing="0" borderFillIDRef="2" noAdjust="0">`;
4551
4894
  // Size element
@@ -4578,7 +4921,7 @@ class HwpxDocument {
4578
4921
  xml += `</hp:subList>`;
4579
4922
  xml += `<hp:cellAddr colAddr="${c}" rowAddr="${r}"/>`;
4580
4923
  xml += `<hp:cellSpan colSpan="1" rowSpan="1"/>`;
4581
- xml += `<hp:cellSz width="${cellWidth}" height="${cellHeight}"/>`;
4924
+ xml += `<hp:cellSz width="${cellWidthAt(c)}" height="${cellHeight}"/>`;
4582
4925
  xml += `<hp:cellMargin left="141" right="141" top="141" bottom="141"/>`;
4583
4926
  xml += `</hp:tc>`;
4584
4927
  }
@@ -4599,7 +4942,7 @@ class HwpxDocument {
4599
4942
  const pMatch = cellXml.match(/<hp:p[^>]*>/);
4600
4943
  if (pMatch) {
4601
4944
  const insertPos = cellXml.indexOf(pMatch[0]) + pMatch[0].length;
4602
- const runXml = `<hp:run charPrIDRef="0"><hp:t> </hp:t>${nestedTableXml}<hp:t/></hp:run>`;
4945
+ const runXml = `<hp:run charPrIDRef="0">${nestedTableXml}<hp:t/></hp:run>`;
4603
4946
  return cellXml.substring(0, insertPos) + runXml + cellXml.substring(insertPos);
4604
4947
  }
4605
4948
  return cellXml;
@@ -4618,8 +4961,10 @@ class HwpxDocument {
4618
4961
  return cellXml;
4619
4962
  // Find the end of the opening <hp:p ...> tag
4620
4963
  const pTagEnd = cellXml.indexOf('>', pStart) + 1;
4621
- // Create new run with nested table
4622
- const runXml = `<hp:run charPrIDRef="0"><hp:t> </hp:t>${nestedTableXml}<hp:t/></hp:run>`;
4964
+ // Create new run with nested table. No leading text: a space before an
4965
+ // inline (treatAsChar) table that fills the cell width forces the table onto
4966
+ // a second line and leaves an empty first line above it (measured in Hancom).
4967
+ const runXml = `<hp:run charPrIDRef="0">${nestedTableXml}<hp:t/></hp:run>`;
4623
4968
  // Insert after the opening <hp:p> tag
4624
4969
  return cellXml.substring(0, pTagEnd) + runXml + cellXml.substring(pTagEnd);
4625
4970
  }
@@ -6106,9 +6451,31 @@ class HwpxDocument {
6106
6451
  async applyDirectTextUpdatesToXml() {
6107
6452
  if (!this._zip)
6108
6453
  return;
6454
+ // Re-anchor every update on the memory paragraph it edits. The element
6455
+ // index and id-occurrence recorded at call time are stale once a later
6456
+ // insert/delete/copy/move reshapes the section: the frozen occurrence then
6457
+ // names another same-id paragraph (measured: [A,B,C] all id="0", edit B,
6458
+ // move C to the front → A was rewritten). At this point the memory model
6459
+ // matches the XML, whose structural edits were already replayed.
6460
+ for (const update of this._pendingDirectTextUpdates) {
6461
+ if (!update.paragraph)
6462
+ continue;
6463
+ const elements = this._content.sections[update.sectionIndex]?.elements ?? [];
6464
+ const now = elements.findIndex(e => e.type === 'paragraph' && e.data === update.paragraph);
6465
+ if (now === -1) {
6466
+ // The paragraph was deleted after the edit; there is nothing to write.
6467
+ update.elementIndex = -1;
6468
+ continue;
6469
+ }
6470
+ update.elementIndex = now;
6471
+ update.paragraphId = update.paragraph.id || '';
6472
+ update.paragraphOccurrence = this.getParagraphOccurrence(update.sectionIndex, now, update.paragraphId);
6473
+ }
6109
6474
  // Group updates by sectionIndex, then by elementIndex
6110
6475
  const updatesBySectionAndElement = new Map();
6111
6476
  for (const update of this._pendingDirectTextUpdates) {
6477
+ if (update.elementIndex < 0)
6478
+ continue;
6112
6479
  let sectionMap = updatesBySectionAndElement.get(update.sectionIndex);
6113
6480
  if (!sectionMap) {
6114
6481
  sectionMap = new Map();
@@ -6170,11 +6537,11 @@ class HwpxDocument {
6170
6537
  // Sort by runIndex to process in order
6171
6538
  updates.sort((a, b) => a.runIndex - b.runIndex);
6172
6539
  // Apply the update directly using pre-computed target location
6173
- if (updates.length > 1) {
6540
+ if (updates.length > 1 || /<hp:t\b/.test(target.xml)) {
6174
6541
  xml = this.replaceRunsInParagraphDirect(xml, target, updates);
6175
6542
  }
6176
6543
  else {
6177
- // For single update, use the existing method with pre-computed target
6544
+ // Empty runs without text tags need a new hp:t element.
6178
6545
  xml = this.replaceTextInElementDirect(xml, target, updates[0].oldText, updates[0].newText);
6179
6546
  }
6180
6547
  }
@@ -6483,27 +6850,14 @@ class HwpxDocument {
6483
6850
  return xml.slice(0, targetInOriginal.start) + newParagraphXml + xml.slice(targetInOriginal.end);
6484
6851
  }
6485
6852
  /**
6486
- * Calculate the occurrence index for a paragraph with given ID.
6487
- * Returns how many paragraphs with the same ID appear before this one.
6853
+ * Occurrence index of the paragraph at `elementIndex` among paragraphs with
6854
+ * the same id — counted with the same rule as insert anchors
6855
+ * (resolveElementAnchor), so a text update finds the paragraph that
6856
+ * findParsedParagraph resolves. Divider paragraphs parsed as 'hr' count.
6488
6857
  */
6489
6858
  getParagraphOccurrence(sectionIndex, elementIndex, paragraphId) {
6490
- // Use _content.sections (same as findParagraphByPath) instead of _sections
6491
- if (!this._content || !this._content.sections || !this._content.sections[sectionIndex])
6492
- return 0;
6493
- const section = this._content.sections[sectionIndex];
6494
- if (!section || !section.elements)
6495
- return 0;
6496
- let occurrenceCount = 0;
6497
- for (let i = 0; i < elementIndex; i++) {
6498
- const element = section.elements[i];
6499
- if (element && element.type === 'paragraph') { // Use 'paragraph' not 'p'
6500
- const para = element.data;
6501
- if (para.id === paragraphId) {
6502
- occurrenceCount++;
6503
- }
6504
- }
6505
- }
6506
- return occurrenceCount;
6859
+ const anchor = this.resolveElementAnchor(sectionIndex, elementIndex);
6860
+ return anchor && anchor.kind === 'paragraph' && anchor.id === paragraphId ? anchor.occurrence : 0;
6507
6861
  }
6508
6862
  /**
6509
6863
  * Find paragraph by its ID attribute and occurrence index.
@@ -6799,18 +7153,22 @@ class HwpxDocument {
6799
7153
  // TIER 1: ID-based lookup (most reliable)
6800
7154
  // TIER 2: Index-based lookup with text validation
6801
7155
  // TIER 3: Fuzzy text matching fallback
6802
- // TIER 1: ID-based lookup - DISABLED
6803
- // Problem: XML counting includes nested paragraphs (inside tables),
6804
- // but _content.sections.elements only has top-level elements.
6805
- // This mismatch causes wrong paragraph selection.
6806
- // Solution: Skip ID-based lookup and use index-based (TIER 2) instead.
7156
+ // TIER 1: id + occurrence, counted with the parser's paragraph rule.
6807
7157
  //
6808
- // if (paragraphId) {
6809
- // const idBasedTarget = this.findParagraphById(xml, paragraphId, paragraphOccurrence ?? 0);
6810
- // if (idBasedTarget) {
6811
- // return idBasedTarget;
6812
- // }
6813
- // }
7158
+ // This was disabled because counting every <hp:p> in the XML included cell
7159
+ // paragraphs the memory model does not have. findAnchorEnd counts only
7160
+ // top-level paragraphs the parser keeps, so the occurrence recorded from
7161
+ // the memory model names the same node. Index lookup (TIER 2) is wrong
7162
+ // after a copy: its ±2 text search finds the original first because the
7163
+ // copy carries the same text, and the edit lands on the original.
7164
+ if (paragraphId) {
7165
+ // The paragraph's OWN range. For a paragraph inside a header or text
7166
+ // box, the enclosing top-level paragraph would rewrite the whole body.
7167
+ const hit = this.findParsedParagraph(xml, { kind: 'paragraph', id: paragraphId, occurrence: paragraphOccurrence ?? 0 });
7168
+ if (hit) {
7169
+ return { start: hit.start, end: hit.end, xml: xml.slice(hit.start, hit.end) };
7170
+ }
7171
+ }
6814
7172
  // Calculate paragraph index using _content.sections.elements (same source as elementIndex)
6815
7173
  // This ensures consistency between elementIndex and paragraph counting
6816
7174
  let topLevelParagraphIndex = 0;
@@ -6911,27 +7269,43 @@ class HwpxDocument {
6911
7269
  // Filter to only runs that have <hp:t> content (matching memory model behavior)
6912
7270
  // Memory model only counts runs with text, not runs with only <hp:ctrl> etc.
6913
7271
  const textRuns = runs.filter(run => /<hp:t\b/.test(run.xml) || /<hp:t\s*\/>/.test(run.xml));
7272
+ // The parser creates a model run per non-empty hp:t, not per hp:run.
7273
+ // Merge those updates back into their shared XML run without losing a suffix.
7274
+ const xmlRunUpdates = new Map();
7275
+ let modelRunIndex = 0;
7276
+ for (let i = 0; i < textRuns.length; i++) {
7277
+ const textNodes = [...textRuns[i].xml.matchAll(/<hp:t\b[^>]*>([^<]+)<\/hp:t>/g)];
7278
+ const count = Math.max(1, textNodes.length);
7279
+ let changed = false;
7280
+ let escapedText = '';
7281
+ for (let offset = 0; offset < count; offset++) {
7282
+ const index = modelRunIndex + offset;
7283
+ if (updateMap.has(index)) {
7284
+ escapedText += this.escapeXml(updateMap.get(index));
7285
+ changed = true;
7286
+ }
7287
+ else {
7288
+ escapedText += textNodes[offset]?.[1] || '';
7289
+ }
7290
+ }
7291
+ if (changed)
7292
+ xmlRunUpdates.set(i, escapedText);
7293
+ modelRunIndex += count;
7294
+ }
6914
7295
  // Process text runs in reverse order to maintain positions
6915
7296
  for (let i = textRuns.length - 1; i >= 0; i--) {
6916
- if (!updateMap.has(i))
7297
+ if (!xmlRunUpdates.has(i))
6917
7298
  continue;
6918
7299
  const run = textRuns[i];
6919
- const newText = updateMap.get(i);
6920
- const escapedNew = this.escapeXml(newText);
7300
+ const escapedNew = xmlRunUpdates.get(i);
6921
7301
  let newRunXml = run.xml;
6922
- // Find and replace hp:t content within this run
6923
- if (/<hp:t\s*\/>/.test(newRunXml)) {
6924
- // Self-closing tag: <hp:t/> -> <hp:t>newText</hp:t>
6925
- newRunXml = newRunXml.replace(/<hp:t\s*\/>/, `<hp:t>${escapedNew}</hp:t>`);
6926
- }
6927
- else if (/<hp:t\b[^>]*>/.test(newRunXml)) {
6928
- // Has content: replace first hp:t content only
6929
- newRunXml = newRunXml.replace(/(<hp:t\b[^>]*>)[^<]*(<\/hp:t>)/, `$1${escapedNew}$2`);
6930
- }
6931
- else {
6932
- // No hp:t tag - add one after the opening hp:run tag
6933
- newRunXml = newRunXml.replace(/(<hp:run\b[^>]*>)/, `$1<hp:t>${escapedNew}</hp:t>`);
6934
- }
7302
+ // Write each XML run's combined text once, preserving text-tag attributes.
7303
+ let textWritten = false;
7304
+ newRunXml = newRunXml.replace(/<hp:t\b([^>]*?)\/>|<hp:t\b([^>]*)>[^<]*<\/hp:t>/g, (_match, selfClosingAttrs, attrs) => {
7305
+ const text = textWritten ? '' : escapedNew;
7306
+ textWritten = true;
7307
+ return `<hp:t${selfClosingAttrs ?? attrs ?? ''}>${text}</hp:t>`;
7308
+ });
6935
7309
  // Replace in paragraph XML
6936
7310
  paragraphXml = paragraphXml.slice(0, run.start) + newRunXml + paragraphXml.slice(run.end);
6937
7311
  }
@@ -10284,6 +10658,35 @@ class HwpxDocument {
10284
10658
  // ============================================================
10285
10659
  // Table Row Insert/Delete XML Persistence
10286
10660
  // ============================================================
10661
+ /**
10662
+ * Clone a table cell for a newly inserted row: same cell attributes, same
10663
+ * first-paragraph formatting, but a single paragraph holding `text`.
10664
+ *
10665
+ * Nested tables and extra paragraphs are dropped. The first run's
10666
+ * charPrIDRef is kept so the new text matches the template cell's font.
10667
+ */
10668
+ cloneCellWithText(cellXml, text) {
10669
+ const subListOpen = cellXml.match(/<(hp|hs):subList\b[^>]*>/);
10670
+ const subListCloseIdx = cellXml.lastIndexOf('</hp:subList>') !== -1
10671
+ ? cellXml.lastIndexOf('</hp:subList>')
10672
+ : cellXml.lastIndexOf('</hs:subList>');
10673
+ if (!subListOpen || subListOpen.index === undefined || subListCloseIdx === -1) {
10674
+ // No sub-list to rebuild — fall back to blanking the text in place.
10675
+ return this.resetLinesegInXml(cellXml.replace(T_TAG_WITH_CONTENT, '<$1:t$2></$1:t>'));
10676
+ }
10677
+ const prefix = subListOpen[1];
10678
+ const inner = cellXml.slice(subListOpen.index + subListOpen[0].length, subListCloseIdx);
10679
+ const firstPara = inner.match(new RegExp(`<${prefix}:p\\b[^>]*>`));
10680
+ const paraOpen = firstPara
10681
+ ? firstPara[0]
10682
+ : `<${prefix}:p id="0" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">`;
10683
+ const firstRun = inner.match(new RegExp(`<${prefix}:run\\b[^>]*charPrIDRef="(\\d+)"`));
10684
+ const charPr = firstRun ? firstRun[1] : '0';
10685
+ const paragraph = `${paraOpen}<${prefix}:run charPrIDRef="${charPr}"><${prefix}:t>${this.escapeXml(text)}</${prefix}:t></${prefix}:run>` +
10686
+ `<${prefix}:linesegarray><${prefix}:lineseg textpos="0" vertpos="0" vertsize="1000" textheight="1000" baseline="850" spacing="600" horzpos="0" horzsize="0" flags="0"/></${prefix}:linesegarray>` +
10687
+ `</${prefix}:p>`;
10688
+ return cellXml.slice(0, subListOpen.index + subListOpen[0].length) + paragraph + cellXml.slice(subListCloseIdx);
10689
+ }
10287
10690
  async applyTableRowInsertsToXml() {
10288
10691
  if (!this._zip)
10289
10692
  return;
@@ -10310,26 +10713,22 @@ class HwpxDocument {
10310
10713
  if (insert.afterRowIndex >= rows.length)
10311
10714
  continue;
10312
10715
  const templateRow = rows[insert.afterRowIndex];
10313
- // Clone the template row - clear text content but preserve XML structure
10716
+ // Clone the template row cell by cell. Each new cell keeps the
10717
+ // template cell's formatting but only its FIRST paragraph, emptied:
10718
+ // cloning every paragraph copied multi-line cells (e.g. "○ a\n○ b\n- c")
10719
+ // as three empty lines, so Hancom sized the row for three lines and the
10720
+ // one line of new text sat at the top.
10721
+ const newRowAddr = insert.afterRowIndex + 1;
10722
+ const templateCells = this.findAllElementsWithDepth(templateRow.xml, 'tc');
10314
10723
  let newRowXml = templateRow.xml;
10315
- // Clear text inside <hp:t> and <hs:t> tags but preserve the tags themselves
10316
- newRowXml = newRowXml.replace(/<(hp|hs):t([^>]*)>[\s\S]*?<\/\1:t>/g, '<$1:t$2></$1:t>');
10724
+ for (let c = templateCells.length - 1; c >= 0; c--) {
10725
+ const cell = templateCells[c];
10726
+ const text = insert.cellTexts?.[c] ?? '';
10727
+ const newCellXml = this.cloneCellWithText(cell.xml, text);
10728
+ newRowXml = newRowXml.slice(0, cell.startIndex) + newCellXml + newRowXml.slice(cell.endIndex);
10729
+ }
10317
10730
  // Update rowAddr in each cell
10318
- const newRowAddr = insert.afterRowIndex + 1;
10319
10731
  newRowXml = newRowXml.replace(/rowAddr="(\d+)"/g, `rowAddr="${newRowAddr}"`);
10320
- // Set cell texts if provided
10321
- if (insert.cellTexts) {
10322
- let cellIdx = 0;
10323
- newRowXml = newRowXml.replace(/<(hp|hs):t([^>]*)><\/\1:t>/g, (match, prefix, attrs) => {
10324
- if (cellIdx < insert.cellTexts.length) {
10325
- const text = this.escapeXml(insert.cellTexts[cellIdx]);
10326
- cellIdx++;
10327
- return `<${prefix}:t${attrs}>${text}</${prefix}:t>`;
10328
- }
10329
- cellIdx++;
10330
- return match;
10331
- });
10332
- }
10333
10732
  // Insert after the template row
10334
10733
  const insertPos = templateRow.startIndex + templateRow.xml.length;
10335
10734
  const newTableXml = tableXml.substring(0, insertPos) + '\n' + newRowXml + tableXml.substring(insertPos);
@@ -10480,9 +10879,11 @@ class HwpxDocument {
10480
10879
  }
10481
10880
  if (!templateCell)
10482
10881
  continue;
10483
- // Clone template and clear text
10882
+ // Clone template and clear text.
10883
+ // The tag-name boundary in T_TAG_WITH_CONTENT keeps <hp:tc> structure intact.
10484
10884
  let newCellXml = templateCell.xml;
10485
- newCellXml = newCellXml.replace(/<(hp|hs):t([^>]*)>[\s\S]*?<\/\1:t>/g, '<$1:t$2></$1:t>');
10885
+ newCellXml = newCellXml.replace(T_TAG_WITH_CONTENT, '<$1:t$2></$1:t>');
10886
+ newCellXml = this.resetLinesegInXml(newCellXml);
10486
10887
  // Update colAddr to afterColIndex + 1
10487
10888
  newCellXml = newCellXml.replace(/colAddr="(\d+)"/, `colAddr="${insert.afterColIndex + 1}"`);
10488
10889
  // Also update <hp:cellAddr colAddr="..."> inside the cell
@@ -10582,12 +10983,43 @@ class HwpxDocument {
10582
10983
  const prefix = prefixMatch[1];
10583
10984
  const tag = prefixMatch[2];
10584
10985
  const closeTag = `</${prefix}:${tag}>`;
10585
- // For paragraphs, find the close tag accounting for nesting
10986
+ // Paragraphs DO nest: a paragraph that holds a table contains the
10987
+ // paragraphs of every cell. Taking the first </hp:p> cut a table-wrapper
10988
+ // paragraph off inside its first cell, so anything placed "after" it
10989
+ // landed inside that cell (measured: text inserted after a table
10990
+ // appeared in the table's first cell).
10586
10991
  if (tag === 'p') {
10587
- // Paragraphs don't nest, so find the next close tag
10588
- const closeIdx = sectionXml.indexOf(closeTag, elem.start);
10589
- if (closeIdx !== -1) {
10590
- const endIndex = closeIdx + closeTag.length;
10992
+ const openTag = `<${prefix}:p`;
10993
+ let depth = 1;
10994
+ let pos = elem.start + elem.tagLength;
10995
+ let endIndex = -1;
10996
+ while (depth > 0 && pos < sectionXml.length) {
10997
+ const nextClose = sectionXml.indexOf(closeTag, pos);
10998
+ if (nextClose === -1)
10999
+ break;
11000
+ // Count only real <hp:p ...> / <hp:p> opens, not <hp:pic>, <hp:pos>, ...
11001
+ let nextOpen = sectionXml.indexOf(openTag, pos);
11002
+ while (nextOpen !== -1 && nextOpen < nextClose) {
11003
+ const after = sectionXml[nextOpen + openTag.length];
11004
+ if (after === ' ' || after === '>' || after === '/')
11005
+ break;
11006
+ nextOpen = sectionXml.indexOf(openTag, nextOpen + 1);
11007
+ }
11008
+ if (nextOpen !== -1 && nextOpen < nextClose) {
11009
+ const tagEnd = sectionXml.indexOf('>', nextOpen);
11010
+ // A self-closing <hp:p/> does not change depth.
11011
+ if (sectionXml[tagEnd - 1] !== '/')
11012
+ depth++;
11013
+ pos = tagEnd + 1;
11014
+ }
11015
+ else {
11016
+ depth--;
11017
+ pos = nextClose + closeTag.length;
11018
+ if (depth === 0)
11019
+ endIndex = pos;
11020
+ }
11021
+ }
11022
+ if (endIndex !== -1) {
10591
11023
  results.push({
10592
11024
  xml: sectionXml.substring(elem.start, endIndex),
10593
11025
  startIndex: elem.start,
@@ -10628,102 +11060,6 @@ class HwpxDocument {
10628
11060
  }
10629
11061
  return results;
10630
11062
  }
10631
- async applyParagraphCopiesToXml() {
10632
- if (!this._zip)
10633
- return;
10634
- for (const copy of this._pendingParagraphCopies) {
10635
- const srcPath = `Contents/section${copy.sourceSection}.xml`;
10636
- const srcXml = await this._zip.file(srcPath)?.async('string');
10637
- if (!srcXml)
10638
- continue;
10639
- const srcElements = this.findTopLevelFullElements(srcXml);
10640
- if (copy.sourceParagraph >= srcElements.length)
10641
- continue;
10642
- const srcElem = srcElements[copy.sourceParagraph];
10643
- if (srcElem.type !== 'p')
10644
- continue;
10645
- // Clone and regenerate ID
10646
- let clonedXml = srcElem.xml;
10647
- const newId = Math.random().toString(36).substring(2, 11);
10648
- clonedXml = clonedXml.replace(/<(hp|hs):p\s+([^>]*?)id="[^"]*"/, `<$1:p $2id="${newId}"`);
10649
- // Read target section
10650
- const tgtPath = `Contents/section${copy.targetSection}.xml`;
10651
- let tgtXml = await this._zip.file(tgtPath)?.async('string');
10652
- if (!tgtXml)
10653
- continue;
10654
- const tgtElements = this.findTopLevelFullElements(tgtXml);
10655
- // Insert after targetAfter element
10656
- let insertPos;
10657
- if (copy.targetAfter >= 0 && copy.targetAfter < tgtElements.length) {
10658
- insertPos = tgtElements[copy.targetAfter].endIndex;
10659
- }
10660
- else if (copy.targetAfter < 0) {
10661
- // Insert at beginning - find first element
10662
- if (tgtElements.length > 0) {
10663
- insertPos = tgtElements[0].startIndex;
10664
- }
10665
- else {
10666
- const secMatch = tgtXml.match(/<(?:hs|hp):sec[^>]*>/);
10667
- insertPos = secMatch ? secMatch.index + secMatch[0].length : 0;
10668
- }
10669
- }
10670
- else {
10671
- // After last element
10672
- insertPos = tgtElements.length > 0 ? tgtElements[tgtElements.length - 1].endIndex : tgtXml.lastIndexOf('</');
10673
- }
10674
- tgtXml = tgtXml.substring(0, insertPos) + '\n' + clonedXml + tgtXml.substring(insertPos);
10675
- this._zip.file(tgtPath, tgtXml);
10676
- }
10677
- }
10678
- async applyParagraphMovesToXml() {
10679
- if (!this._zip)
10680
- return;
10681
- for (const move of this._pendingParagraphMoves) {
10682
- const srcPath = `Contents/section${move.sourceSection}.xml`;
10683
- let srcXml = await this._zip.file(srcPath)?.async('string');
10684
- if (!srcXml)
10685
- continue;
10686
- const srcElements = this.findTopLevelFullElements(srcXml);
10687
- if (move.sourceParagraph >= srcElements.length)
10688
- continue;
10689
- const srcElem = srcElements[move.sourceParagraph];
10690
- if (srcElem.type !== 'p')
10691
- continue;
10692
- const extractedXml = srcElem.xml;
10693
- // Remove from source
10694
- srcXml = srcXml.substring(0, srcElem.startIndex) + srcXml.substring(srcElem.endIndex);
10695
- this._zip.file(srcPath, srcXml);
10696
- // Read target section (re-read if same section since we modified it)
10697
- const tgtPath = `Contents/section${move.targetSection}.xml`;
10698
- let tgtXml = await this._zip.file(tgtPath)?.async('string');
10699
- if (!tgtXml)
10700
- continue;
10701
- const tgtElements = this.findTopLevelFullElements(tgtXml);
10702
- // Adjust target index for same-section moves
10703
- let adjustedTarget = move.targetAfter;
10704
- if (move.sourceSection === move.targetSection && move.sourceParagraph < move.targetAfter) {
10705
- adjustedTarget -= 1;
10706
- }
10707
- let insertPos;
10708
- if (adjustedTarget >= 0 && adjustedTarget < tgtElements.length) {
10709
- insertPos = tgtElements[adjustedTarget].endIndex;
10710
- }
10711
- else if (adjustedTarget < 0) {
10712
- if (tgtElements.length > 0) {
10713
- insertPos = tgtElements[0].startIndex;
10714
- }
10715
- else {
10716
- const secMatch = tgtXml.match(/<(?:hs|hp):sec[^>]*>/);
10717
- insertPos = secMatch ? secMatch.index + secMatch[0].length : 0;
10718
- }
10719
- }
10720
- else {
10721
- insertPos = tgtElements.length > 0 ? tgtElements[tgtElements.length - 1].endIndex : tgtXml.lastIndexOf('</');
10722
- }
10723
- tgtXml = tgtXml.substring(0, insertPos) + '\n' + extractedXml + tgtXml.substring(insertPos);
10724
- this._zip.file(tgtPath, tgtXml);
10725
- }
10726
- }
10727
11063
  // ============================================================
10728
11064
  // Header/Footer XML Persistence
10729
11065
  // ============================================================