@kimdayoun/hwpx-mcp 0.3.3 → 0.3.4

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.
@@ -32,6 +32,12 @@ class HwpxDocument {
32
32
  this._redoStack = [];
33
33
  this._pendingTextReplacements = [];
34
34
  this._pendingDirectTextUpdates = [];
35
+ /**
36
+ * `col` is the cell's position in the memory row; `colAddr` is its grid column.
37
+ * They differ after a merge: memory keeps covered cells, the XML drops them.
38
+ * The XML writer finds the target by colAddr so a write made after a merge
39
+ * lands in the right cell (writes now replay in call order).
40
+ */
35
41
  this._pendingTableCellUpdates = [];
36
42
  this._pendingNestedTableInserts = [];
37
43
  this._pendingImageInserts = [];
@@ -58,10 +64,25 @@ class HwpxDocument {
58
64
  this._pendingTableRowDeletes = [];
59
65
  this._pendingTableColumnInserts = [];
60
66
  this._pendingTableColumnDeletes = [];
67
+ /**
68
+ * Call order of every pending edit that names a table cell or row/column by
69
+ * index. Each such index is relative to the table as it was at call time,
70
+ * so save must replay these edits in call order (applyTableOpsInCallOrder).
71
+ * A WeakMap keeps the queue element types unchanged and drops entries with
72
+ * their ops (undo, section delete).
73
+ */
74
+ this._tableOpSeq = new WeakMap();
75
+ this._tableOpCounter = 0;
61
76
  this._pendingParagraphCopies = [];
62
77
  this._pendingParagraphMoves = [];
63
78
  this._pendingHeaderUpdates = [];
64
79
  this._pendingFooterUpdates = [];
80
+ /**
81
+ * New sections to materialise as Contents/sectionN.xml on save, in call
82
+ * order. `templateFrom` is the section whose <hp:secPr> (page size, margins)
83
+ * the new section copies — Hancom's own "insert section" does the same.
84
+ */
85
+ this._pendingSectionOps = [];
65
86
  // Cache for character properties (id → font size in pt)
66
87
  this._charPrCache = null;
67
88
  // Private: Pending table move/copy operations
@@ -270,6 +291,11 @@ class HwpxDocument {
270
291
  get isDirty() { return this._isDirty; }
271
292
  get zip() { return this._zip; }
272
293
  get content() { return this._content; }
294
+ /** Push a table-structure or table-cell edit and remember its call order. */
295
+ queueTableOp(queue, op) {
296
+ this._tableOpSeq.set(op, ++this._tableOpCounter);
297
+ queue.push(op);
298
+ }
273
299
  // ============================================================
274
300
  // Undo/Redo
275
301
  // ============================================================
@@ -348,6 +374,7 @@ class HwpxDocument {
348
374
  this._pendingParagraphMoves = [];
349
375
  this._pendingHeaderUpdates = [];
350
376
  this._pendingFooterUpdates = [];
377
+ this._pendingSectionOps = [];
351
378
  if (this._pendingTableMoves)
352
379
  this._pendingTableMoves = [];
353
380
  }
@@ -502,14 +529,26 @@ class HwpxDocument {
502
529
  };
503
530
  }
504
531
  updateParagraphText(sectionIndex, elementIndex, runIndex, text) {
505
- const paragraph = this.findParagraphByPath(sectionIndex, elementIndex);
506
- if (!paragraph)
507
- return;
508
- // Auto-delegate to preserve styles method for multi-run paragraphs
509
- if (paragraph.runs.length > 1) {
510
- this.updateParagraphTextPreserveStyles(sectionIndex, elementIndex, text);
511
- return;
512
- }
532
+ const section = this._content.sections[sectionIndex];
533
+ if (!section)
534
+ throw new Error(`Section ${sectionIndex} does not exist.`);
535
+ const element = section.elements[elementIndex];
536
+ if (!element) {
537
+ throw new Error(`Element ${elementIndex} does not exist in section ${sectionIndex} (${section.elements.length} elements).`);
538
+ }
539
+ if (element.type !== 'paragraph') {
540
+ // Reported 2026-09-24: aimed at a table, this answered "Paragraph updated"
541
+ // and changed nothing. Say what is there instead.
542
+ throw new Error(`Element ${elementIndex} in section ${sectionIndex} is a ${element.type}, not a paragraph. ` +
543
+ (element.type === 'table' ? 'Use update_table_cell to change table text.' : 'It has no paragraph text to replace.'));
544
+ }
545
+ const paragraph = element.data;
546
+ // Replacing run 0 means "replace the whole paragraph": the new text goes
547
+ // into the first run and every other run is emptied, so the result takes
548
+ // the first run's character shape. Spreading the text across the old runs
549
+ // (preserve-styles) instead gave the tail of the sentence whatever shape
550
+ // those runs had — reported 2026-09-24: plain + bold paragraph, replaced
551
+ // wholesale, came out bold from the third line on.
513
552
  // Handle case where paragraph has no runs (e.g., run without hp:t tag)
514
553
  // We need to create a run in memory and track the update for XML modification
515
554
  if (!paragraph.runs[runIndex]) {
@@ -1093,7 +1132,7 @@ class HwpxDocument {
1093
1132
  this._pendingTableCellHangingIndents[existingIdx].indentPt = indentPt;
1094
1133
  }
1095
1134
  else {
1096
- this._pendingTableCellHangingIndents.push({
1135
+ this.queueTableOp(this._pendingTableCellHangingIndents, {
1097
1136
  sectionIndex,
1098
1137
  tableIndex,
1099
1138
  row,
@@ -1172,7 +1211,7 @@ class HwpxDocument {
1172
1211
  this._pendingTableCellHangingIndents[existingIdx].indentPt = 0; // 0 means remove
1173
1212
  }
1174
1213
  else {
1175
- this._pendingTableCellHangingIndents.push({
1214
+ this.queueTableOp(this._pendingTableCellHangingIndents, {
1176
1215
  sectionIndex,
1177
1216
  tableIndex,
1178
1217
  row,
@@ -1288,12 +1327,22 @@ class HwpxDocument {
1288
1327
  /**
1289
1328
  * Get table map with headers - maps table indices to their header paragraphs
1290
1329
  * Returns array of table info including the header text from the preceding paragraph
1330
+ *
1331
+ * Two indices are returned because they differ once a document has more than
1332
+ * one section:
1333
+ * - `table_index_in_section` — what every table tool (update_table_cell,
1334
+ * get_table_cell, insert_table_row, …) expects together with
1335
+ * `section_index`. Use this one.
1336
+ * - `table_index` — position across the whole document, kept for callers
1337
+ * that list tables. Passing it to a table tool in section 1+ addresses a
1338
+ * DIFFERENT table (reported 2026-09-24: map said 5, the tool needed 4).
1291
1339
  */
1292
1340
  getTableMap() {
1293
1341
  const result = [];
1294
1342
  let globalTableIndex = 0;
1295
1343
  this._content.sections.forEach((section, sectionIndex) => {
1296
1344
  let lastParagraphText = '';
1345
+ let sectionTableIndex = 0;
1297
1346
  section.elements.forEach((element, _elementIndex) => {
1298
1347
  if (element.type === 'paragraph') {
1299
1348
  // Store the paragraph text as potential header
@@ -1316,6 +1365,7 @@ class HwpxDocument {
1316
1365
  }) || [];
1317
1366
  result.push({
1318
1367
  table_index: globalTableIndex,
1368
+ table_index_in_section: sectionTableIndex,
1319
1369
  section_index: sectionIndex,
1320
1370
  header: lastParagraphText,
1321
1371
  rows,
@@ -1324,6 +1374,7 @@ class HwpxDocument {
1324
1374
  first_row_preview: firstRowPreview,
1325
1375
  });
1326
1376
  globalTableIndex++;
1377
+ sectionTableIndex++;
1327
1378
  // Don't reset lastParagraphText here - next table might reuse same header if consecutive
1328
1379
  }
1329
1380
  });
@@ -2174,7 +2225,7 @@ class HwpxDocument {
2174
2225
  // Track cell update for XML sync (works for both empty and non-empty cells)
2175
2226
  // Store table ID for reliable XML matching
2176
2227
  // charShapeId is optional - if provided, it will override the existing charPrIDRef
2177
- this._pendingTableCellUpdates.push({ sectionIndex, tableIndex, tableId: table.id, row, col, text, charShapeId });
2228
+ this.queueTableOp(this._pendingTableCellUpdates, { sectionIndex, tableIndex, tableId: table.id, row, col, colAddr: cell.colAddr, text, charShapeId });
2178
2229
  this.saveState();
2179
2230
  if (cell.paragraphs.length > 0 && cell.paragraphs[0].runs.length > 0) {
2180
2231
  cell.paragraphs[0].runs[0].text = text;
@@ -2201,19 +2252,80 @@ class HwpxDocument {
2201
2252
  const table = this.findTable(sectionIndex, tableIndex);
2202
2253
  if (!table || !table.rows[afterRowIndex])
2203
2254
  return false;
2255
+ // A new row between afterRowIndex and afterRowIndex+1 must not cut through
2256
+ // a vertical merge. Cloning a row that holds a rowSpan>1 master, or one that
2257
+ // sits inside such a span, copied the span into the gap and made the merged
2258
+ // area overlap the new row (reported 2026-09-24: rowSpan=2 header, after_row 0).
2259
+ for (const row of table.rows) {
2260
+ for (const cell of row.cells) {
2261
+ const top = cell.rowAddr ?? table.rows.indexOf(row);
2262
+ const span = cell.rowSpan ?? 1;
2263
+ if (span > 1 && top <= afterRowIndex && afterRowIndex < top + span - 1) {
2264
+ throw new Error(`Cannot insert a row after row ${afterRowIndex}: it would split the merged cell at ` +
2265
+ `(${top}, ${cell.colAddr ?? 0}) that spans rows ${top}-${top + span - 1}. ` +
2266
+ `Insert after row ${top + span - 1} instead, or unmerge first.`);
2267
+ }
2268
+ }
2269
+ }
2204
2270
  this.saveState();
2205
- const templateRow = table.rows[afterRowIndex];
2206
- const colCount = templateRow.cells.length;
2271
+ // Same column grid as the XML path (gridCellsForNewRow): one cell per
2272
+ // column position, taking the colAddr/colSpan of the cell that starts
2273
+ // there in the template row or the nearest row above. Sizing the row by
2274
+ // templateRow.cells.length left out a column covered by a vertical merge.
2275
+ // A cell with no colAddr (e.g. added by insertTableColumn, which does not
2276
+ // renumber) is placed by its position in the row, as gridCellsForNewRow
2277
+ // does for the XML. Dropping it made the new row one cell short.
2278
+ const placed = (r) => {
2279
+ let next = 0;
2280
+ return (table.rows[r]?.cells ?? []).map(c => {
2281
+ const span = c.colSpan ?? 1;
2282
+ const col = c.colAddr ?? next;
2283
+ next = col + span;
2284
+ return { col, span };
2285
+ });
2286
+ };
2287
+ const starts = (r) => new Map(placed(r).map(c => [c.col, c.span]));
2288
+ const colCount = Math.max(0, ...table.rows.map((_, r) => Math.max(0, ...placed(r).map(c => c.col + c.span))));
2289
+ const templateStarts = placed(afterRowIndex).map(c => c.col).sort((a, b) => a - b);
2290
+ const grid = [];
2291
+ for (let col = 0; col < colCount;) {
2292
+ let span;
2293
+ for (let r = afterRowIndex; r >= 0 && span === undefined; r--)
2294
+ span = starts(r).get(col);
2295
+ for (let r = afterRowIndex + 1; r < table.rows.length && span === undefined; r++)
2296
+ span = starts(r).get(col);
2297
+ if (span === undefined) {
2298
+ col++;
2299
+ continue;
2300
+ }
2301
+ const next = templateStarts.find(c => c > col);
2302
+ if (next !== undefined && col + span > next)
2303
+ span = next - col;
2304
+ grid.push({ colAddr: col, colSpan: Math.max(1, span) });
2305
+ col += Math.max(1, span);
2306
+ }
2207
2307
  const newRow = {
2208
- cells: Array.from({ length: colCount }, (_, i) => ({
2308
+ cells: grid.map((g, i) => ({
2309
+ rowAddr: afterRowIndex + 1,
2310
+ colAddr: g.colAddr,
2311
+ rowSpan: 1,
2312
+ colSpan: g.colSpan,
2209
2313
  paragraphs: [{
2210
2314
  id: Math.random().toString(36).substring(2, 11),
2211
2315
  runs: [{ text: cellTexts?.[i] || '' }],
2212
2316
  }],
2213
2317
  })),
2214
2318
  };
2319
+ // Keep memory row addresses in step with the XML renumbering, so a later
2320
+ // merge/split/insert on this table reads the right rows.
2321
+ for (const row of table.rows) {
2322
+ for (const cell of row.cells) {
2323
+ if (cell.rowAddr !== undefined && cell.rowAddr > afterRowIndex)
2324
+ cell.rowAddr += 1;
2325
+ }
2326
+ }
2215
2327
  table.rows.splice(afterRowIndex + 1, 0, newRow);
2216
- this._pendingTableRowInserts.push({
2328
+ this.queueTableOp(this._pendingTableRowInserts, {
2217
2329
  sectionIndex,
2218
2330
  tableIndex,
2219
2331
  afterRowIndex,
@@ -2231,8 +2343,27 @@ class HwpxDocument {
2231
2343
  return this.deleteTable(sectionIndex, tableIndex);
2232
2344
  }
2233
2345
  this.saveState();
2346
+ // Mirror applyTableRowDeletesToXml so later edits read the same addresses the
2347
+ // XML has after replay: a vertical merge from an earlier row that reaches the
2348
+ // deleted row loses one row, and cells below move up one row. Stale rowAddr
2349
+ // made the row-insert guard refuse an insert below a merge and allow one
2350
+ // through it (CodeRabbit, 2026-09-24).
2351
+ for (let r = 0; r < rowIndex; r++) {
2352
+ for (const cell of table.rows[r]?.cells ?? []) {
2353
+ const top = cell.rowAddr ?? r;
2354
+ const span = cell.rowSpan ?? 1;
2355
+ if (span > 1 && top + span > rowIndex)
2356
+ cell.rowSpan = span - 1;
2357
+ }
2358
+ }
2234
2359
  table.rows.splice(rowIndex, 1);
2235
- this._pendingTableRowDeletes.push({
2360
+ for (const row of table.rows) {
2361
+ for (const cell of row.cells) {
2362
+ if (cell.rowAddr !== undefined && cell.rowAddr > rowIndex)
2363
+ cell.rowAddr -= 1;
2364
+ }
2365
+ }
2366
+ this.queueTableOp(this._pendingTableRowDeletes, {
2236
2367
  sectionIndex,
2237
2368
  tableIndex,
2238
2369
  rowIndex,
@@ -2282,15 +2413,29 @@ class HwpxDocument {
2282
2413
  if (!table)
2283
2414
  return false;
2284
2415
  this.saveState();
2416
+ // Keep memory addresses in step with the XML path (applyTableColumnInsertsToXml
2417
+ // gives the new cell colAddr afterColIndex+1 and shifts the cells after it).
2418
+ // A new cell with no colAddr in the middle of a row made the row read as
2419
+ // [0, (none), 1]: the grid for a later row insert counted column 1 twice and
2420
+ // the new row came out one cell short (CodeRabbit, 2026-09-24).
2285
2421
  for (const row of table.rows) {
2422
+ for (const cell of row.cells) {
2423
+ if (cell.colAddr !== undefined && cell.colAddr > afterColIndex)
2424
+ cell.colAddr += 1;
2425
+ }
2426
+ const rowAddr = row.cells.find(c => c.rowAddr !== undefined)?.rowAddr;
2286
2427
  row.cells.splice(afterColIndex + 1, 0, {
2428
+ colAddr: afterColIndex + 1,
2429
+ ...(rowAddr !== undefined ? { rowAddr } : {}),
2430
+ colSpan: 1,
2431
+ rowSpan: 1,
2287
2432
  paragraphs: [{
2288
2433
  id: Math.random().toString(36).substring(2, 11),
2289
2434
  runs: [{ text: '' }],
2290
2435
  }],
2291
2436
  });
2292
2437
  }
2293
- this._pendingTableColumnInserts.push({
2438
+ this.queueTableOp(this._pendingTableColumnInserts, {
2294
2439
  sectionIndex,
2295
2440
  tableIndex,
2296
2441
  afterColIndex,
@@ -2303,10 +2448,18 @@ class HwpxDocument {
2303
2448
  if (!table || (table.rows[0]?.cells.length || 0) <= 1)
2304
2449
  return false;
2305
2450
  this.saveState();
2451
+ // Mirror applyTableColumnDeletesToXml: cells after the deleted column move one
2452
+ // column left. A write queued after the delete carries the cell's colAddr and
2453
+ // the XML is matched by it, so a stale address sent the text nowhere
2454
+ // (CodeRabbit, 2026-09-24; 0.3.3 dropped these writes too).
2306
2455
  for (const row of table.rows) {
2307
2456
  row.cells.splice(colIndex, 1);
2457
+ for (const cell of row.cells) {
2458
+ if (cell.colAddr !== undefined && cell.colAddr > colIndex)
2459
+ cell.colAddr -= 1;
2460
+ }
2308
2461
  }
2309
- this._pendingTableColumnDeletes.push({
2462
+ this.queueTableOp(this._pendingTableColumnDeletes, {
2310
2463
  sectionIndex,
2311
2464
  tableIndex,
2312
2465
  colIndex,
@@ -2492,12 +2645,13 @@ class HwpxDocument {
2492
2645
  const cellText = cell.paragraphs.map(p => p.runs.map(r => r.text).join('')).join('\n');
2493
2646
  // Use existing pending table cell update mechanism
2494
2647
  this._pendingTableCellUpdates = this._pendingTableCellUpdates || [];
2495
- this._pendingTableCellUpdates.push({
2648
+ this.queueTableOp(this._pendingTableCellUpdates, {
2496
2649
  sectionIndex,
2497
2650
  tableIndex,
2498
2651
  tableId,
2499
2652
  row,
2500
2653
  col,
2654
+ colAddr: cell.colAddr,
2501
2655
  text: cellText,
2502
2656
  });
2503
2657
  this.markModified();
@@ -2856,7 +3010,7 @@ class HwpxDocument {
2856
3010
  if (!this._pendingNestedTableInserts) {
2857
3011
  this._pendingNestedTableInserts = [];
2858
3012
  }
2859
- this._pendingNestedTableInserts.push({
3013
+ this.queueTableOp(this._pendingNestedTableInserts, {
2860
3014
  sectionIndex,
2861
3015
  parentTableIndex,
2862
3016
  row,
@@ -2923,6 +3077,29 @@ class HwpxDocument {
2923
3077
  console.warn(`[HwpxDocument] mergeCells: Single cell selected, no merge needed`);
2924
3078
  return false;
2925
3079
  }
3080
+ // A row whose every own cell falls inside the merge is saved as an <hp:tr>
3081
+ // with no <hp:tc>. 한/글 2024 gave no PDF for such a file (measured: a
3082
+ // full-width two-row merge and a vertical merge in a one-column table; the
3083
+ // same table merged short of full width converted), and a scan of 275 한/글
3084
+ // originals found no row without a cell. 0.3.3 wrote these files too.
3085
+ // Rows built in memory keep covered cells and rows read from a file do not,
3086
+ // so cells are placed by their own address (position only when it has none)
3087
+ // and a cell counts only if no other merged cell covers it.
3088
+ const placedCells = table.rows.flatMap((row, ri) => row.cells.map((cell, ci) => ({ cell, row: cell.rowAddr ?? ri, col: cell.colAddr ?? ci })));
3089
+ const masters = placedCells.filter(p => (p.cell.rowSpan ?? 1) > 1 || (p.cell.colSpan ?? 1) > 1);
3090
+ const coveredByOther = (p) => masters.some(m => m.cell !== p.cell &&
3091
+ p.row >= m.row && p.row < m.row + (m.cell.rowSpan ?? 1) &&
3092
+ p.col >= m.col && p.col < m.col + (m.cell.colSpan ?? 1));
3093
+ for (let r = startRow + 1; r <= endRow; r++) {
3094
+ const keepsCell = placedCells.some(p => p.row === r &&
3095
+ (p.col + (p.cell.colSpan ?? 1) - 1 < startCol || p.col > endCol) &&
3096
+ !coveredByOther(p));
3097
+ if (!keepsCell) {
3098
+ throw new Error(`Cannot merge (${startRow}, ${startCol})-(${endRow}, ${endCol}): row ${r} would have no ` +
3099
+ `cell of its own, and 한/글 does not open a table row without cells. Merge fewer ` +
3100
+ `columns so row ${r} keeps a cell, or delete row ${r} instead.`);
3101
+ }
3102
+ }
2926
3103
  this.saveState();
2927
3104
  // Calculate span values
2928
3105
  const colSpan = endCol - startCol + 1;
@@ -2934,7 +3111,7 @@ class HwpxDocument {
2934
3111
  masterCell.rowSpan = rowSpan;
2935
3112
  }
2936
3113
  // Add to pending merges for XML application during save
2937
- this._pendingCellMerges.push({
3114
+ this.queueTableOp(this._pendingCellMerges, {
2938
3115
  sectionIndex,
2939
3116
  tableIndex,
2940
3117
  startRow,
@@ -3001,7 +3178,7 @@ class HwpxDocument {
3001
3178
  cell.rowSpan = 1;
3002
3179
  }
3003
3180
  // Add to pending splits for XML application during save
3004
- this._pendingCellSplits.push({
3181
+ this.queueTableOp(this._pendingCellSplits, {
3005
3182
  sectionIndex,
3006
3183
  tableIndex,
3007
3184
  row,
@@ -3407,7 +3584,7 @@ class HwpxDocument {
3407
3584
  // Get original image dimensions from binary data
3408
3585
  const orgDimensions = this.getImageDimensions(imageData.data, imageData.mimeType);
3409
3586
  // Add to pending cell image inserts
3410
- this._pendingCellImageInserts.push({
3587
+ this.queueTableOp(this._pendingCellImageInserts, {
3411
3588
  sectionIndex,
3412
3589
  tableIndex,
3413
3590
  row,
@@ -3686,13 +3863,18 @@ class HwpxDocument {
3686
3863
  }));
3687
3864
  }
3688
3865
  insertSection(afterSectionIndex) {
3866
+ if (afterSectionIndex < -1 || afterSectionIndex >= this._content.sections.length) {
3867
+ throw new Error(`Cannot insert a section after ${afterSectionIndex}: document has ${this._content.sections.length} section(s).`);
3868
+ }
3689
3869
  this.saveState();
3870
+ // The first paragraph of every section carries <hp:secPr>, so it must have
3871
+ // an XML id the anchors can find. '0' matches the section template below.
3690
3872
  const newSection = {
3691
3873
  id: Math.random().toString(36).substring(2, 11),
3692
3874
  elements: [{
3693
3875
  type: 'paragraph',
3694
3876
  data: {
3695
- id: Math.random().toString(36).substring(2, 11),
3877
+ id: '0',
3696
3878
  runs: [{ text: '' }],
3697
3879
  },
3698
3880
  }],
@@ -3707,9 +3889,44 @@ class HwpxDocument {
3707
3889
  };
3708
3890
  const insertIndex = afterSectionIndex + 1;
3709
3891
  this._content.sections.splice(insertIndex, 0, newSection);
3892
+ this.markStructureChanged();
3893
+ // insertSection used to change only the memory model: save wrote no
3894
+ // sectionN.xml, so a two-section document silently came back with one
3895
+ // section and everything added to the new section was lost (measured on
3896
+ // 0.3.3 with insert_section + insert_table, 2026-09-24).
3897
+ this._pendingSectionOps.push({ op: 'insert', at: insertIndex, templateFrom: Math.max(0, afterSectionIndex) });
3898
+ // Section files are created at the start of save, before every other
3899
+ // pending edit is replayed. Edits recorded earlier still name sections by
3900
+ // their old number; shift those at or after the insertion point so they
3901
+ // land in the same section after the renumbering (measured: an edit to the
3902
+ // old section 0, then insert_section(-1), wrote into the new section 0).
3903
+ this.shiftPendingSectionIndices(insertIndex, +1);
3710
3904
  this.markModified();
3711
3905
  return insertIndex;
3712
3906
  }
3907
+ /**
3908
+ * Add `delta` to every section number held by a pending edit that is >= from.
3909
+ * Covers all pending arrays generically: any numeric field whose name is
3910
+ * sectionIndex or ends in "Section"/"SectionIndex" (source/target pairs).
3911
+ */
3912
+ shiftPendingSectionIndices(from, delta) {
3913
+ const isSectionKey = (k) => k === 'sectionIndex' || /Section(Index)?$/.test(k);
3914
+ for (const key of Object.keys(this)) {
3915
+ if (!String(key).startsWith('_pending') || key === '_pendingSectionOps')
3916
+ continue;
3917
+ const list = this[key];
3918
+ if (!Array.isArray(list))
3919
+ continue;
3920
+ for (const item of list) {
3921
+ if (!item || typeof item !== 'object')
3922
+ continue;
3923
+ for (const [k, v] of Object.entries(item)) {
3924
+ if (isSectionKey(k) && typeof v === 'number' && v >= from)
3925
+ item[k] = v + delta;
3926
+ }
3927
+ }
3928
+ }
3929
+ }
3713
3930
  deleteSection(sectionIndex) {
3714
3931
  if (sectionIndex < 0 || sectionIndex >= this._content.sections.length)
3715
3932
  return false;
@@ -3717,6 +3934,23 @@ class HwpxDocument {
3717
3934
  return false; // Cannot delete the last section
3718
3935
  this.saveState();
3719
3936
  this._content.sections.splice(sectionIndex, 1);
3937
+ this.markStructureChanged();
3938
+ // Same persistence gap as insertSection had: the memory model lost the
3939
+ // section but save kept its file, so the deleted section came back on
3940
+ // reopen. Pending edits aimed at the deleted section are dropped; later
3941
+ // sections move down one number.
3942
+ for (const key of Object.keys(this)) {
3943
+ if (!String(key).startsWith('_pending') || key === '_pendingSectionOps')
3944
+ continue;
3945
+ const list = this[key];
3946
+ if (!Array.isArray(list))
3947
+ continue;
3948
+ const kept = list.filter(item => !(item && typeof item === 'object' &&
3949
+ Object.entries(item).some(([k, v]) => (k === 'sectionIndex' || /Section(Index)?$/.test(k)) && v === sectionIndex)));
3950
+ this[key] = kept;
3951
+ }
3952
+ this.shiftPendingSectionIndices(sectionIndex + 1, -1);
3953
+ this._pendingSectionOps.push({ op: 'delete', at: sectionIndex, templateFrom: 0 });
3720
3954
  this.markModified();
3721
3955
  return true;
3722
3956
  }
@@ -3844,6 +4078,13 @@ class HwpxDocument {
3844
4078
  async syncContentToZip() {
3845
4079
  if (!this._zip)
3846
4080
  return;
4081
+ // New sections first: every later step addresses Contents/sectionN.xml by
4082
+ // the memory section index, so the files must already exist and be numbered
4083
+ // the same way.
4084
+ if (this._pendingSectionOps.length > 0) {
4085
+ await this.applySectionOpsToZip();
4086
+ this._pendingSectionOps = [];
4087
+ }
3847
4088
  // Replay paragraph/table inserts and paragraph copies/moves together, in
3848
4089
  // call order, before any text update. Text updates resolve their target in
3849
4090
  // the current XML, and other operations locate tables by index, so the
@@ -3874,31 +4115,13 @@ class HwpxDocument {
3874
4115
  await this.applyTableMovesToXml();
3875
4116
  this._pendingTableMoves = [];
3876
4117
  }
3877
- // Apply table cell updates (preserves original XML structure)
3878
- if (this._pendingTableCellUpdates && this._pendingTableCellUpdates.length > 0) {
3879
- await this.applyTableCellUpdatesToXml();
3880
- this._pendingTableCellUpdates = [];
3881
- }
3882
- // Apply cell merges
3883
- if (this._pendingCellMerges && this._pendingCellMerges.length > 0) {
3884
- await this.applyCellMergesToXml();
3885
- this._pendingCellMerges = [];
3886
- }
3887
- // Apply cell splits
3888
- if (this._pendingCellSplits && this._pendingCellSplits.length > 0) {
3889
- await this.applyCellSplitsToXml();
3890
- this._pendingCellSplits = [];
3891
- }
3892
- // Apply nested table inserts
3893
- if (this._pendingNestedTableInserts && this._pendingNestedTableInserts.length > 0) {
3894
- await this.applyNestedTableInsertsToXml();
3895
- this._pendingNestedTableInserts = [];
3896
- }
3897
- // Apply cell image inserts
3898
- if (this._pendingCellImageInserts && this._pendingCellImageInserts.length > 0) {
3899
- await this.applyCellImageInsertsToXml();
3900
- this._pendingCellImageInserts = [];
3901
- }
4118
+ // Table edits that address cells or rows/columns by index, replayed in
4119
+ // CALL order. Each index is relative to the table as it was when that edit
4120
+ // was made; applying them by kind (all cell writes, then all row inserts,
4121
+ // then column inserts ...) wrote cell text into the pre-insert layout and
4122
+ // dropped text written to a new row or column (CodeRabbit, 2026-09-24; the
4123
+ // same 5 scenarios failed on 0.3.3).
4124
+ await this.applyTableOpsInCallOrder();
3902
4125
  // Apply direct text updates (from updateParagraphText)
3903
4126
  if (this._pendingDirectTextUpdates && this._pendingDirectTextUpdates.length > 0) {
3904
4127
  await this.applyDirectTextUpdatesToXml();
@@ -3924,11 +4147,6 @@ class HwpxDocument {
3924
4147
  await this.applyHangingIndentsToXml();
3925
4148
  this._pendingHangingIndents = [];
3926
4149
  }
3927
- // Apply table cell hanging indent changes
3928
- if (this._pendingTableCellHangingIndents && this._pendingTableCellHangingIndents.length > 0) {
3929
- await this.applyTableCellHangingIndentsToXml();
3930
- this._pendingTableCellHangingIndents = [];
3931
- }
3932
4150
  // Apply paragraph style changes (alignment, etc.)
3933
4151
  if (this._pendingParagraphStyles && this._pendingParagraphStyles.length > 0) {
3934
4152
  await this.applyParagraphStylesToXml();
@@ -3939,26 +4157,6 @@ class HwpxDocument {
3939
4157
  await this.applyCharacterStylesToXml();
3940
4158
  this._pendingCharacterStyles = [];
3941
4159
  }
3942
- // Apply table row inserts
3943
- if (this._pendingTableRowInserts && this._pendingTableRowInserts.length > 0) {
3944
- await this.applyTableRowInsertsToXml();
3945
- this._pendingTableRowInserts = [];
3946
- }
3947
- // Apply table row deletes
3948
- if (this._pendingTableRowDeletes && this._pendingTableRowDeletes.length > 0) {
3949
- await this.applyTableRowDeletesToXml();
3950
- this._pendingTableRowDeletes = [];
3951
- }
3952
- // Apply table column inserts
3953
- if (this._pendingTableColumnInserts && this._pendingTableColumnInserts.length > 0) {
3954
- await this.applyTableColumnInsertsToXml();
3955
- this._pendingTableColumnInserts = [];
3956
- }
3957
- // Apply table column deletes
3958
- if (this._pendingTableColumnDeletes && this._pendingTableColumnDeletes.length > 0) {
3959
- await this.applyTableColumnDeletesToXml();
3960
- this._pendingTableColumnDeletes = [];
3961
- }
3962
4160
  // Apply header/footer updates
3963
4161
  if (this._pendingHeaderUpdates && this._pendingHeaderUpdates.length > 0 ||
3964
4162
  this._pendingFooterUpdates && this._pendingFooterUpdates.length > 0) {
@@ -4137,10 +4335,10 @@ class HwpxDocument {
4137
4335
  }
4138
4336
  // Clean up empty runs that may be left behind
4139
4337
  // <hp:run charPrIDRef="0"><hp:t/></hp:run> or <hp:run charPrIDRef="0"></hp:run>
4140
- xml = xml.replace(/<hp:run[^>]*>(\s*<hp:t\s*\/>)?\s*<\/hp:run>/g, '');
4338
+ xml = xml.replace(/<hp:run(?:\s[^>]*)?>(\s*<hp:t\s*\/>)?\s*<\/hp:run>/g, '');
4141
4339
  // Clean up empty paragraphs that only contained the image
4142
4340
  // <hp:p ...><hp:linesegarray>...</hp:linesegarray></hp:p>
4143
- xml = xml.replace(/<hp:p[^>]*>\s*(<hp:linesegarray[^>]*>[\s\S]*?<\/hp:linesegarray>)?\s*<\/hp:p>/g, '');
4341
+ xml = xml.replace(/<hp:p(?:\s[^>]*)?>\s*(<hp:linesegarray[^>]*>[\s\S]*?<\/hp:linesegarray>)?\s*<\/hp:p>/g, '');
4144
4342
  if (modified) {
4145
4343
  this._zip.file(sectionPath, xml);
4146
4344
  }
@@ -4682,12 +4880,13 @@ class HwpxDocument {
4682
4880
  idMap.set(oldId, newId);
4683
4881
  }
4684
4882
  }
4685
- // Second pass: replace all IDs
4686
- let result = xml;
4687
- for (const [oldId, newId] of idMap) {
4688
- result = result.replace(new RegExp(`id="${oldId}"`, 'g'), `id="${newId}"`);
4689
- }
4690
- return result;
4883
+ // Second pass: replace every id in one scan. Building a RegExp per old id
4884
+ // broke on ids with regex metacharacters, and replacing ids one at a time
4885
+ // could rewrite an id that an earlier replacement had just produced.
4886
+ return xml.replace(/id="([^"]+)"/g, (whole, oldId) => {
4887
+ const newId = idMap.get(oldId);
4888
+ return newId === undefined ? whole : `id="${newId}"`;
4889
+ });
4691
4890
  }
4692
4891
  /**
4693
4892
  * Find the position to insert an element after a given element index.
@@ -4705,7 +4904,7 @@ class HwpxDocument {
4705
4904
  // Find all root-level elements (paragraphs, tables)
4706
4905
  const elements = [];
4707
4906
  // Find paragraphs (not inside subList)
4708
- const pRegex = /<hp:p[^>]*>[\s\S]*?<\/hp:p>/g;
4907
+ const pRegex = /<hp:p(?:\s[^>]*)?>[\s\S]*?<\/hp:p>/g;
4709
4908
  let match;
4710
4909
  // Find tables
4711
4910
  const tables = this.findAllTables(xml);
@@ -4719,7 +4918,7 @@ class HwpxDocument {
4719
4918
  const end = start + match[0].length;
4720
4919
  // Check if this paragraph is inside a table (inside subList)
4721
4920
  const beforeMatch = xml.substring(0, start);
4722
- const subListOpen = (beforeMatch.match(/<hp:subList[^>]*>/g) || []).length;
4921
+ const subListOpen = (beforeMatch.match(/<hp:subList(?:\s[^>]*)?>/g) || []).length;
4723
4922
  const subListClose = (beforeMatch.match(/<\/hp:subList>/g) || []).length;
4724
4923
  if (subListOpen === subListClose) {
4725
4924
  // This is a root-level paragraph
@@ -4936,10 +5135,10 @@ class HwpxDocument {
4936
5135
  */
4937
5136
  insertNestedTableIntoCell(cellXml, nestedTableXml) {
4938
5137
  // Find the subList in the cell
4939
- const subListMatch = cellXml.match(/<hp:subList[^>]*>/);
5138
+ const subListMatch = cellXml.match(/<hp:subList(?:\s[^>]*)?>/);
4940
5139
  if (!subListMatch) {
4941
5140
  // No subList, try to add to paragraph directly
4942
- const pMatch = cellXml.match(/<hp:p[^>]*>/);
5141
+ const pMatch = cellXml.match(/<hp:p(?:\s[^>]*)?>/);
4943
5142
  if (pMatch) {
4944
5143
  const insertPos = cellXml.indexOf(pMatch[0]) + pMatch[0].length;
4945
5144
  const runXml = `<hp:run charPrIDRef="0">${nestedTableXml}<hp:t/></hp:run>`;
@@ -5249,7 +5448,7 @@ class HwpxDocument {
5249
5448
  }
5250
5449
  // If no cell before masterCol, insert at the beginning of row content
5251
5450
  if (insertPoint === -1) {
5252
- const trMatch = updatedRowXml.match(/<(hp|hs):tr[^>]*>/);
5451
+ const trMatch = updatedRowXml.match(/<(hp|hs):tr(?:\s[^>]*)?>/);
5253
5452
  if (trMatch) {
5254
5453
  insertPoint = trMatch[0].length;
5255
5454
  }
@@ -5286,6 +5485,54 @@ class HwpxDocument {
5286
5485
  </hp:subList>
5287
5486
  </hp:tc>`;
5288
5487
  }
5488
+ /**
5489
+ * Replay every pending table edit (cell text, merge/split, nested table,
5490
+ * cell image, cell hanging indent, row/column insert/delete) in call order.
5491
+ *
5492
+ * Each index an edit carries is relative to the table as it was when the
5493
+ * edit was made. Applying by kind (all cell writes, then all row inserts,
5494
+ * then all column inserts ...) wrote text into the pre-insert layout; and
5495
+ * the row appliers sort their own queue by index, which reorders two
5496
+ * inserts or two deletes on the same table. So edits that change a table's
5497
+ * row/column layout run one at a time. Runs of layout-preserving edits
5498
+ * (cell text, indents, images, nested tables) go to their applier together.
5499
+ */
5500
+ async applyTableOpsInCallOrder() {
5501
+ const kinds = [
5502
+ { layout: false, take: () => this._pendingTableCellUpdates, put: o => { this._pendingTableCellUpdates = o; }, apply: () => this.applyTableCellUpdatesToXml() },
5503
+ { layout: true, take: () => this._pendingCellMerges, put: o => { this._pendingCellMerges = o; }, apply: () => this.applyCellMergesToXml() },
5504
+ { layout: true, take: () => this._pendingCellSplits, put: o => { this._pendingCellSplits = o; }, apply: () => this.applyCellSplitsToXml() },
5505
+ { layout: false, take: () => this._pendingNestedTableInserts, put: o => { this._pendingNestedTableInserts = o; }, apply: () => this.applyNestedTableInsertsToXml() },
5506
+ { layout: false, take: () => this._pendingCellImageInserts, put: o => { this._pendingCellImageInserts = o; }, apply: () => this.applyCellImageInsertsToXml() },
5507
+ { layout: false, take: () => this._pendingTableCellHangingIndents, put: o => { this._pendingTableCellHangingIndents = o; }, apply: () => this.applyTableCellHangingIndentsToXml() },
5508
+ { layout: true, take: () => this._pendingTableRowInserts, put: o => { this._pendingTableRowInserts = o; }, apply: () => this.applyTableRowInsertsToXml() },
5509
+ { layout: true, take: () => this._pendingTableRowDeletes, put: o => { this._pendingTableRowDeletes = o; }, apply: () => this.applyTableRowDeletesToXml() },
5510
+ { layout: true, take: () => this._pendingTableColumnInserts, put: o => { this._pendingTableColumnInserts = o; }, apply: () => this.applyTableColumnInsertsToXml() },
5511
+ { layout: true, take: () => this._pendingTableColumnDeletes, put: o => { this._pendingTableColumnDeletes = o; }, apply: () => this.applyTableColumnDeletesToXml() },
5512
+ ];
5513
+ // Every push goes through queueTableOp, so every op has a sequence number;
5514
+ // a missing one would sort last and keep its queue position.
5515
+ const all = [];
5516
+ for (const kind of kinds) {
5517
+ kind.take().forEach((op, pos) => all.push({ kind, op, seq: this._tableOpSeq.get(op) ?? Number.MAX_SAFE_INTEGER, pos }));
5518
+ kind.put([]);
5519
+ }
5520
+ all.sort((a, b) => a.seq - b.seq || a.pos - b.pos);
5521
+ for (let i = 0; i < all.length;) {
5522
+ const kind = all[i].kind;
5523
+ const batch = [all[i++].op];
5524
+ if (!kind.layout)
5525
+ while (i < all.length && all[i].kind === kind)
5526
+ batch.push(all[i++].op);
5527
+ kind.put(batch);
5528
+ try {
5529
+ await kind.apply();
5530
+ }
5531
+ finally {
5532
+ kind.put([]);
5533
+ }
5534
+ }
5535
+ }
5289
5536
  /**
5290
5537
  * Apply table cell updates to XML while preserving original structure.
5291
5538
  * This function modifies only the text content of specific cells,
@@ -5303,7 +5550,7 @@ class HwpxDocument {
5303
5550
  const updatesBySection = new Map();
5304
5551
  for (const update of this._pendingTableCellUpdates) {
5305
5552
  const sectionUpdates = updatesBySection.get(update.sectionIndex) || [];
5306
- sectionUpdates.push({ tableId: update.tableId, row: update.row, col: update.col, text: update.text, charShapeId: update.charShapeId });
5553
+ sectionUpdates.push({ tableId: update.tableId, row: update.row, col: update.col, colAddr: update.colAddr, text: update.text, charShapeId: update.charShapeId });
5307
5554
  updatesBySection.set(update.sectionIndex, sectionUpdates);
5308
5555
  }
5309
5556
  // Process each section that has updates
@@ -5319,7 +5566,7 @@ class HwpxDocument {
5319
5566
  const updatesByTableId = new Map();
5320
5567
  for (const update of updates) {
5321
5568
  const tableUpdates = updatesByTableId.get(update.tableId) || [];
5322
- tableUpdates.push({ row: update.row, col: update.col, text: update.text, charShapeId: update.charShapeId });
5569
+ tableUpdates.push({ row: update.row, col: update.col, colAddr: update.colAddr, text: update.text, charShapeId: update.charShapeId });
5323
5570
  updatesByTableId.set(update.tableId, tableUpdates);
5324
5571
  }
5325
5572
  // Process each table that has updates (by ID)
@@ -5570,12 +5817,14 @@ class HwpxDocument {
5570
5817
  * Find a table by its ID in XML.
5571
5818
  */
5572
5819
  findTableById(xml, tableId) {
5573
- // Match table with specific ID
5574
- const tableStartRegex = new RegExp(`<(?:hp|hs|hc):tbl[^>]*\\bid="${tableId}"[^>]*>`, 'g');
5820
+ // Match table with specific ID. The id comes from document XML, so it is
5821
+ // escaped: an id holding '.', '(' or '+' matched another table or threw.
5822
+ const id = this.escapeRegex(tableId);
5823
+ const tableStartRegex = new RegExp(`<(?:hp|hs|hc):tbl\\s[^>]*\\bid="${id}"[^>]*>`, 'g');
5575
5824
  const match = tableStartRegex.exec(xml);
5576
5825
  if (!match) {
5577
5826
  // Try alternate ID format (id='...' instead of id="...")
5578
- const altRegex = new RegExp(`<(?:hp|hs|hc):tbl[^>]*\\bid='${tableId}'[^>]*>`, 'g');
5827
+ const altRegex = new RegExp(`<(?:hp|hs|hc):tbl\\s[^>]*\\bid='${id}'[^>]*>`, 'g');
5579
5828
  const altMatch = altRegex.exec(xml);
5580
5829
  if (!altMatch)
5581
5830
  return null;
@@ -5714,7 +5963,7 @@ class HwpxDocument {
5714
5963
  findAllTables(xml) {
5715
5964
  const tables = [];
5716
5965
  // Match both hp:tbl and hs:tbl (different namespace prefixes)
5717
- const tableStartRegex = /<(?:hp|hs|hc):tbl[^>]*>/g;
5966
+ const tableStartRegex = /<(?:hp|hs|hc):tbl(?:\s[^>]*)?>/g;
5718
5967
  let match;
5719
5968
  while ((match = tableStartRegex.exec(xml)) !== null) {
5720
5969
  const startIndex = match.index;
@@ -5819,7 +6068,7 @@ class HwpxDocument {
5819
6068
  if (!updatesByRow.has(update.row)) {
5820
6069
  updatesByRow.set(update.row, []);
5821
6070
  }
5822
- updatesByRow.get(update.row).push({ col: update.col, text: update.text, charShapeId: update.charShapeId });
6071
+ updatesByRow.get(update.row).push({ col: update.col, colAddr: update.colAddr, text: update.text, charShapeId: update.charShapeId });
5823
6072
  }
5824
6073
  // Sort row indices descending to process from end to start (avoid index shifting)
5825
6074
  const sortedRowIndices = Array.from(updatesByRow.keys()).sort((a, b) => b - a);
@@ -5865,17 +6114,28 @@ class HwpxDocument {
5865
6114
  let result = rowXml;
5866
6115
  // Find all cells in this row using depth tracking to handle nested tables correctly
5867
6116
  const cells = this.findAllElementsWithDepth(rowXml, 'tc');
6117
+ // Resolve each update to its <hp:tc> index. The cell's grid column
6118
+ // (colAddr) is authoritative: after a merge the XML row no longer has the
6119
+ // covered cells that memory still lists, so the memory position `col`
6120
+ // points one cell too far. `col` is used only when the write has no
6121
+ // colAddr or the row carries no addresses.
6122
+ const cellCols = cells.map(c => this.cellOwnAttr(c.xml, 'colAddr')?.value);
6123
+ const indexOf = (u) => {
6124
+ if (u.colAddr !== undefined && cellCols.some(a => a !== undefined))
6125
+ return cellCols.indexOf(u.colAddr);
6126
+ return u.col < cells.length ? u.col : -1;
6127
+ };
5868
6128
  // Deduplicate updates for the same cell (keep last value)
5869
6129
  // This prevents stale index issues when the same cell is updated multiple times
5870
6130
  const uniqueUpdates = new Map();
5871
6131
  for (const update of updates) {
5872
- uniqueUpdates.set(update.col, update);
6132
+ const at = indexOf(update);
6133
+ if (at >= 0)
6134
+ uniqueUpdates.set(at, { ...update, col: at });
5873
6135
  }
5874
6136
  // Sort updates by col descending to process from right to left (avoid index shifting)
5875
6137
  const sortedUpdates = Array.from(uniqueUpdates.values()).sort((a, b) => b.col - a.col);
5876
6138
  for (const update of sortedUpdates) {
5877
- if (update.col >= cells.length)
5878
- continue;
5879
6139
  const cellData = cells[update.col];
5880
6140
  // Validate cell before update - capture nested table structure
5881
6141
  const cellTblOpen = (cellData.xml.match(/<(?:hp|hs|hc):tbl[\s>]/g) || []).length;
@@ -5951,7 +6211,7 @@ class HwpxDocument {
5951
6211
  xml = xml.replace(/(<(?:hp|hs|hc):run\s+)charPrIDRef="[^"]*"/, `$1charPrIDRef="${charShapeId}"`);
5952
6212
  }
5953
6213
  // Pattern 1: Cell has existing <hp:t> or <hs:t> or <hc:t> tags with content
5954
- const tTagPattern = /(<(?:hp|hs|hc):t[^>]*>)([^<]*)(<\/(?:hp|hs|hc):t>)/g;
6214
+ const tTagPattern = /(<(?:hp|hs|hc):t(?:\s[^>]*)?>)([^<]*)(<\/(?:hp|hs|hc):t>)/g;
5955
6215
  let foundText = false;
5956
6216
  let result = xml.replace(tTagPattern, (match, openTag, _oldText, closeTag, offset) => {
5957
6217
  // Only replace the first text occurrence
@@ -5964,14 +6224,14 @@ class HwpxDocument {
5964
6224
  if (foundText)
5965
6225
  return this.resetLinesegInXml(result);
5966
6226
  // Pattern 2: Cell has empty <hp:t/> or <hp:t></hp:t> tags
5967
- const emptyTTagPattern = /<((?:hp|hs|hc):t)([^>]*)\s*\/>/;
6227
+ const emptyTTagPattern = /<((?:hp|hs|hc):t)((?:\s[^>]*?)?)\s*\/>/;
5968
6228
  const emptyTMatch = xml.match(emptyTTagPattern);
5969
6229
  if (emptyTMatch) {
5970
6230
  const updated = xml.replace(emptyTTagPattern, `<${emptyTMatch[1]}${emptyTMatch[2]}>${escapedText}</${emptyTMatch[1]}>`);
5971
6231
  return this.resetLinesegInXml(updated);
5972
6232
  }
5973
6233
  // Pattern 3a: Self-closing <hp:run .../> - expand to full run with text
5974
- const selfClosingRunPattern = /<((?:hp|hs|hc):run)([^>]*)\s*\/>/;
6234
+ const selfClosingRunPattern = /<((?:hp|hs|hc):run)((?:\s[^>]*?)?)\s*\/>/;
5975
6235
  const selfClosingRunMatch = xml.match(selfClosingRunPattern);
5976
6236
  if (selfClosingRunMatch) {
5977
6237
  const tagName = selfClosingRunMatch[1]; // e.g., "hp:run"
@@ -5990,7 +6250,7 @@ class HwpxDocument {
5990
6250
  return this.resetLinesegInXml(updated);
5991
6251
  }
5992
6252
  // Pattern 3b: Cell has <hp:run> but no <hp:t> - add text inside run
5993
- const runPattern = /(<(?:hp|hs|hc):run[^>]*>)([\s\S]*?)(<\/(?:hp|hs|hc):run>)/;
6253
+ const runPattern = /(<(?:hp|hs|hc):run(?:\s[^>]*)?>)([\s\S]*?)(<\/(?:hp|hs|hc):run>)/;
5994
6254
  const runMatch = xml.match(runPattern);
5995
6255
  if (runMatch) {
5996
6256
  const prefix = runMatch[1].match(/<(hp|hs|hc):run/)?.[1] || 'hp';
@@ -5999,7 +6259,7 @@ class HwpxDocument {
5999
6259
  return this.resetLinesegInXml(updated);
6000
6260
  }
6001
6261
  // Pattern 4: Cell has <hp:subList><hp:p> structure - find the paragraph and add text
6002
- const subListPattern = /(<(?:hp|hs|hc):subList[^>]*>[\s\S]*?<(?:hp|hs|hc):p[^>]*>)([\s\S]*?)(<\/(?:hp|hs|hc):p>)/;
6262
+ const subListPattern = /(<(?:hp|hs|hc):subList(?:\s[^>]*)?>[\s\S]*?<(?:hp|hs|hc):p(?:\s[^>]*)?>)([\s\S]*?)(<\/(?:hp|hs|hc):p>)/;
6003
6263
  const subListMatch = xml.match(subListPattern);
6004
6264
  if (subListMatch) {
6005
6265
  const prefix = subListMatch[1].match(/<(hp|hs|hc):subList/)?.[1] || 'hp';
@@ -6012,7 +6272,7 @@ class HwpxDocument {
6012
6272
  }
6013
6273
  }
6014
6274
  // Pattern 5: Cell has only <hp:p> without subList
6015
- const pPattern = /(<(?:hp|hs|hc):p[^>]*>)([\s\S]*?)(<\/(?:hp|hs|hc):p>)/;
6275
+ const pPattern = /(<(?:hp|hs|hc):p(?:\s[^>]*)?>)([\s\S]*?)(<\/(?:hp|hs|hc):p>)/;
6016
6276
  const pMatch = xml.match(pPattern);
6017
6277
  if (pMatch) {
6018
6278
  const prefix = pMatch[1].match(/<(hp|hs|hc):p/)?.[1] || 'hp';
@@ -6034,7 +6294,7 @@ class HwpxDocument {
6034
6294
  const charAttr = charShapeId !== undefined ? ` charPrIDRef="${charShapeId}"` : ' charPrIDRef="0"';
6035
6295
  let xml = cellXml;
6036
6296
  // Find the subList element to replace paragraph content
6037
- const subListStartMatch = xml.match(/<(hp|hs|hc):subList[^>]*>/);
6297
+ const subListStartMatch = xml.match(/<(hp|hs|hc):subList(?:\s[^>]*)?>/);
6038
6298
  if (subListStartMatch) {
6039
6299
  const prefix = subListStartMatch[1];
6040
6300
  const startTag = subListStartMatch[0];
@@ -6068,7 +6328,7 @@ class HwpxDocument {
6068
6328
  // Preserve nested tables
6069
6329
  const nestedTables = this.extractNestedTables(subListContent, prefix);
6070
6330
  // Extract paraPrIDRef and styleIDRef from existing paragraph
6071
- const existingPMatch = subListContent.match(/<(?:hp|hs|hc):p[^>]*paraPrIDRef="([^"]*)"[^>]*styleIDRef="([^"]*)"/);
6331
+ const existingPMatch = subListContent.match(/<(?:hp|hs|hc):p\s[^>]*paraPrIDRef="([^"]*)"[^>]*styleIDRef="([^"]*)"/);
6072
6332
  const paraPrIDRef = existingPMatch?.[1] || '0';
6073
6333
  const styleIDRef = existingPMatch?.[2] || '0';
6074
6334
  const paraId = Math.floor(Math.random() * 2147483647);
@@ -6080,7 +6340,7 @@ class HwpxDocument {
6080
6340
  }
6081
6341
  }
6082
6342
  // Fallback: try to find paragraph directly
6083
- const pStartMatch = xml.match(/<(hp|hs|hc):p[^>]*>/);
6343
+ const pStartMatch = xml.match(/<(hp|hs|hc):p(?:\s[^>]*)?>/);
6084
6344
  if (pStartMatch) {
6085
6345
  const prefix = pStartMatch[1];
6086
6346
  const attrMatch = pStartMatch[0].match(/<(?:hp|hs|hc):p([^>]*)>/);
@@ -6108,7 +6368,7 @@ class HwpxDocument {
6108
6368
  if (depth === 0) {
6109
6369
  lastParagraphEnd = searchIndex;
6110
6370
  const remainingXml = xml.substring(searchIndex);
6111
- const nextPMatch = remainingXml.match(/^\s*<(hp|hs|hc):p[^>]*>/);
6371
+ const nextPMatch = remainingXml.match(/^\s*<(hp|hs|hc):p(?:\s[^>]*)?>/);
6112
6372
  if (!nextPMatch)
6113
6373
  break;
6114
6374
  }
@@ -6139,7 +6399,7 @@ class HwpxDocument {
6139
6399
  const charAttr = charShapeId !== undefined ? ` charPrIDRef="${charShapeId}"` : ' charPrIDRef="0"';
6140
6400
  // Find the OUTER subList element with balanced tag matching
6141
6401
  // This is crucial because cells can contain nested tables with their own subLists
6142
- const subListStartMatch = cellXml.match(/<(hp|hs|hc):subList[^>]*>/);
6402
+ const subListStartMatch = cellXml.match(/<(hp|hs|hc):subList(?:\s[^>]*)?>/);
6143
6403
  if (subListStartMatch) {
6144
6404
  const prefix = subListStartMatch[1];
6145
6405
  const startTag = subListStartMatch[0];
@@ -6177,7 +6437,7 @@ class HwpxDocument {
6177
6437
  // IMPORTANT: Check for nested tables in subList content - preserve them!
6178
6438
  const nestedTables = this.extractNestedTables(subListContent, prefix);
6179
6439
  // Extract paraPrIDRef and styleIDRef from existing paragraph if available
6180
- const existingPMatch = subListContent.match(/<(?:hp|hs|hc):p[^>]*paraPrIDRef="([^"]*)"[^>]*styleIDRef="([^"]*)"/);
6440
+ const existingPMatch = subListContent.match(/<(?:hp|hs|hc):p\s[^>]*paraPrIDRef="([^"]*)"[^>]*styleIDRef="([^"]*)"/);
6181
6441
  const paraPrIDRef = existingPMatch?.[1] || '0';
6182
6442
  const styleIDRef = existingPMatch?.[2] || '0';
6183
6443
  // Generate multiple paragraphs with chunked runs for long lines
@@ -6194,7 +6454,7 @@ class HwpxDocument {
6194
6454
  }
6195
6455
  // If no subList found, try to find just paragraphs and replace
6196
6456
  // Use balanced matching for paragraphs too, since they can contain nested tables
6197
- const pStartMatch = cellXml.match(/<(hp|hs|hc):p[^>]*>/);
6457
+ const pStartMatch = cellXml.match(/<(hp|hs|hc):p(?:\s[^>]*)?>/);
6198
6458
  if (pStartMatch) {
6199
6459
  const prefix = pStartMatch[1];
6200
6460
  const firstPStart = cellXml.indexOf(pStartMatch[0]);
@@ -6228,7 +6488,7 @@ class HwpxDocument {
6228
6488
  lastParagraphEnd = searchIndex;
6229
6489
  // Check if there's another paragraph at top level
6230
6490
  const remainingXml = cellXml.substring(searchIndex);
6231
- const nextPMatch = remainingXml.match(/^\s*<(hp|hs|hc):p[^>]*>/);
6491
+ const nextPMatch = remainingXml.match(/^\s*<(hp|hs|hc):p(?:\s[^>]*)?>/);
6232
6492
  if (!nextPMatch) {
6233
6493
  // No more top-level paragraphs
6234
6494
  break;
@@ -6353,7 +6613,7 @@ class HwpxDocument {
6353
6613
  else {
6354
6614
  // Text not found, fall back to first paragraph
6355
6615
  console.warn(`[HwpxDocument] afterText "${insert.afterText}" not found in cell, using first paragraph`);
6356
- const paragraphMatch = targetCell.xml.match(/<hp:p[^>]*>/);
6616
+ const paragraphMatch = targetCell.xml.match(/<hp:p(?:\s[^>]*)?>/);
6357
6617
  if (!paragraphMatch)
6358
6618
  continue;
6359
6619
  insertPosition = targetCell.xml.indexOf(paragraphMatch[0]) + paragraphMatch[0].length;
@@ -6361,7 +6621,7 @@ class HwpxDocument {
6361
6621
  }
6362
6622
  else {
6363
6623
  // Default: find the first <hp:p> in the cell and insert the image inside it
6364
- const paragraphMatch = targetCell.xml.match(/<hp:p[^>]*>/);
6624
+ const paragraphMatch = targetCell.xml.match(/<hp:p(?:\s[^>]*)?>/);
6365
6625
  if (!paragraphMatch)
6366
6626
  continue;
6367
6627
  insertPosition = targetCell.xml.indexOf(paragraphMatch[0]) + paragraphMatch[0].length;
@@ -6494,25 +6754,38 @@ class HwpxDocument {
6494
6754
  if (!file)
6495
6755
  continue;
6496
6756
  let xml = await file.async('string');
6497
- // STEP 1: Pre-compute target paragraph mappings BEFORE any modifications
6498
- // OPTIMIZATION: Use cached XML positions when available (populated during parsing)
6757
+ // STEP 1: Pre-compute target paragraph ranges BEFORE any modifications.
6758
+ //
6759
+ // Each memory paragraph is mapped to its XML paragraph with the parser's
6760
+ // own rule (parsedParagraphStarts), computed once per section. The offsets
6761
+ // the parser cached at load time are not used: they pair memory paragraphs
6762
+ // with a DIFFERENT list (top-level paragraphs of the raw XML), which drifts
6763
+ // wherever the parser lifts paragraphs out of headers, text boxes or
6764
+ // endnotes. Measured on 325 Hancom-saved sections: 16,271 of 70,677 cached
6765
+ // offsets pointed at another paragraph, and an edit then reported success
6766
+ // while its text went to — or vanished into — the wrong paragraph.
6499
6767
  const paragraphTargets = new Map();
6768
+ const starts = this.parsedParagraphStarts(xml);
6769
+ const elements = this._content.sections[sectionIdx]?.elements ?? [];
6770
+ const slotOf = new Map();
6771
+ let slot = 0;
6772
+ elements.forEach((el, i) => {
6773
+ if (this.anchorKeyOf(el)?.kind === 'paragraph')
6774
+ slotOf.set(i, slot++);
6775
+ });
6776
+ const aligned = slot === starts.length;
6500
6777
  for (const [elementIndex, updates] of elementMap) {
6501
- // Try cached position first (from parsing phase)
6502
- const cachedPosition = this.getCachedXmlPosition(sectionIdx, elementIndex);
6503
- if (cachedPosition && cachedPosition.start < xml.length && cachedPosition.end <= xml.length) {
6504
- // Validate cached position by checking if it points to a paragraph element
6505
- const cachedXml = xml.slice(cachedPosition.start, cachedPosition.end);
6506
- if (cachedXml.startsWith('<hp:p') && cachedXml.endsWith('</hp:p>')) {
6507
- paragraphTargets.set(elementIndex, {
6508
- start: cachedPosition.start,
6509
- end: cachedPosition.end,
6510
- xml: cachedXml
6511
- });
6778
+ const k = slotOf.get(elementIndex);
6779
+ if (aligned && k !== undefined) {
6780
+ const start = starts[k];
6781
+ const end = this.findBalancedParagraphEnd(xml, start);
6782
+ if (end !== -1) {
6783
+ paragraphTargets.set(elementIndex, { start, end, xml: xml.slice(start, end) });
6512
6784
  continue;
6513
6785
  }
6514
6786
  }
6515
- // Fallback to full search if no cached position or validation failed
6787
+ // Memory and XML disagree on the paragraph count (should not happen for
6788
+ // parser-produced documents); fall back to id + occurrence search.
6516
6789
  const paragraphId = updates[0]?.paragraphId || '';
6517
6790
  const paragraphOccurrence = updates[0]?.paragraphOccurrence ?? 0;
6518
6791
  const target = this.findTargetParagraphForUpdate(xml, sectionIdx, elementIndex, updates, paragraphId, paragraphOccurrence);
@@ -6816,10 +7089,10 @@ class HwpxDocument {
6816
7089
  }
6817
7090
  else if (/<hp:t\b[^>]*>/.test(run.xml)) {
6818
7091
  // Has <hp:t>...</hp:t> tags - replace content of FIRST one only (no g flag)
6819
- newRunXml = run.xml.replace(/(<hp:t[^>]*>)[^<]*(<\/hp:t>)/, `$1${escapedNew}$2`);
7092
+ newRunXml = run.xml.replace(/(<hp:t(?:\s[^>]*)?>)[^<]*(<\/hp:t>)/, `$1${escapedNew}$2`);
6820
7093
  // Remove any additional <hp:t>...</hp:t> tags to prevent duplication
6821
7094
  let firstReplaced = false;
6822
- newRunXml = newRunXml.replace(/<hp:t[^>]*>[^<]*<\/hp:t>/g, (match) => {
7095
+ newRunXml = newRunXml.replace(/<hp:t(?:\s[^>]*)?>[^<]*<\/hp:t>/g, (match) => {
6823
7096
  if (!firstReplaced) {
6824
7097
  firstReplaced = true;
6825
7098
  return match; // Keep the first one
@@ -7233,48 +7506,22 @@ class HwpxDocument {
7233
7506
  for (const update of updates) {
7234
7507
  updateMap.set(update.runIndex, update.newText);
7235
7508
  }
7236
- // Find all hp:run elements with their positions
7237
- // Use non-greedy matching and track depth for nested elements
7238
- const runs = [];
7239
- const runOpenRegex = /<hp:run\b[^>]*>/g;
7240
- let match;
7241
- while ((match = runOpenRegex.exec(paragraphXml)) !== null) {
7242
- const runStart = match.index;
7243
- let depth = 1;
7244
- let pos = runStart + match[0].length;
7245
- // Find matching </hp:run> using depth tracking
7246
- while (depth > 0 && pos < paragraphXml.length) {
7247
- const nextOpen = paragraphXml.indexOf('<hp:run', pos);
7248
- const nextClose = paragraphXml.indexOf('</hp:run>', pos);
7249
- if (nextClose === -1)
7250
- break;
7251
- if (nextOpen !== -1 && nextOpen < nextClose) {
7252
- depth++;
7253
- pos = nextOpen + 7;
7254
- }
7255
- else {
7256
- depth--;
7257
- if (depth === 0) {
7258
- const runEnd = nextClose + '</hp:run>'.length;
7259
- runs.push({
7260
- start: runStart,
7261
- end: runEnd,
7262
- xml: paragraphXml.slice(runStart, runEnd)
7263
- });
7264
- }
7265
- pos = nextClose + 9;
7266
- }
7267
- }
7268
- }
7509
+ // The paragraph's OWN runs only — its direct children. A paragraph that
7510
+ // holds a table, text box, footnote or endnote also contains the runs of
7511
+ // every paragraph inside those containers. Counting those as its own made
7512
+ // "run N" land in a table cell or endnote: the reported success wrote the
7513
+ // new text into a nested paragraph (or into nothing) and cut the rest.
7514
+ // Measured: 39 of 60 Hancom files lost the text this way (2026-09-24).
7515
+ const runs = this.findDirectChildRuns(paragraphXml);
7269
7516
  // Filter to only runs that have <hp:t> content (matching memory model behavior)
7270
7517
  // Memory model only counts runs with text, not runs with only <hp:ctrl> etc.
7271
- const textRuns = runs.filter(run => /<hp:t\b/.test(run.xml) || /<hp:t\s*\/>/.test(run.xml));
7518
+ const textRuns = runs.filter(run => /<hp:t\b/.test(this.ownRunText(run.xml)));
7272
7519
  // The parser creates a model run per non-empty hp:t, not per hp:run.
7273
7520
  // Merge those updates back into their shared XML run without losing a suffix.
7274
7521
  const xmlRunUpdates = new Map();
7275
7522
  let modelRunIndex = 0;
7276
7523
  for (let i = 0; i < textRuns.length; i++) {
7277
- const textNodes = [...textRuns[i].xml.matchAll(/<hp:t\b[^>]*>([^<]+)<\/hp:t>/g)];
7524
+ const textNodes = [...this.ownRunText(textRuns[i].xml).matchAll(/<hp:t\b[^>]*>([^<]+)<\/hp:t>/g)];
7278
7525
  const count = Math.max(1, textNodes.length);
7279
7526
  let changed = false;
7280
7527
  let escapedText = '';
@@ -7298,19 +7545,112 @@ class HwpxDocument {
7298
7545
  continue;
7299
7546
  const run = textRuns[i];
7300
7547
  const escapedNew = xmlRunUpdates.get(i);
7301
- let newRunXml = run.xml;
7302
7548
  // Write each XML run's combined text once, preserving text-tag attributes.
7549
+ // Only the run's own <hp:t> are rewritten; text inside a table, equation
7550
+ // or text box that sits in the same run is left untouched.
7303
7551
  let textWritten = false;
7304
- newRunXml = newRunXml.replace(/<hp:t\b([^>]*?)\/>|<hp:t\b([^>]*)>[^<]*<\/hp:t>/g, (_match, selfClosingAttrs, attrs) => {
7552
+ const newRunXml = this.mapOwnRunText(run.xml, tXml => tXml.replace(/<hp:t\b([^>]*?)\/>|<hp:t\b([^>]*)>[^<]*<\/hp:t>/g, (_match, selfClosingAttrs, attrs) => {
7305
7553
  const text = textWritten ? '' : escapedNew;
7306
7554
  textWritten = true;
7307
7555
  return `<hp:t${selfClosingAttrs ?? attrs ?? ''}>${text}</hp:t>`;
7308
- });
7556
+ }));
7309
7557
  // Replace in paragraph XML
7310
7558
  paragraphXml = paragraphXml.slice(0, run.start) + newRunXml + paragraphXml.slice(run.end);
7311
7559
  }
7312
7560
  return xml.slice(0, target.start) + paragraphXml + xml.slice(target.end);
7313
7561
  }
7562
+ /** Direct <hp:run> children of a paragraph (runs of nested paragraphs excluded). */
7563
+ findDirectChildRuns(paragraphXml) {
7564
+ const runs = [];
7565
+ const openEnd = paragraphXml.indexOf('>') + 1;
7566
+ let pos = openEnd;
7567
+ let depth = 0; // nesting depth of <hp:p> inside this paragraph
7568
+ const tagRe = /<(\/?)hp:(p|run)\b[^>]*?(\/?)>/g;
7569
+ tagRe.lastIndex = pos;
7570
+ let runStart = -1;
7571
+ let m;
7572
+ while ((m = tagRe.exec(paragraphXml)) !== null) {
7573
+ const [whole, closing, name, selfClosing] = m;
7574
+ if (name === 'p') {
7575
+ if (selfClosing)
7576
+ continue;
7577
+ if (closing) {
7578
+ if (depth === 0)
7579
+ break; // end of this paragraph
7580
+ depth--;
7581
+ }
7582
+ else {
7583
+ depth++;
7584
+ }
7585
+ continue;
7586
+ }
7587
+ if (depth !== 0)
7588
+ continue; // a run of a nested paragraph
7589
+ if (selfClosing) {
7590
+ runs.push({ start: m.index, end: m.index + whole.length, xml: whole });
7591
+ }
7592
+ else if (!closing) {
7593
+ runStart = m.index;
7594
+ }
7595
+ else if (runStart !== -1) {
7596
+ const end = m.index + whole.length;
7597
+ runs.push({ start: runStart, end, xml: paragraphXml.slice(runStart, end) });
7598
+ runStart = -1;
7599
+ }
7600
+ }
7601
+ return runs;
7602
+ }
7603
+ /**
7604
+ * A run's own markup with every nested container (table, equation, text box,
7605
+ * note…) blanked out, so its <hp:t> are the run's own text only.
7606
+ */
7607
+ ownRunText(runXml) {
7608
+ return this.mapOwnRunText(runXml, s => s, true);
7609
+ }
7610
+ /**
7611
+ * Apply `fn` to the parts of a run that are its own text, leaving nested
7612
+ * containers byte-for-byte intact. With `blank`, nested containers are
7613
+ * replaced by an empty marker instead (for reading).
7614
+ */
7615
+ mapOwnRunText(runXml, fn, blank = false) {
7616
+ let out = '';
7617
+ let pos = 0;
7618
+ while (pos < runXml.length) {
7619
+ const rest = runXml.slice(pos);
7620
+ const m = rest.match(HwpxDocument.NESTED_CONTENT);
7621
+ if (!m || m.index === undefined) {
7622
+ out += fn(rest);
7623
+ break;
7624
+ }
7625
+ const openAt = pos + m.index;
7626
+ const name = m[1];
7627
+ out += fn(runXml.slice(pos, openAt));
7628
+ const end = this.findElementEnd(runXml, openAt, name);
7629
+ out += blank ? '<NESTED/>' : runXml.slice(openAt, end);
7630
+ pos = end;
7631
+ }
7632
+ return out;
7633
+ }
7634
+ /** End offset of the <hp:name> element opening at `start` (handles nesting and self-closing). */
7635
+ findElementEnd(xml, start, name) {
7636
+ const tagEnd = xml.indexOf('>', start);
7637
+ if (tagEnd === -1)
7638
+ return xml.length;
7639
+ if (xml[tagEnd - 1] === '/')
7640
+ return tagEnd + 1;
7641
+ const re = new RegExp(`<(/?)hp:${name}\\b[^>]*?(/?)>`, 'g');
7642
+ re.lastIndex = tagEnd + 1;
7643
+ let depth = 1;
7644
+ let m;
7645
+ while ((m = re.exec(xml)) !== null) {
7646
+ if (m[2])
7647
+ continue;
7648
+ depth += m[1] ? -1 : 1;
7649
+ if (depth === 0)
7650
+ return m.index + m[0].length;
7651
+ }
7652
+ return xml.length;
7653
+ }
7314
7654
  /**
7315
7655
  * Replace text in a single run directly using pre-computed target location.
7316
7656
  * Simpler version for single-run updates.
@@ -7324,9 +7664,9 @@ class HwpxDocument {
7324
7664
  // Self-closing: <hp:t/> -> <hp:t>newText</hp:t>
7325
7665
  paragraphXml = paragraphXml.replace(/<hp:t\s*\/>/, `<hp:t>${escapedNew}</hp:t>`);
7326
7666
  }
7327
- else if (/<hp:t[^>]*>/.test(paragraphXml)) {
7667
+ else if (/<hp:t(?:\s[^>]*)?>/.test(paragraphXml)) {
7328
7668
  // Has content or empty: <hp:t>...</hp:t> -> <hp:t>newText</hp:t>
7329
- paragraphXml = paragraphXml.replace(/(<hp:t[^>]*>)[^<]*(<\/hp:t>)/, `$1${escapedNew}$2`);
7669
+ paragraphXml = paragraphXml.replace(/(<hp:t(?:\s[^>]*)?>)[^<]*(<\/hp:t>)/, `$1${escapedNew}$2`);
7330
7670
  }
7331
7671
  else if (/<hp:run\b[^>]*>/.test(paragraphXml)) {
7332
7672
  // No <hp:t> tag exists - add one after the <hp:run> opening tag
@@ -7386,7 +7726,7 @@ class HwpxDocument {
7386
7726
  continue;
7387
7727
  // Found the right paragraph! Replace the text
7388
7728
  // Replace within <hp:t> tags
7389
- const pattern1 = new RegExp(`(<hp:t[^>]*>)${this.escapeRegex(escapedOld)}`);
7729
+ const pattern1 = new RegExp(`(<hp:t(?:\\s[^>]*)?>)${this.escapeRegex(escapedOld)}`);
7390
7730
  let newParagraphContent = paragraphContent.replace(pattern1, `$1${escapedNew}`);
7391
7731
  // Also try standalone text replacement
7392
7732
  const pattern2 = new RegExp(`>${this.escapeRegex(escapedOld)}<`);
@@ -7649,9 +7989,9 @@ class HwpxDocument {
7649
7989
  // Case 1: Self-closing <hp:t/> - replace with full tag containing new text
7650
7990
  newElementContent = elementContent.replace(/<hp:t\s*\/>/, `<hp:t>${escapedNew}</hp:t>`);
7651
7991
  }
7652
- else if (oldText === '' && /<hp:t[^>]*><\/hp:t>/.test(elementContent)) {
7992
+ else if (oldText === '' && /<hp:t(?:\s[^>]*)?><\/hp:t>/.test(elementContent)) {
7653
7993
  // Case 2: Empty <hp:t></hp:t> - fill with new text
7654
- newElementContent = elementContent.replace(/(<hp:t[^>]*>)<\/hp:t>/, `$1${escapedNew}</hp:t>`);
7994
+ newElementContent = elementContent.replace(/(<hp:t(?:\s[^>]*)?>)<\/hp:t>/, `$1${escapedNew}</hp:t>`);
7655
7995
  }
7656
7996
  else if (oldText === '' && !/<hp:t\b[^>]*>/.test(elementContent)) {
7657
7997
  // Case 3: No hp:t tag at all - add one after the first hp:run opening tag
@@ -7659,7 +7999,7 @@ class HwpxDocument {
7659
7999
  }
7660
8000
  else {
7661
8001
  // Case 4: Normal case - replace text within <hp:t> tags (first match only)
7662
- const pattern1 = new RegExp(`(<hp:t[^>]*>)${this.escapeRegex(escapedOld)}`);
8002
+ const pattern1 = new RegExp(`(<hp:t(?:\\s[^>]*)?>)${this.escapeRegex(escapedOld)}`);
7663
8003
  newElementContent = elementContent.replace(pattern1, `$1${escapedNew}`);
7664
8004
  // Also try standalone text replacement if pattern1 didn't match
7665
8005
  if (newElementContent === elementContent) {
@@ -7732,7 +8072,7 @@ class HwpxDocument {
7732
8072
  if (runIndex >= runs.length) {
7733
8073
  // Run index out of bounds, try to replace in any run
7734
8074
  // Replace text within <hp:t> tags (first match only)
7735
- const pattern1 = new RegExp(`(<hp:t[^>]*>)${this.escapeRegex(escapedOld)}`);
8075
+ const pattern1 = new RegExp(`(<hp:t(?:\\s[^>]*)?>)${this.escapeRegex(escapedOld)}`);
7736
8076
  let newParagraphContent = paragraphContent.replace(pattern1, `$1${escapedNew}`);
7737
8077
  // Also try standalone text replacement
7738
8078
  if (newParagraphContent === paragraphContent) {
@@ -7745,7 +8085,7 @@ class HwpxDocument {
7745
8085
  const targetRun = runs[runIndex];
7746
8086
  let newRunContent = targetRun.content;
7747
8087
  // Replace within <hp:t> tags in this run
7748
- const tPattern = new RegExp(`(<hp:t[^>]*>)${this.escapeRegex(escapedOld)}(</hp:t>)`);
8088
+ const tPattern = new RegExp(`(<hp:t(?:\\s[^>]*)?>)${this.escapeRegex(escapedOld)}(</hp:t>)`);
7749
8089
  newRunContent = newRunContent.replace(tPattern, `$1${escapedNew}$2`);
7750
8090
  // If no match, try simpler pattern
7751
8091
  if (newRunContent === targetRun.content) {
@@ -7927,7 +8267,7 @@ class HwpxDocument {
7927
8267
  return tblMatch;
7928
8268
  }
7929
8269
  let rowIndex = 0;
7930
- return tblMatch.replace(/<hp:tr[^>]*>([\s\S]*?)<\/hp:tr>/g, (rowMatch) => {
8270
+ return tblMatch.replace(/<hp:tr(?:\s[^>]*)?>([\s\S]*?)<\/hp:tr>/g, (rowMatch) => {
7931
8271
  if (rowIndex >= table.rows.length) {
7932
8272
  rowIndex++;
7933
8273
  return rowMatch;
@@ -8704,6 +9044,84 @@ class HwpxDocument {
8704
9044
  contentHpf = contentHpf.substring(0, insertPos) + newItem + contentHpf.substring(insertPos);
8705
9045
  this._zip.file('Contents/content.hpf', contentHpf);
8706
9046
  }
9047
+ /**
9048
+ * Apply section inserts/deletes to Contents/sectionN.xml, in call order.
9049
+ *
9050
+ * File numbers must keep matching memory section indices, so an insert
9051
+ * renames later files up one (section1 → section2 …) and a delete removes
9052
+ * its file and renames later files down one. content.hpf gets a manifest
9053
+ * item and a spine itemref per section, and header.xml's secCnt follows.
9054
+ */
9055
+ async applySectionOpsToZip() {
9056
+ if (!this._zip)
9057
+ return;
9058
+ const secPath = (i) => `Contents/section${i}.xml`;
9059
+ const countFiles = () => Object.keys(this._zip.files).filter(n => /^Contents\/section\d+\.xml$/.test(n)).length;
9060
+ const move = async (from, to) => {
9061
+ const f = this._zip.file(secPath(from));
9062
+ if (!f)
9063
+ return;
9064
+ this._zip.file(secPath(to), await f.async('string'));
9065
+ this._zip.remove(secPath(from));
9066
+ };
9067
+ for (const op of this._pendingSectionOps) {
9068
+ const fileCount = countFiles();
9069
+ if (op.op === 'delete') {
9070
+ if (op.at >= fileCount || fileCount <= 1)
9071
+ continue;
9072
+ this._zip.remove(secPath(op.at));
9073
+ for (let i = op.at + 1; i < fileCount; i++)
9074
+ await move(i, i - 1);
9075
+ continue;
9076
+ }
9077
+ // Insert: shift later files up, highest first.
9078
+ for (let i = fileCount - 1; i >= op.at; i--)
9079
+ await move(i, i + 1);
9080
+ // Build the new section from the template section's <hs:sec> wrapper and
9081
+ // its first paragraph's <hp:secPr> (page size, margins, numbering).
9082
+ const templateIndex = op.templateFrom >= op.at ? op.templateFrom + 1 : op.templateFrom;
9083
+ const template = await this._zip.file(secPath(templateIndex))?.async('string');
9084
+ this._zip.file(secPath(op.at), this.buildEmptySectionXml(template));
9085
+ }
9086
+ // Manifest + spine: one item per section file, in order.
9087
+ const hpfFile = this._zip.file('Contents/content.hpf');
9088
+ const total = countFiles();
9089
+ if (hpfFile) {
9090
+ let hpf = await hpfFile.async('string');
9091
+ hpf = hpf.replace(/<opf:item\b[^>]*\bid="section\d+"[^>]*\/>\s*/g, '');
9092
+ hpf = hpf.replace(/<opf:itemref\b[^>]*\bidref="section\d+"[^>]*\/>\s*/g, '');
9093
+ const items = Array.from({ length: total }, (_, i) => `<opf:item id="section${i}" href="Contents/section${i}.xml" media-type="application/xml"/>`).join('');
9094
+ const refs = Array.from({ length: total }, (_, i) => `<opf:itemref idref="section${i}" linear="yes"/>`).join('');
9095
+ hpf = hpf.replace('</opf:manifest>', items + '</opf:manifest>');
9096
+ hpf = hpf.replace('</opf:spine>', refs + '</opf:spine>');
9097
+ this._zip.file('Contents/content.hpf', hpf);
9098
+ }
9099
+ const headerFile = this._zip.file('Contents/header.xml');
9100
+ if (headerFile) {
9101
+ const header = await headerFile.async('string');
9102
+ this._zip.file('Contents/header.xml', header.replace(/\bsecCnt="\d+"/, `secCnt="${total}"`));
9103
+ }
9104
+ }
9105
+ /** A section XML holding one empty paragraph with the template's <hp:secPr>. */
9106
+ buildEmptySectionXml(template) {
9107
+ const declaration = '<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>';
9108
+ const secOpen = template?.match(/<hs:sec\b[^>]*>/)?.[0]
9109
+ ?? '<hs:sec xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph" xmlns:hs="http://www.hancom.co.kr/hwpml/2011/section">';
9110
+ let secPr = '';
9111
+ if (template) {
9112
+ const at = template.indexOf('<hp:secPr');
9113
+ if (at !== -1)
9114
+ secPr = template.slice(at, this.findElementEnd(template, at, 'secPr'));
9115
+ }
9116
+ // A fresh column definition follows secPr in Hancom's own first paragraph.
9117
+ const colPr = template?.match(/<hp:ctrl>\s*<hp:colPr\b[^>]*\/>\s*<\/hp:ctrl>/)?.[0] ?? '';
9118
+ return `${declaration}${secOpen}` +
9119
+ `<hp:p id="0" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">` +
9120
+ `<hp:run charPrIDRef="0">${secPr}${colPr}</hp:run>` +
9121
+ `<hp:run charPrIDRef="0"><hp:t></hp:t></hp:run>` +
9122
+ `<hp:linesegarray><hp:lineseg textpos="0" vertpos="0" vertsize="1000" textheight="1000" baseline="850" spacing="600" horzpos="0" horzsize="0" flags="393216"/></hp:linesegarray>` +
9123
+ `</hp:p></hs:sec>`;
9124
+ }
8707
9125
  /**
8708
9126
  * Add hp:pic tag to section XML
8709
9127
  */
@@ -8806,7 +9224,7 @@ class HwpxDocument {
8806
9224
  for (const table of tables) {
8807
9225
  const tableXml = xml.substring(table.startIndex, table.endIndex);
8808
9226
  // Find cells in this table
8809
- const cellMatches = [...tableXml.matchAll(/<(?:hp|hs):tc[^>]*>([\s\S]*?)<\/(?:hp|hs):tc>/g)];
9227
+ const cellMatches = [...tableXml.matchAll(/<(?:hp|hs):tc(?:\s[^>]*)?>([\s\S]*?)<\/(?:hp|hs):tc>/g)];
8810
9228
  for (const cellMatch of cellMatches) {
8811
9229
  const cellContent = cellMatch[1];
8812
9230
  const textContent = this.extractTextFromCellXml(cellContent);
@@ -8838,7 +9256,7 @@ class HwpxDocument {
8838
9256
  */
8839
9257
  findAllParagraphsInCell(cellXml) {
8840
9258
  const paragraphs = [];
8841
- const paragraphRegex = /<hp:p[^>]*>[\s\S]*?<\/hp:p>/g;
9259
+ const paragraphRegex = /<hp:p(?:\s[^>]*)?>[\s\S]*?<\/hp:p>/g;
8842
9260
  let match;
8843
9261
  while ((match = paragraphRegex.exec(cellXml)) !== null) {
8844
9262
  paragraphs.push({
@@ -9021,7 +9439,7 @@ class HwpxDocument {
9021
9439
  findTblTagIssues(xml) {
9022
9440
  const issues = [];
9023
9441
  // Track table tag positions
9024
- const tblOpenRegex = /<(?:hp|hs|hc):tbl[^>]*>/g;
9442
+ const tblOpenRegex = /<(?:hp|hs|hc):tbl(?:\s[^>]*)?>/g;
9025
9443
  const tblCloseRegex = /<\/(?:hp|hs|hc):tbl>/g;
9026
9444
  const allPositions = [];
9027
9445
  let match;
@@ -9074,11 +9492,11 @@ class HwpxDocument {
9074
9492
  checkNestingErrors(xml) {
9075
9493
  const issues = [];
9076
9494
  // Check for tc outside of tr
9077
- const tcOutsideTr = /<(?:hp|hs|hc):tc[^>]*>(?:(?!<(?:hp|hs|hc):tr[^>]*>).)*?<\/(?:hp|hs|hc):tc>/gs;
9495
+ const tcOutsideTr = /<(?:hp|hs|hc):tc(?:\s[^>]*)?>(?:(?!<(?:hp|hs|hc):tr(?:\s[^>]*)?>).)*?<\/(?:hp|hs|hc):tc>/gs;
9078
9496
  // This is simplified - a full check would need proper nesting validation
9079
9497
  // Check for tr outside of tbl
9080
- const trPattern = /<(?:hp|hs|hc):tr[^>]*>/g;
9081
- const tblPattern = /<(?:hp|hs|hc):tbl[^>]*>/g;
9498
+ const trPattern = /<(?:hp|hs|hc):tr(?:\s[^>]*)?>/g;
9499
+ const tblPattern = /<(?:hp|hs|hc):tbl(?:\s[^>]*)?>/g;
9082
9500
  // Simple check: count if tr appears without preceding tbl
9083
9501
  let match;
9084
9502
  let lastTblPos = -1;
@@ -10621,7 +11039,7 @@ class HwpxDocument {
10621
11039
  return null;
10622
11040
  const targetRowData = rows[targetRow];
10623
11041
  // Extract content inside the row (between <hp:tr...> and </hp:tr>)
10624
- const rowOpenTagMatch = targetRowData.xml.match(/^<(?:hp|hs|hc):tr[^>]*>/);
11042
+ const rowOpenTagMatch = targetRowData.xml.match(/^<(?:hp|hs|hc):tr(?:\s[^>]*)?>/);
10625
11043
  if (!rowOpenTagMatch)
10626
11044
  return null;
10627
11045
  const rowContentStart = rowOpenTagMatch[0].length;
@@ -10635,7 +11053,7 @@ class HwpxDocument {
10635
11053
  return null;
10636
11054
  const targetCellData = cells[targetCol];
10637
11055
  // Extract content inside the cell (between <hp:tc...> and </hp:tc>)
10638
- const cellOpenTagMatch = targetCellData.xml.match(/^<(?:hp|hs|hc):tc[^>]*>/);
11056
+ const cellOpenTagMatch = targetCellData.xml.match(/^<(?:hp|hs|hc):tc(?:\s[^>]*)?>/);
10639
11057
  if (!cellOpenTagMatch)
10640
11058
  return null;
10641
11059
  const cellContentStart = cellOpenTagMatch[0].length;
@@ -10658,6 +11076,131 @@ class HwpxDocument {
10658
11076
  // ============================================================
10659
11077
  // Table Row Insert/Delete XML Persistence
10660
11078
  // ============================================================
11079
+ /**
11080
+ * Scale this table's column widths so they sum to its <hp:sz width>.
11081
+ *
11082
+ * Column widths are read from cells whose colSpan is 1 (the first one seen
11083
+ * per colAddr). Every cell then gets the sum of the scaled widths of the
11084
+ * columns it spans, so merged cells stay aligned. Rounding leftovers go to
11085
+ * the last column so the total is exact. Nested tables are not touched.
11086
+ */
11087
+ fitColumnsToTableWidth(tableXml) {
11088
+ const tableWidth = parseInt(tableXml.match(/^<hp:tbl\b[\s\S]*?<hp:sz width="(\d+)"/)?.[1] ?? '', 10);
11089
+ const colCnt = parseInt(tableXml.match(/^<hp:tbl\b[^>]*\bcolCnt="(\d+)"/)?.[1] ?? '', 10);
11090
+ if (!tableWidth || !colCnt)
11091
+ return tableXml;
11092
+ const rows = this.findAllElementsWithDepth(tableXml, 'tr');
11093
+ const own = [];
11094
+ rows.forEach((row, r) => {
11095
+ for (const cell of this.findAllElementsWithDepth(row.xml, 'tc')) {
11096
+ const tail = cell.xml.lastIndexOf('</hp:subList>');
11097
+ const from = tail === -1 ? 0 : tail;
11098
+ const props = cell.xml.slice(from);
11099
+ const col = parseInt(props.match(/<hp:cellAddr\b[^>]*\bcolAddr="(\d+)"/)?.[1] ?? '-1', 10);
11100
+ const span = parseInt(props.match(/<hp:cellSpan\b[^>]*\bcolSpan="(\d+)"/)?.[1] ?? '1', 10);
11101
+ const sz = props.match(/(<hp:cellSz\b[^>]*\bwidth=")(\d+)(")/);
11102
+ if (col < 0 || !sz || sz.index === undefined)
11103
+ continue;
11104
+ own.push({ row: r, cell, col, span, width: parseInt(sz[2], 10), at: from + sz.index + sz[1].length });
11105
+ }
11106
+ });
11107
+ const widths = new Array(colCnt).fill(0);
11108
+ for (const o of own)
11109
+ if (o.span === 1 && o.col < colCnt && widths[o.col] === 0)
11110
+ widths[o.col] = o.width;
11111
+ if (widths.some(w => w === 0))
11112
+ return tableXml; // cannot derive every column safely
11113
+ const sum = widths.reduce((a, b) => a + b, 0);
11114
+ if (sum === tableWidth)
11115
+ return tableXml;
11116
+ const scaled = widths.map(w => Math.floor((w * tableWidth) / sum));
11117
+ scaled[colCnt - 1] += tableWidth - scaled.reduce((a, b) => a + b, 0);
11118
+ let out = tableXml;
11119
+ for (let r = rows.length - 1; r >= 0; r--) {
11120
+ let rowXml = rows[r].xml;
11121
+ const cellsInRow = own.filter(o => o.row === r).sort((a, b) => b.cell.startIndex - a.cell.startIndex);
11122
+ for (const o of cellsInRow) {
11123
+ const w = scaled.slice(o.col, o.col + o.span).reduce((a, b) => a + b, 0);
11124
+ const newCell = o.cell.xml.slice(0, o.at) + String(w) + o.cell.xml.slice(o.at + String(o.width).length);
11125
+ rowXml = rowXml.slice(0, o.cell.startIndex) + newCell + rowXml.slice(o.cell.endIndex);
11126
+ }
11127
+ out = out.slice(0, rows[r].startIndex) + rowXml + out.slice(rows[r].endIndex);
11128
+ }
11129
+ return out;
11130
+ }
11131
+ /**
11132
+ * Locate one of a cell's OWN address/span attributes (`colAddr`, `rowAddr`,
11133
+ * `colSpan`, `rowSpan`) in `cellXml`, returning the value and the absolute
11134
+ * index of its digits so callers can rewrite it in place.
11135
+ *
11136
+ * Hancom writes them on `<hp:cellAddr>`/`<hp:cellSpan>` after the cell's
11137
+ * sub-list (209/209 corpus files). Hand-made files may put them on the
11138
+ * `<hp:tc>` start tag instead, which the parser also accepts. A nested
11139
+ * table's cells live inside the sub-list, so only the tail is searched for
11140
+ * the child form and only the start tag for the attribute form.
11141
+ */
11142
+ cellOwnAttr(cellXml, name) {
11143
+ const child = name.endsWith('Addr') ? 'cellAddr' : 'cellSpan';
11144
+ const tail = cellXml.lastIndexOf('</hp:subList>');
11145
+ const from = tail === -1 ? 0 : tail;
11146
+ const own = new RegExp(`(<hp:${child}\\b[^>]*\\b${name}=")(\\d+)"`).exec(cellXml.slice(from));
11147
+ if (own) {
11148
+ return { value: parseInt(own[2], 10), at: from + own.index + own[1].length, length: own[2].length };
11149
+ }
11150
+ const startTag = cellXml.slice(0, cellXml.indexOf('>') + 1);
11151
+ const attr = new RegExp(`(\\s${name}=")(\\d+)"`).exec(startTag);
11152
+ if (attr) {
11153
+ return { value: parseInt(attr[2], 10), at: attr.index + attr[1].length, length: attr[2].length };
11154
+ }
11155
+ return null;
11156
+ }
11157
+ /** Rewrite one of a cell's own attributes (see cellOwnAttr); no-op if absent. */
11158
+ setCellOwnAttr(cellXml, name, value) {
11159
+ const a = this.cellOwnAttr(cellXml, name);
11160
+ return a ? cellXml.slice(0, a.at) + String(value) + cellXml.slice(a.at + a.length) : cellXml;
11161
+ }
11162
+ /** A cell's own <hp:cellSz width> (after its sub-list, so never a nested table's). */
11163
+ cellOwnWidth(cellXml) {
11164
+ const tail = cellXml.lastIndexOf('</hp:subList>');
11165
+ const m = cellXml.slice(tail === -1 ? 0 : tail).match(/<hp:cellSz\b[^>]*\bwidth="(\d+)"/);
11166
+ return m ? parseInt(m[1], 10) : null;
11167
+ }
11168
+ /** Rewrite a cell's own <hp:cellSz width>; no-op if the cell has none or width <= 0. */
11169
+ setCellOwnWidth(cellXml, width) {
11170
+ if (width <= 0)
11171
+ return cellXml;
11172
+ const tail = cellXml.lastIndexOf('</hp:subList>');
11173
+ const from = tail === -1 ? 0 : tail;
11174
+ const m = /(<hp:cellSz\b[^>]*\bwidth=")(\d+)"/.exec(cellXml.slice(from));
11175
+ if (!m)
11176
+ return cellXml;
11177
+ const at = from + m.index + m[1].length;
11178
+ return cellXml.slice(0, at) + String(width) + cellXml.slice(at + m[2].length);
11179
+ }
11180
+ /**
11181
+ * Add `delta` to the rowAddr of every cell of THIS table whose rowAddr is
11182
+ * >= fromRow. Nested tables inside cells keep their own addresses.
11183
+ */
11184
+ shiftTableRowAddrs(tableXml, fromRow, delta) {
11185
+ let out = tableXml;
11186
+ const rows = this.findAllElementsWithDepth(out, 'tr');
11187
+ for (let r = rows.length - 1; r >= 0; r--) {
11188
+ const row = rows[r];
11189
+ const cells = this.findAllElementsWithDepth(row.xml, 'tc');
11190
+ let rowXml = row.xml;
11191
+ for (let c = cells.length - 1; c >= 0; c--) {
11192
+ const cell = cells[c];
11193
+ const addr = this.cellOwnAttr(cell.xml, 'rowAddr');
11194
+ if (!addr || addr.value < fromRow)
11195
+ continue;
11196
+ const newCell = this.setCellOwnAttr(cell.xml, 'rowAddr', addr.value + delta);
11197
+ rowXml = rowXml.slice(0, cell.startIndex) + newCell + rowXml.slice(cell.endIndex);
11198
+ }
11199
+ if (rowXml !== row.xml)
11200
+ out = out.slice(0, row.startIndex) + rowXml + out.slice(row.endIndex);
11201
+ }
11202
+ return out;
11203
+ }
10661
11204
  /**
10662
11205
  * Clone a table cell for a newly inserted row: same cell attributes, same
10663
11206
  * first-paragraph formatting, but a single paragraph holding `text`.
@@ -10687,6 +11230,65 @@ class HwpxDocument {
10687
11230
  `</${prefix}:p>`;
10688
11231
  return cellXml.slice(0, subListOpen.index + subListOpen[0].length) + paragraph + cellXml.slice(subListCloseIdx);
10689
11232
  }
11233
+ /**
11234
+ * Source cells for a new row inserted after `afterRow`, one per column
11235
+ * position, in column order, covering every column 0..colCnt-1 exactly once.
11236
+ *
11237
+ * For each column: the cell that STARTS there in the template row (keeping
11238
+ * its colSpan so horizontal merges carry over), otherwise the nearest row
11239
+ * above whose own cell starts there. A column no row starts is skipped by the
11240
+ * colSpan of the cell covering it. Returned XML still carries the source
11241
+ * addresses; the caller rewrites rowAddr/rowSpan.
11242
+ */
11243
+ gridCellsForNewRow(rows, afterRow) {
11244
+ const ownProps = (cellXml) => ({
11245
+ col: this.cellOwnAttr(cellXml, 'colAddr')?.value ?? -1,
11246
+ span: this.cellOwnAttr(cellXml, 'colSpan')?.value ?? 1,
11247
+ });
11248
+ // Cells with no address anywhere are placed by position in their row.
11249
+ const rowCells = rows.map(r => {
11250
+ let next = 0;
11251
+ return this.findAllElementsWithDepth(r.xml, 'tc').map(c => {
11252
+ const p = ownProps(c.xml);
11253
+ const col = p.col >= 0 ? p.col : next;
11254
+ next = col + p.span;
11255
+ return { xml: c.xml, col, span: p.span };
11256
+ });
11257
+ });
11258
+ const colCount = Math.max(0, ...rowCells.flat().map(c => c.col + c.span));
11259
+ const out = [];
11260
+ for (let col = 0; col < colCount;) {
11261
+ let pick;
11262
+ for (let r = afterRow; r >= 0 && !pick; r--)
11263
+ pick = rowCells[r].find(c => c.col === col);
11264
+ // Nothing above starts here (should not happen in a well-formed table):
11265
+ // fall back to any row below so the grid still has no hole.
11266
+ for (let r = afterRow + 1; r < rowCells.length && !pick; r++)
11267
+ pick = rowCells[r].find(c => c.col === col);
11268
+ if (!pick) {
11269
+ col++;
11270
+ continue;
11271
+ }
11272
+ // A cell borrowed from a row above may span columns the template row
11273
+ // splits; keep the template row's split by clamping to the next column
11274
+ // that the template row starts.
11275
+ let span = Math.max(1, pick.span);
11276
+ const nextTemplateStart = rowCells[afterRow].map(c => c.col).filter(c => c > col).sort((a, b) => a - b)[0];
11277
+ if (nextTemplateStart !== undefined && col + span > nextTemplateStart)
11278
+ span = nextTemplateStart - col;
11279
+ // Narrow through the cell's OWN attributes: a nested table's cells come
11280
+ // first in the XML, so replacing the first <hp:cellSpan> changed the
11281
+ // nested cell and left this one overlapping the next template cell.
11282
+ // Its width shrinks to the columns it still covers, so the row keeps
11283
+ // the table width.
11284
+ const xml = span === pick.span
11285
+ ? pick.xml
11286
+ : this.setCellOwnWidth(this.setCellOwnAttr(pick.xml, 'colSpan', span), Math.round((this.cellOwnWidth(pick.xml) ?? 0) * span / pick.span));
11287
+ out.push(xml);
11288
+ col += span;
11289
+ }
11290
+ return out;
11291
+ }
10690
11292
  async applyTableRowInsertsToXml() {
10691
11293
  if (!this._zip)
10692
11294
  return;
@@ -10713,25 +11315,40 @@ class HwpxDocument {
10713
11315
  if (insert.afterRowIndex >= rows.length)
10714
11316
  continue;
10715
11317
  const templateRow = rows[insert.afterRowIndex];
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.
11318
+ // Build the new row from the table's COLUMN GRID, not from the template
11319
+ // row's cells. A row just below a vertical merge has no <hp:tc> for the
11320
+ // merged column (the master above covers it), so cloning its cells gave
11321
+ // the new row a hole there: colCnt=3 but only columns 1-2 present
11322
+ // (CodeRabbit, 2026-09-24). For each column position we take the cell
11323
+ // that starts there in the template row, or — if the template row has
11324
+ // none — the nearest row above that does, cloned as a single-row cell.
11325
+ //
11326
+ // Each new cell keeps its source's formatting but only its FIRST
11327
+ // paragraph, emptied: cloning every paragraph copied multi-line cells
11328
+ // (e.g. "○ a\n○ b\n- c") as three empty lines, so Hancom sized the row
11329
+ // for three lines and the one line of new text sat at the top.
10721
11330
  const newRowAddr = insert.afterRowIndex + 1;
10722
- const templateCells = this.findAllElementsWithDepth(templateRow.xml, 'tc');
10723
- let newRowXml = templateRow.xml;
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
- }
10730
- // Update rowAddr in each cell
10731
- newRowXml = newRowXml.replace(/rowAddr="(\d+)"/g, `rowAddr="${newRowAddr}"`);
10732
- // Insert after the template row
10733
- const insertPos = templateRow.startIndex + templateRow.xml.length;
10734
- const newTableXml = tableXml.substring(0, insertPos) + '\n' + newRowXml + tableXml.substring(insertPos);
11331
+ const newRowCells = this.gridCellsForNewRow(rows, insert.afterRowIndex);
11332
+ const trOpen = templateRow.xml.slice(0, templateRow.xml.indexOf('>') + 1);
11333
+ let newRowXml = trOpen + newRowCells.map((cellXml, i) => {
11334
+ const text = insert.cellTexts?.[i] ?? '';
11335
+ // New cells sit on row afterRowIndex+1 and span one row each.
11336
+ const cell = this.setCellOwnAttr(this.cloneCellWithText(cellXml, text), 'rowAddr', newRowAddr);
11337
+ return this.setCellOwnAttr(cell, 'rowSpan', 1);
11338
+ }).join('') + '</hp:tr>';
11339
+ // Shift every existing cell below the insertion point down one row.
11340
+ // Without this the next row kept rowAddr=afterRowIndex+1 — the same as
11341
+ // the new row — and Hancom 2020 hung opening the file (reported
11342
+ // 2026-09-24; renumbering rowAddr by <hp:tr> order made it open).
11343
+ // Only the table's OWN cells are touched: a nested table in a cell has
11344
+ // its own row addresses. The delete path does the mirror of this.
11345
+ const shiftedTableXml = this.shiftTableRowAddrs(tableXml, newRowAddr, +1);
11346
+ // Insert after the template row (positions unchanged by the shift above:
11347
+ // it rewrites digits in place only after re-finding rows).
11348
+ const rowsAfterShift = this.findAllElementsWithDepth(shiftedTableXml, 'tr');
11349
+ const anchorRow = rowsAfterShift[insert.afterRowIndex];
11350
+ const insertPos = anchorRow.startIndex + anchorRow.xml.length;
11351
+ const newTableXml = shiftedTableXml.substring(0, insertPos) + '\n' + newRowXml + shiftedTableXml.substring(insertPos);
10735
11352
  // Update rowCnt attribute
10736
11353
  const updatedTableXml = newTableXml.replace(/rowCnt="(\d+)"/, (_m, cnt) => `rowCnt="${parseInt(cnt) + 1}"`);
10737
11354
  xml = xml.substring(0, tables[insert.tableIndex].startIndex) + updatedTableXml + xml.substring(tables[insert.tableIndex].endIndex);
@@ -10909,6 +11526,13 @@ class HwpxDocument {
10909
11526
  }
10910
11527
  // Update colCnt
10911
11528
  tableXml = tableXml.replace(/colCnt="(\d+)"/, (_m, cnt) => `colCnt="${parseInt(cnt) + 1}"`);
11529
+ // Keep the table inside its original width. The new column cloned the
11530
+ // template column's width, so the columns summed to more than the
11531
+ // table: reported 2026-09-24, 4 × 11765 + 11765 = 58825 > body 51024
11532
+ // while <hp:sz width> still said 47060, and the table ran past the
11533
+ // right margin. Scale every column by the same factor so the total is
11534
+ // exactly the table's width again.
11535
+ tableXml = this.fitColumnsToTableWidth(tableXml);
10912
11536
  xml = xml.substring(0, tables[insert.tableIndex].startIndex) + tableXml + xml.substring(tables[insert.tableIndex].endIndex);
10913
11537
  }
10914
11538
  this._zip.file(sectionPath, xml);
@@ -11174,6 +11798,8 @@ exports.HwpxDocument = HwpxDocument;
11174
11798
  // Constants for magic numbers
11175
11799
  HwpxDocument.NESTED_CHECK_LOOKBACK = 500;
11176
11800
  HwpxDocument.SEARCH_SKIP_OFFSET = 10;
11801
+ /** Container elements whose content belongs to OTHER paragraphs or objects. */
11802
+ HwpxDocument.NESTED_CONTENT = /<hp:(tbl|subList|equation|pic|rect|ellipse|polygon|curve|arc|line|container|drawText|textart|ole|footNote|endNote|header|footer)\b/;
11177
11803
  /**
11178
11804
  * Default chunk size for splitting long text (in characters).
11179
11805
  * Texts longer than this will be split into multiple <hp:run> elements.