@kimdayoun/hwpx-mcp 0.3.3 → 0.3.5

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]) {
@@ -525,35 +564,36 @@ class HwpxDocument {
525
564
  // Track for XML update - always add if we have a zip (HWPX file)
526
565
  // Similar to updateTableCell which always tracks changes
527
566
  if (this._zip) {
528
- const oldText = paragraph.runs[runIndex].text || '';
529
567
  const paragraphOccurrence = this.getParagraphOccurrence(sectionIndex, elementIndex, paragraph.id || '');
530
- this._pendingDirectTextUpdates.push({
531
- sectionIndex,
532
- elementIndex,
533
- paragraphId: paragraph.id || '', // Use stable paragraph ID for reliable identification
534
- paragraphOccurrence,
535
- paragraph,
536
- runIndex,
537
- oldText,
538
- newText: text
539
- });
540
- // When updating run 0, clear other runs (full paragraph replacement)
541
568
  if (runIndex === 0) {
542
- for (let i = 1; i < paragraph.runs.length; i++) {
543
- const otherOldText = paragraph.runs[i].text || '';
544
- if (otherOldText) {
545
- this._pendingDirectTextUpdates.push({
546
- sectionIndex,
547
- elementIndex,
548
- paragraphId: paragraph.id || '',
549
- paragraphOccurrence,
550
- paragraph,
551
- runIndex: i,
552
- oldText: otherOldText,
553
- newText: '' // Clear other runs
554
- });
555
- }
556
- }
569
+ // Whole-paragraph replacement is written by position (see
570
+ // wholeParagraph): the new text goes into the paragraph's first own
571
+ // text node and its other own text nodes are emptied. A paragraph that
572
+ // holds a text box reads as its own text only; the box's text is the
573
+ // next element, so editing it writes inside the box.
574
+ this._pendingDirectTextUpdates.push({
575
+ sectionIndex,
576
+ elementIndex,
577
+ paragraphId: paragraph.id || '',
578
+ paragraphOccurrence,
579
+ paragraph,
580
+ runIndex: 0,
581
+ oldText: paragraph.runs.map(r => r.text || '').join(''),
582
+ newText: text,
583
+ wholeParagraph: true,
584
+ });
585
+ }
586
+ else {
587
+ this._pendingDirectTextUpdates.push({
588
+ sectionIndex,
589
+ elementIndex,
590
+ paragraphId: paragraph.id || '', // Use stable paragraph ID for reliable identification
591
+ paragraphOccurrence,
592
+ paragraph,
593
+ runIndex,
594
+ oldText: paragraph.runs[runIndex].text || '',
595
+ newText: text
596
+ });
557
597
  }
558
598
  }
559
599
  this.saveState();
@@ -1093,7 +1133,7 @@ class HwpxDocument {
1093
1133
  this._pendingTableCellHangingIndents[existingIdx].indentPt = indentPt;
1094
1134
  }
1095
1135
  else {
1096
- this._pendingTableCellHangingIndents.push({
1136
+ this.queueTableOp(this._pendingTableCellHangingIndents, {
1097
1137
  sectionIndex,
1098
1138
  tableIndex,
1099
1139
  row,
@@ -1172,7 +1212,7 @@ class HwpxDocument {
1172
1212
  this._pendingTableCellHangingIndents[existingIdx].indentPt = 0; // 0 means remove
1173
1213
  }
1174
1214
  else {
1175
- this._pendingTableCellHangingIndents.push({
1215
+ this.queueTableOp(this._pendingTableCellHangingIndents, {
1176
1216
  sectionIndex,
1177
1217
  tableIndex,
1178
1218
  row,
@@ -1288,12 +1328,22 @@ class HwpxDocument {
1288
1328
  /**
1289
1329
  * Get table map with headers - maps table indices to their header paragraphs
1290
1330
  * Returns array of table info including the header text from the preceding paragraph
1331
+ *
1332
+ * Two indices are returned because they differ once a document has more than
1333
+ * one section:
1334
+ * - `table_index_in_section` — what every table tool (update_table_cell,
1335
+ * get_table_cell, insert_table_row, …) expects together with
1336
+ * `section_index`. Use this one.
1337
+ * - `table_index` — position across the whole document, kept for callers
1338
+ * that list tables. Passing it to a table tool in section 1+ addresses a
1339
+ * DIFFERENT table (reported 2026-09-24: map said 5, the tool needed 4).
1291
1340
  */
1292
1341
  getTableMap() {
1293
1342
  const result = [];
1294
1343
  let globalTableIndex = 0;
1295
1344
  this._content.sections.forEach((section, sectionIndex) => {
1296
1345
  let lastParagraphText = '';
1346
+ let sectionTableIndex = 0;
1297
1347
  section.elements.forEach((element, _elementIndex) => {
1298
1348
  if (element.type === 'paragraph') {
1299
1349
  // Store the paragraph text as potential header
@@ -1316,6 +1366,7 @@ class HwpxDocument {
1316
1366
  }) || [];
1317
1367
  result.push({
1318
1368
  table_index: globalTableIndex,
1369
+ table_index_in_section: sectionTableIndex,
1319
1370
  section_index: sectionIndex,
1320
1371
  header: lastParagraphText,
1321
1372
  rows,
@@ -1324,6 +1375,7 @@ class HwpxDocument {
1324
1375
  first_row_preview: firstRowPreview,
1325
1376
  });
1326
1377
  globalTableIndex++;
1378
+ sectionTableIndex++;
1327
1379
  // Don't reset lastParagraphText here - next table might reuse same header if consecutive
1328
1380
  }
1329
1381
  });
@@ -2174,7 +2226,7 @@ class HwpxDocument {
2174
2226
  // Track cell update for XML sync (works for both empty and non-empty cells)
2175
2227
  // Store table ID for reliable XML matching
2176
2228
  // charShapeId is optional - if provided, it will override the existing charPrIDRef
2177
- this._pendingTableCellUpdates.push({ sectionIndex, tableIndex, tableId: table.id, row, col, text, charShapeId });
2229
+ this.queueTableOp(this._pendingTableCellUpdates, { sectionIndex, tableIndex, tableId: table.id, row, col, colAddr: cell.colAddr, text, charShapeId });
2178
2230
  this.saveState();
2179
2231
  if (cell.paragraphs.length > 0 && cell.paragraphs[0].runs.length > 0) {
2180
2232
  cell.paragraphs[0].runs[0].text = text;
@@ -2201,19 +2253,80 @@ class HwpxDocument {
2201
2253
  const table = this.findTable(sectionIndex, tableIndex);
2202
2254
  if (!table || !table.rows[afterRowIndex])
2203
2255
  return false;
2256
+ // A new row between afterRowIndex and afterRowIndex+1 must not cut through
2257
+ // a vertical merge. Cloning a row that holds a rowSpan>1 master, or one that
2258
+ // sits inside such a span, copied the span into the gap and made the merged
2259
+ // area overlap the new row (reported 2026-09-24: rowSpan=2 header, after_row 0).
2260
+ for (const row of table.rows) {
2261
+ for (const cell of row.cells) {
2262
+ const top = cell.rowAddr ?? table.rows.indexOf(row);
2263
+ const span = cell.rowSpan ?? 1;
2264
+ if (span > 1 && top <= afterRowIndex && afterRowIndex < top + span - 1) {
2265
+ throw new Error(`Cannot insert a row after row ${afterRowIndex}: it would split the merged cell at ` +
2266
+ `(${top}, ${cell.colAddr ?? 0}) that spans rows ${top}-${top + span - 1}. ` +
2267
+ `Insert after row ${top + span - 1} instead, or unmerge first.`);
2268
+ }
2269
+ }
2270
+ }
2204
2271
  this.saveState();
2205
- const templateRow = table.rows[afterRowIndex];
2206
- const colCount = templateRow.cells.length;
2272
+ // Same column grid as the XML path (gridCellsForNewRow): one cell per
2273
+ // column position, taking the colAddr/colSpan of the cell that starts
2274
+ // there in the template row or the nearest row above. Sizing the row by
2275
+ // templateRow.cells.length left out a column covered by a vertical merge.
2276
+ // A cell with no colAddr (e.g. added by insertTableColumn, which does not
2277
+ // renumber) is placed by its position in the row, as gridCellsForNewRow
2278
+ // does for the XML. Dropping it made the new row one cell short.
2279
+ const placed = (r) => {
2280
+ let next = 0;
2281
+ return (table.rows[r]?.cells ?? []).map(c => {
2282
+ const span = c.colSpan ?? 1;
2283
+ const col = c.colAddr ?? next;
2284
+ next = col + span;
2285
+ return { col, span };
2286
+ });
2287
+ };
2288
+ const starts = (r) => new Map(placed(r).map(c => [c.col, c.span]));
2289
+ const colCount = Math.max(0, ...table.rows.map((_, r) => Math.max(0, ...placed(r).map(c => c.col + c.span))));
2290
+ const templateStarts = placed(afterRowIndex).map(c => c.col).sort((a, b) => a - b);
2291
+ const grid = [];
2292
+ for (let col = 0; col < colCount;) {
2293
+ let span;
2294
+ for (let r = afterRowIndex; r >= 0 && span === undefined; r--)
2295
+ span = starts(r).get(col);
2296
+ for (let r = afterRowIndex + 1; r < table.rows.length && span === undefined; r++)
2297
+ span = starts(r).get(col);
2298
+ if (span === undefined) {
2299
+ col++;
2300
+ continue;
2301
+ }
2302
+ const next = templateStarts.find(c => c > col);
2303
+ if (next !== undefined && col + span > next)
2304
+ span = next - col;
2305
+ grid.push({ colAddr: col, colSpan: Math.max(1, span) });
2306
+ col += Math.max(1, span);
2307
+ }
2207
2308
  const newRow = {
2208
- cells: Array.from({ length: colCount }, (_, i) => ({
2309
+ cells: grid.map((g, i) => ({
2310
+ rowAddr: afterRowIndex + 1,
2311
+ colAddr: g.colAddr,
2312
+ rowSpan: 1,
2313
+ colSpan: g.colSpan,
2209
2314
  paragraphs: [{
2210
2315
  id: Math.random().toString(36).substring(2, 11),
2211
2316
  runs: [{ text: cellTexts?.[i] || '' }],
2212
2317
  }],
2213
2318
  })),
2214
2319
  };
2320
+ // Keep memory row addresses in step with the XML renumbering, so a later
2321
+ // merge/split/insert on this table reads the right rows.
2322
+ for (const row of table.rows) {
2323
+ for (const cell of row.cells) {
2324
+ if (cell.rowAddr !== undefined && cell.rowAddr > afterRowIndex)
2325
+ cell.rowAddr += 1;
2326
+ }
2327
+ }
2215
2328
  table.rows.splice(afterRowIndex + 1, 0, newRow);
2216
- this._pendingTableRowInserts.push({
2329
+ this.queueTableOp(this._pendingTableRowInserts, {
2217
2330
  sectionIndex,
2218
2331
  tableIndex,
2219
2332
  afterRowIndex,
@@ -2231,8 +2344,27 @@ class HwpxDocument {
2231
2344
  return this.deleteTable(sectionIndex, tableIndex);
2232
2345
  }
2233
2346
  this.saveState();
2347
+ // Mirror applyTableRowDeletesToXml so later edits read the same addresses the
2348
+ // XML has after replay: a vertical merge from an earlier row that reaches the
2349
+ // deleted row loses one row, and cells below move up one row. Stale rowAddr
2350
+ // made the row-insert guard refuse an insert below a merge and allow one
2351
+ // through it (CodeRabbit, 2026-09-24).
2352
+ for (let r = 0; r < rowIndex; r++) {
2353
+ for (const cell of table.rows[r]?.cells ?? []) {
2354
+ const top = cell.rowAddr ?? r;
2355
+ const span = cell.rowSpan ?? 1;
2356
+ if (span > 1 && top + span > rowIndex)
2357
+ cell.rowSpan = span - 1;
2358
+ }
2359
+ }
2234
2360
  table.rows.splice(rowIndex, 1);
2235
- this._pendingTableRowDeletes.push({
2361
+ for (const row of table.rows) {
2362
+ for (const cell of row.cells) {
2363
+ if (cell.rowAddr !== undefined && cell.rowAddr > rowIndex)
2364
+ cell.rowAddr -= 1;
2365
+ }
2366
+ }
2367
+ this.queueTableOp(this._pendingTableRowDeletes, {
2236
2368
  sectionIndex,
2237
2369
  tableIndex,
2238
2370
  rowIndex,
@@ -2282,15 +2414,29 @@ class HwpxDocument {
2282
2414
  if (!table)
2283
2415
  return false;
2284
2416
  this.saveState();
2417
+ // Keep memory addresses in step with the XML path (applyTableColumnInsertsToXml
2418
+ // gives the new cell colAddr afterColIndex+1 and shifts the cells after it).
2419
+ // A new cell with no colAddr in the middle of a row made the row read as
2420
+ // [0, (none), 1]: the grid for a later row insert counted column 1 twice and
2421
+ // the new row came out one cell short (CodeRabbit, 2026-09-24).
2285
2422
  for (const row of table.rows) {
2423
+ for (const cell of row.cells) {
2424
+ if (cell.colAddr !== undefined && cell.colAddr > afterColIndex)
2425
+ cell.colAddr += 1;
2426
+ }
2427
+ const rowAddr = row.cells.find(c => c.rowAddr !== undefined)?.rowAddr;
2286
2428
  row.cells.splice(afterColIndex + 1, 0, {
2429
+ colAddr: afterColIndex + 1,
2430
+ ...(rowAddr !== undefined ? { rowAddr } : {}),
2431
+ colSpan: 1,
2432
+ rowSpan: 1,
2287
2433
  paragraphs: [{
2288
2434
  id: Math.random().toString(36).substring(2, 11),
2289
2435
  runs: [{ text: '' }],
2290
2436
  }],
2291
2437
  });
2292
2438
  }
2293
- this._pendingTableColumnInserts.push({
2439
+ this.queueTableOp(this._pendingTableColumnInserts, {
2294
2440
  sectionIndex,
2295
2441
  tableIndex,
2296
2442
  afterColIndex,
@@ -2303,10 +2449,18 @@ class HwpxDocument {
2303
2449
  if (!table || (table.rows[0]?.cells.length || 0) <= 1)
2304
2450
  return false;
2305
2451
  this.saveState();
2452
+ // Mirror applyTableColumnDeletesToXml: cells after the deleted column move one
2453
+ // column left. A write queued after the delete carries the cell's colAddr and
2454
+ // the XML is matched by it, so a stale address sent the text nowhere
2455
+ // (CodeRabbit, 2026-09-24; 0.3.3 dropped these writes too).
2306
2456
  for (const row of table.rows) {
2307
2457
  row.cells.splice(colIndex, 1);
2458
+ for (const cell of row.cells) {
2459
+ if (cell.colAddr !== undefined && cell.colAddr > colIndex)
2460
+ cell.colAddr -= 1;
2461
+ }
2308
2462
  }
2309
- this._pendingTableColumnDeletes.push({
2463
+ this.queueTableOp(this._pendingTableColumnDeletes, {
2310
2464
  sectionIndex,
2311
2465
  tableIndex,
2312
2466
  colIndex,
@@ -2492,12 +2646,13 @@ class HwpxDocument {
2492
2646
  const cellText = cell.paragraphs.map(p => p.runs.map(r => r.text).join('')).join('\n');
2493
2647
  // Use existing pending table cell update mechanism
2494
2648
  this._pendingTableCellUpdates = this._pendingTableCellUpdates || [];
2495
- this._pendingTableCellUpdates.push({
2649
+ this.queueTableOp(this._pendingTableCellUpdates, {
2496
2650
  sectionIndex,
2497
2651
  tableIndex,
2498
2652
  tableId,
2499
2653
  row,
2500
2654
  col,
2655
+ colAddr: cell.colAddr,
2501
2656
  text: cellText,
2502
2657
  });
2503
2658
  this.markModified();
@@ -2856,7 +3011,7 @@ class HwpxDocument {
2856
3011
  if (!this._pendingNestedTableInserts) {
2857
3012
  this._pendingNestedTableInserts = [];
2858
3013
  }
2859
- this._pendingNestedTableInserts.push({
3014
+ this.queueTableOp(this._pendingNestedTableInserts, {
2860
3015
  sectionIndex,
2861
3016
  parentTableIndex,
2862
3017
  row,
@@ -2923,6 +3078,29 @@ class HwpxDocument {
2923
3078
  console.warn(`[HwpxDocument] mergeCells: Single cell selected, no merge needed`);
2924
3079
  return false;
2925
3080
  }
3081
+ // A row whose every own cell falls inside the merge is saved as an <hp:tr>
3082
+ // with no <hp:tc>. 한/글 2024 gave no PDF for such a file (measured: a
3083
+ // full-width two-row merge and a vertical merge in a one-column table; the
3084
+ // same table merged short of full width converted), and a scan of 275 한/글
3085
+ // originals found no row without a cell. 0.3.3 wrote these files too.
3086
+ // Rows built in memory keep covered cells and rows read from a file do not,
3087
+ // so cells are placed by their own address (position only when it has none)
3088
+ // and a cell counts only if no other merged cell covers it.
3089
+ const placedCells = table.rows.flatMap((row, ri) => row.cells.map((cell, ci) => ({ cell, row: cell.rowAddr ?? ri, col: cell.colAddr ?? ci })));
3090
+ const masters = placedCells.filter(p => (p.cell.rowSpan ?? 1) > 1 || (p.cell.colSpan ?? 1) > 1);
3091
+ const coveredByOther = (p) => masters.some(m => m.cell !== p.cell &&
3092
+ p.row >= m.row && p.row < m.row + (m.cell.rowSpan ?? 1) &&
3093
+ p.col >= m.col && p.col < m.col + (m.cell.colSpan ?? 1));
3094
+ for (let r = startRow + 1; r <= endRow; r++) {
3095
+ const keepsCell = placedCells.some(p => p.row === r &&
3096
+ (p.col + (p.cell.colSpan ?? 1) - 1 < startCol || p.col > endCol) &&
3097
+ !coveredByOther(p));
3098
+ if (!keepsCell) {
3099
+ throw new Error(`Cannot merge (${startRow}, ${startCol})-(${endRow}, ${endCol}): row ${r} would have no ` +
3100
+ `cell of its own, and 한/글 does not open a table row without cells. Merge fewer ` +
3101
+ `columns so row ${r} keeps a cell, or delete row ${r} instead.`);
3102
+ }
3103
+ }
2926
3104
  this.saveState();
2927
3105
  // Calculate span values
2928
3106
  const colSpan = endCol - startCol + 1;
@@ -2934,7 +3112,7 @@ class HwpxDocument {
2934
3112
  masterCell.rowSpan = rowSpan;
2935
3113
  }
2936
3114
  // Add to pending merges for XML application during save
2937
- this._pendingCellMerges.push({
3115
+ this.queueTableOp(this._pendingCellMerges, {
2938
3116
  sectionIndex,
2939
3117
  tableIndex,
2940
3118
  startRow,
@@ -3001,7 +3179,7 @@ class HwpxDocument {
3001
3179
  cell.rowSpan = 1;
3002
3180
  }
3003
3181
  // Add to pending splits for XML application during save
3004
- this._pendingCellSplits.push({
3182
+ this.queueTableOp(this._pendingCellSplits, {
3005
3183
  sectionIndex,
3006
3184
  tableIndex,
3007
3185
  row,
@@ -3407,7 +3585,7 @@ class HwpxDocument {
3407
3585
  // Get original image dimensions from binary data
3408
3586
  const orgDimensions = this.getImageDimensions(imageData.data, imageData.mimeType);
3409
3587
  // Add to pending cell image inserts
3410
- this._pendingCellImageInserts.push({
3588
+ this.queueTableOp(this._pendingCellImageInserts, {
3411
3589
  sectionIndex,
3412
3590
  tableIndex,
3413
3591
  row,
@@ -3686,13 +3864,18 @@ class HwpxDocument {
3686
3864
  }));
3687
3865
  }
3688
3866
  insertSection(afterSectionIndex) {
3867
+ if (afterSectionIndex < -1 || afterSectionIndex >= this._content.sections.length) {
3868
+ throw new Error(`Cannot insert a section after ${afterSectionIndex}: document has ${this._content.sections.length} section(s).`);
3869
+ }
3689
3870
  this.saveState();
3871
+ // The first paragraph of every section carries <hp:secPr>, so it must have
3872
+ // an XML id the anchors can find. '0' matches the section template below.
3690
3873
  const newSection = {
3691
3874
  id: Math.random().toString(36).substring(2, 11),
3692
3875
  elements: [{
3693
3876
  type: 'paragraph',
3694
3877
  data: {
3695
- id: Math.random().toString(36).substring(2, 11),
3878
+ id: '0',
3696
3879
  runs: [{ text: '' }],
3697
3880
  },
3698
3881
  }],
@@ -3707,9 +3890,44 @@ class HwpxDocument {
3707
3890
  };
3708
3891
  const insertIndex = afterSectionIndex + 1;
3709
3892
  this._content.sections.splice(insertIndex, 0, newSection);
3893
+ this.markStructureChanged();
3894
+ // insertSection used to change only the memory model: save wrote no
3895
+ // sectionN.xml, so a two-section document silently came back with one
3896
+ // section and everything added to the new section was lost (measured on
3897
+ // 0.3.3 with insert_section + insert_table, 2026-09-24).
3898
+ this._pendingSectionOps.push({ op: 'insert', at: insertIndex, templateFrom: Math.max(0, afterSectionIndex) });
3899
+ // Section files are created at the start of save, before every other
3900
+ // pending edit is replayed. Edits recorded earlier still name sections by
3901
+ // their old number; shift those at or after the insertion point so they
3902
+ // land in the same section after the renumbering (measured: an edit to the
3903
+ // old section 0, then insert_section(-1), wrote into the new section 0).
3904
+ this.shiftPendingSectionIndices(insertIndex, +1);
3710
3905
  this.markModified();
3711
3906
  return insertIndex;
3712
3907
  }
3908
+ /**
3909
+ * Add `delta` to every section number held by a pending edit that is >= from.
3910
+ * Covers all pending arrays generically: any numeric field whose name is
3911
+ * sectionIndex or ends in "Section"/"SectionIndex" (source/target pairs).
3912
+ */
3913
+ shiftPendingSectionIndices(from, delta) {
3914
+ const isSectionKey = (k) => k === 'sectionIndex' || /Section(Index)?$/.test(k);
3915
+ for (const key of Object.keys(this)) {
3916
+ if (!String(key).startsWith('_pending') || key === '_pendingSectionOps')
3917
+ continue;
3918
+ const list = this[key];
3919
+ if (!Array.isArray(list))
3920
+ continue;
3921
+ for (const item of list) {
3922
+ if (!item || typeof item !== 'object')
3923
+ continue;
3924
+ for (const [k, v] of Object.entries(item)) {
3925
+ if (isSectionKey(k) && typeof v === 'number' && v >= from)
3926
+ item[k] = v + delta;
3927
+ }
3928
+ }
3929
+ }
3930
+ }
3713
3931
  deleteSection(sectionIndex) {
3714
3932
  if (sectionIndex < 0 || sectionIndex >= this._content.sections.length)
3715
3933
  return false;
@@ -3717,6 +3935,23 @@ class HwpxDocument {
3717
3935
  return false; // Cannot delete the last section
3718
3936
  this.saveState();
3719
3937
  this._content.sections.splice(sectionIndex, 1);
3938
+ this.markStructureChanged();
3939
+ // Same persistence gap as insertSection had: the memory model lost the
3940
+ // section but save kept its file, so the deleted section came back on
3941
+ // reopen. Pending edits aimed at the deleted section are dropped; later
3942
+ // sections move down one number.
3943
+ for (const key of Object.keys(this)) {
3944
+ if (!String(key).startsWith('_pending') || key === '_pendingSectionOps')
3945
+ continue;
3946
+ const list = this[key];
3947
+ if (!Array.isArray(list))
3948
+ continue;
3949
+ const kept = list.filter(item => !(item && typeof item === 'object' &&
3950
+ Object.entries(item).some(([k, v]) => (k === 'sectionIndex' || /Section(Index)?$/.test(k)) && v === sectionIndex)));
3951
+ this[key] = kept;
3952
+ }
3953
+ this.shiftPendingSectionIndices(sectionIndex + 1, -1);
3954
+ this._pendingSectionOps.push({ op: 'delete', at: sectionIndex, templateFrom: 0 });
3720
3955
  this.markModified();
3721
3956
  return true;
3722
3957
  }
@@ -3844,6 +4079,13 @@ class HwpxDocument {
3844
4079
  async syncContentToZip() {
3845
4080
  if (!this._zip)
3846
4081
  return;
4082
+ // New sections first: every later step addresses Contents/sectionN.xml by
4083
+ // the memory section index, so the files must already exist and be numbered
4084
+ // the same way.
4085
+ if (this._pendingSectionOps.length > 0) {
4086
+ await this.applySectionOpsToZip();
4087
+ this._pendingSectionOps = [];
4088
+ }
3847
4089
  // Replay paragraph/table inserts and paragraph copies/moves together, in
3848
4090
  // call order, before any text update. Text updates resolve their target in
3849
4091
  // the current XML, and other operations locate tables by index, so the
@@ -3874,31 +4116,13 @@ class HwpxDocument {
3874
4116
  await this.applyTableMovesToXml();
3875
4117
  this._pendingTableMoves = [];
3876
4118
  }
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
- }
4119
+ // Table edits that address cells or rows/columns by index, replayed in
4120
+ // CALL order. Each index is relative to the table as it was when that edit
4121
+ // was made; applying them by kind (all cell writes, then all row inserts,
4122
+ // then column inserts ...) wrote cell text into the pre-insert layout and
4123
+ // dropped text written to a new row or column (CodeRabbit, 2026-09-24; the
4124
+ // same 5 scenarios failed on 0.3.3).
4125
+ await this.applyTableOpsInCallOrder();
3902
4126
  // Apply direct text updates (from updateParagraphText)
3903
4127
  if (this._pendingDirectTextUpdates && this._pendingDirectTextUpdates.length > 0) {
3904
4128
  await this.applyDirectTextUpdatesToXml();
@@ -3924,11 +4148,6 @@ class HwpxDocument {
3924
4148
  await this.applyHangingIndentsToXml();
3925
4149
  this._pendingHangingIndents = [];
3926
4150
  }
3927
- // Apply table cell hanging indent changes
3928
- if (this._pendingTableCellHangingIndents && this._pendingTableCellHangingIndents.length > 0) {
3929
- await this.applyTableCellHangingIndentsToXml();
3930
- this._pendingTableCellHangingIndents = [];
3931
- }
3932
4151
  // Apply paragraph style changes (alignment, etc.)
3933
4152
  if (this._pendingParagraphStyles && this._pendingParagraphStyles.length > 0) {
3934
4153
  await this.applyParagraphStylesToXml();
@@ -3939,26 +4158,6 @@ class HwpxDocument {
3939
4158
  await this.applyCharacterStylesToXml();
3940
4159
  this._pendingCharacterStyles = [];
3941
4160
  }
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
4161
  // Apply header/footer updates
3963
4162
  if (this._pendingHeaderUpdates && this._pendingHeaderUpdates.length > 0 ||
3964
4163
  this._pendingFooterUpdates && this._pendingFooterUpdates.length > 0) {
@@ -4137,10 +4336,10 @@ class HwpxDocument {
4137
4336
  }
4138
4337
  // Clean up empty runs that may be left behind
4139
4338
  // <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, '');
4339
+ xml = xml.replace(/<hp:run(?:\s[^>]*)?>(\s*<hp:t\s*\/>)?\s*<\/hp:run>/g, '');
4141
4340
  // Clean up empty paragraphs that only contained the image
4142
4341
  // <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, '');
4342
+ xml = xml.replace(/<hp:p(?:\s[^>]*)?>\s*(<hp:linesegarray[^>]*>[\s\S]*?<\/hp:linesegarray>)?\s*<\/hp:p>/g, '');
4144
4343
  if (modified) {
4145
4344
  this._zip.file(sectionPath, xml);
4146
4345
  }
@@ -4682,12 +4881,13 @@ class HwpxDocument {
4682
4881
  idMap.set(oldId, newId);
4683
4882
  }
4684
4883
  }
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;
4884
+ // Second pass: replace every id in one scan. Building a RegExp per old id
4885
+ // broke on ids with regex metacharacters, and replacing ids one at a time
4886
+ // could rewrite an id that an earlier replacement had just produced.
4887
+ return xml.replace(/id="([^"]+)"/g, (whole, oldId) => {
4888
+ const newId = idMap.get(oldId);
4889
+ return newId === undefined ? whole : `id="${newId}"`;
4890
+ });
4691
4891
  }
4692
4892
  /**
4693
4893
  * Find the position to insert an element after a given element index.
@@ -4705,7 +4905,7 @@ class HwpxDocument {
4705
4905
  // Find all root-level elements (paragraphs, tables)
4706
4906
  const elements = [];
4707
4907
  // Find paragraphs (not inside subList)
4708
- const pRegex = /<hp:p[^>]*>[\s\S]*?<\/hp:p>/g;
4908
+ const pRegex = /<hp:p(?:\s[^>]*)?>[\s\S]*?<\/hp:p>/g;
4709
4909
  let match;
4710
4910
  // Find tables
4711
4911
  const tables = this.findAllTables(xml);
@@ -4719,7 +4919,7 @@ class HwpxDocument {
4719
4919
  const end = start + match[0].length;
4720
4920
  // Check if this paragraph is inside a table (inside subList)
4721
4921
  const beforeMatch = xml.substring(0, start);
4722
- const subListOpen = (beforeMatch.match(/<hp:subList[^>]*>/g) || []).length;
4922
+ const subListOpen = (beforeMatch.match(/<hp:subList(?:\s[^>]*)?>/g) || []).length;
4723
4923
  const subListClose = (beforeMatch.match(/<\/hp:subList>/g) || []).length;
4724
4924
  if (subListOpen === subListClose) {
4725
4925
  // This is a root-level paragraph
@@ -4936,10 +5136,10 @@ class HwpxDocument {
4936
5136
  */
4937
5137
  insertNestedTableIntoCell(cellXml, nestedTableXml) {
4938
5138
  // Find the subList in the cell
4939
- const subListMatch = cellXml.match(/<hp:subList[^>]*>/);
5139
+ const subListMatch = cellXml.match(/<hp:subList(?:\s[^>]*)?>/);
4940
5140
  if (!subListMatch) {
4941
5141
  // No subList, try to add to paragraph directly
4942
- const pMatch = cellXml.match(/<hp:p[^>]*>/);
5142
+ const pMatch = cellXml.match(/<hp:p(?:\s[^>]*)?>/);
4943
5143
  if (pMatch) {
4944
5144
  const insertPos = cellXml.indexOf(pMatch[0]) + pMatch[0].length;
4945
5145
  const runXml = `<hp:run charPrIDRef="0">${nestedTableXml}<hp:t/></hp:run>`;
@@ -5249,7 +5449,7 @@ class HwpxDocument {
5249
5449
  }
5250
5450
  // If no cell before masterCol, insert at the beginning of row content
5251
5451
  if (insertPoint === -1) {
5252
- const trMatch = updatedRowXml.match(/<(hp|hs):tr[^>]*>/);
5452
+ const trMatch = updatedRowXml.match(/<(hp|hs):tr(?:\s[^>]*)?>/);
5253
5453
  if (trMatch) {
5254
5454
  insertPoint = trMatch[0].length;
5255
5455
  }
@@ -5286,6 +5486,54 @@ class HwpxDocument {
5286
5486
  </hp:subList>
5287
5487
  </hp:tc>`;
5288
5488
  }
5489
+ /**
5490
+ * Replay every pending table edit (cell text, merge/split, nested table,
5491
+ * cell image, cell hanging indent, row/column insert/delete) in call order.
5492
+ *
5493
+ * Each index an edit carries is relative to the table as it was when the
5494
+ * edit was made. Applying by kind (all cell writes, then all row inserts,
5495
+ * then all column inserts ...) wrote text into the pre-insert layout; and
5496
+ * the row appliers sort their own queue by index, which reorders two
5497
+ * inserts or two deletes on the same table. So edits that change a table's
5498
+ * row/column layout run one at a time. Runs of layout-preserving edits
5499
+ * (cell text, indents, images, nested tables) go to their applier together.
5500
+ */
5501
+ async applyTableOpsInCallOrder() {
5502
+ const kinds = [
5503
+ { layout: false, take: () => this._pendingTableCellUpdates, put: o => { this._pendingTableCellUpdates = o; }, apply: () => this.applyTableCellUpdatesToXml() },
5504
+ { layout: true, take: () => this._pendingCellMerges, put: o => { this._pendingCellMerges = o; }, apply: () => this.applyCellMergesToXml() },
5505
+ { layout: true, take: () => this._pendingCellSplits, put: o => { this._pendingCellSplits = o; }, apply: () => this.applyCellSplitsToXml() },
5506
+ { layout: false, take: () => this._pendingNestedTableInserts, put: o => { this._pendingNestedTableInserts = o; }, apply: () => this.applyNestedTableInsertsToXml() },
5507
+ { layout: false, take: () => this._pendingCellImageInserts, put: o => { this._pendingCellImageInserts = o; }, apply: () => this.applyCellImageInsertsToXml() },
5508
+ { layout: false, take: () => this._pendingTableCellHangingIndents, put: o => { this._pendingTableCellHangingIndents = o; }, apply: () => this.applyTableCellHangingIndentsToXml() },
5509
+ { layout: true, take: () => this._pendingTableRowInserts, put: o => { this._pendingTableRowInserts = o; }, apply: () => this.applyTableRowInsertsToXml() },
5510
+ { layout: true, take: () => this._pendingTableRowDeletes, put: o => { this._pendingTableRowDeletes = o; }, apply: () => this.applyTableRowDeletesToXml() },
5511
+ { layout: true, take: () => this._pendingTableColumnInserts, put: o => { this._pendingTableColumnInserts = o; }, apply: () => this.applyTableColumnInsertsToXml() },
5512
+ { layout: true, take: () => this._pendingTableColumnDeletes, put: o => { this._pendingTableColumnDeletes = o; }, apply: () => this.applyTableColumnDeletesToXml() },
5513
+ ];
5514
+ // Every push goes through queueTableOp, so every op has a sequence number;
5515
+ // a missing one would sort last and keep its queue position.
5516
+ const all = [];
5517
+ for (const kind of kinds) {
5518
+ kind.take().forEach((op, pos) => all.push({ kind, op, seq: this._tableOpSeq.get(op) ?? Number.MAX_SAFE_INTEGER, pos }));
5519
+ kind.put([]);
5520
+ }
5521
+ all.sort((a, b) => a.seq - b.seq || a.pos - b.pos);
5522
+ for (let i = 0; i < all.length;) {
5523
+ const kind = all[i].kind;
5524
+ const batch = [all[i++].op];
5525
+ if (!kind.layout)
5526
+ while (i < all.length && all[i].kind === kind)
5527
+ batch.push(all[i++].op);
5528
+ kind.put(batch);
5529
+ try {
5530
+ await kind.apply();
5531
+ }
5532
+ finally {
5533
+ kind.put([]);
5534
+ }
5535
+ }
5536
+ }
5289
5537
  /**
5290
5538
  * Apply table cell updates to XML while preserving original structure.
5291
5539
  * This function modifies only the text content of specific cells,
@@ -5303,7 +5551,7 @@ class HwpxDocument {
5303
5551
  const updatesBySection = new Map();
5304
5552
  for (const update of this._pendingTableCellUpdates) {
5305
5553
  const sectionUpdates = updatesBySection.get(update.sectionIndex) || [];
5306
- sectionUpdates.push({ tableId: update.tableId, row: update.row, col: update.col, text: update.text, charShapeId: update.charShapeId });
5554
+ sectionUpdates.push({ tableId: update.tableId, row: update.row, col: update.col, colAddr: update.colAddr, text: update.text, charShapeId: update.charShapeId });
5307
5555
  updatesBySection.set(update.sectionIndex, sectionUpdates);
5308
5556
  }
5309
5557
  // Process each section that has updates
@@ -5319,7 +5567,7 @@ class HwpxDocument {
5319
5567
  const updatesByTableId = new Map();
5320
5568
  for (const update of updates) {
5321
5569
  const tableUpdates = updatesByTableId.get(update.tableId) || [];
5322
- tableUpdates.push({ row: update.row, col: update.col, text: update.text, charShapeId: update.charShapeId });
5570
+ tableUpdates.push({ row: update.row, col: update.col, colAddr: update.colAddr, text: update.text, charShapeId: update.charShapeId });
5323
5571
  updatesByTableId.set(update.tableId, tableUpdates);
5324
5572
  }
5325
5573
  // Process each table that has updates (by ID)
@@ -5570,12 +5818,14 @@ class HwpxDocument {
5570
5818
  * Find a table by its ID in XML.
5571
5819
  */
5572
5820
  findTableById(xml, tableId) {
5573
- // Match table with specific ID
5574
- const tableStartRegex = new RegExp(`<(?:hp|hs|hc):tbl[^>]*\\bid="${tableId}"[^>]*>`, 'g');
5821
+ // Match table with specific ID. The id comes from document XML, so it is
5822
+ // escaped: an id holding '.', '(' or '+' matched another table or threw.
5823
+ const id = this.escapeRegex(tableId);
5824
+ const tableStartRegex = new RegExp(`<(?:hp|hs|hc):tbl\\s[^>]*\\bid="${id}"[^>]*>`, 'g');
5575
5825
  const match = tableStartRegex.exec(xml);
5576
5826
  if (!match) {
5577
5827
  // Try alternate ID format (id='...' instead of id="...")
5578
- const altRegex = new RegExp(`<(?:hp|hs|hc):tbl[^>]*\\bid='${tableId}'[^>]*>`, 'g');
5828
+ const altRegex = new RegExp(`<(?:hp|hs|hc):tbl\\s[^>]*\\bid='${id}'[^>]*>`, 'g');
5579
5829
  const altMatch = altRegex.exec(xml);
5580
5830
  if (!altMatch)
5581
5831
  return null;
@@ -5714,7 +5964,7 @@ class HwpxDocument {
5714
5964
  findAllTables(xml) {
5715
5965
  const tables = [];
5716
5966
  // Match both hp:tbl and hs:tbl (different namespace prefixes)
5717
- const tableStartRegex = /<(?:hp|hs|hc):tbl[^>]*>/g;
5967
+ const tableStartRegex = /<(?:hp|hs|hc):tbl(?:\s[^>]*)?>/g;
5718
5968
  let match;
5719
5969
  while ((match = tableStartRegex.exec(xml)) !== null) {
5720
5970
  const startIndex = match.index;
@@ -5819,7 +6069,7 @@ class HwpxDocument {
5819
6069
  if (!updatesByRow.has(update.row)) {
5820
6070
  updatesByRow.set(update.row, []);
5821
6071
  }
5822
- updatesByRow.get(update.row).push({ col: update.col, text: update.text, charShapeId: update.charShapeId });
6072
+ updatesByRow.get(update.row).push({ col: update.col, colAddr: update.colAddr, text: update.text, charShapeId: update.charShapeId });
5823
6073
  }
5824
6074
  // Sort row indices descending to process from end to start (avoid index shifting)
5825
6075
  const sortedRowIndices = Array.from(updatesByRow.keys()).sort((a, b) => b - a);
@@ -5865,17 +6115,28 @@ class HwpxDocument {
5865
6115
  let result = rowXml;
5866
6116
  // Find all cells in this row using depth tracking to handle nested tables correctly
5867
6117
  const cells = this.findAllElementsWithDepth(rowXml, 'tc');
6118
+ // Resolve each update to its <hp:tc> index. The cell's grid column
6119
+ // (colAddr) is authoritative: after a merge the XML row no longer has the
6120
+ // covered cells that memory still lists, so the memory position `col`
6121
+ // points one cell too far. `col` is used only when the write has no
6122
+ // colAddr or the row carries no addresses.
6123
+ const cellCols = cells.map(c => this.cellOwnAttr(c.xml, 'colAddr')?.value);
6124
+ const indexOf = (u) => {
6125
+ if (u.colAddr !== undefined && cellCols.some(a => a !== undefined))
6126
+ return cellCols.indexOf(u.colAddr);
6127
+ return u.col < cells.length ? u.col : -1;
6128
+ };
5868
6129
  // Deduplicate updates for the same cell (keep last value)
5869
6130
  // This prevents stale index issues when the same cell is updated multiple times
5870
6131
  const uniqueUpdates = new Map();
5871
6132
  for (const update of updates) {
5872
- uniqueUpdates.set(update.col, update);
6133
+ const at = indexOf(update);
6134
+ if (at >= 0)
6135
+ uniqueUpdates.set(at, { ...update, col: at });
5873
6136
  }
5874
6137
  // Sort updates by col descending to process from right to left (avoid index shifting)
5875
6138
  const sortedUpdates = Array.from(uniqueUpdates.values()).sort((a, b) => b.col - a.col);
5876
6139
  for (const update of sortedUpdates) {
5877
- if (update.col >= cells.length)
5878
- continue;
5879
6140
  const cellData = cells[update.col];
5880
6141
  // Validate cell before update - capture nested table structure
5881
6142
  const cellTblOpen = (cellData.xml.match(/<(?:hp|hs|hc):tbl[\s>]/g) || []).length;
@@ -5951,7 +6212,7 @@ class HwpxDocument {
5951
6212
  xml = xml.replace(/(<(?:hp|hs|hc):run\s+)charPrIDRef="[^"]*"/, `$1charPrIDRef="${charShapeId}"`);
5952
6213
  }
5953
6214
  // 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;
6215
+ const tTagPattern = /(<(?:hp|hs|hc):t(?:\s[^>]*)?>)([^<]*)(<\/(?:hp|hs|hc):t>)/g;
5955
6216
  let foundText = false;
5956
6217
  let result = xml.replace(tTagPattern, (match, openTag, _oldText, closeTag, offset) => {
5957
6218
  // Only replace the first text occurrence
@@ -5964,14 +6225,14 @@ class HwpxDocument {
5964
6225
  if (foundText)
5965
6226
  return this.resetLinesegInXml(result);
5966
6227
  // Pattern 2: Cell has empty <hp:t/> or <hp:t></hp:t> tags
5967
- const emptyTTagPattern = /<((?:hp|hs|hc):t)([^>]*)\s*\/>/;
6228
+ const emptyTTagPattern = /<((?:hp|hs|hc):t)((?:\s[^>]*?)?)\s*\/>/;
5968
6229
  const emptyTMatch = xml.match(emptyTTagPattern);
5969
6230
  if (emptyTMatch) {
5970
6231
  const updated = xml.replace(emptyTTagPattern, `<${emptyTMatch[1]}${emptyTMatch[2]}>${escapedText}</${emptyTMatch[1]}>`);
5971
6232
  return this.resetLinesegInXml(updated);
5972
6233
  }
5973
6234
  // Pattern 3a: Self-closing <hp:run .../> - expand to full run with text
5974
- const selfClosingRunPattern = /<((?:hp|hs|hc):run)([^>]*)\s*\/>/;
6235
+ const selfClosingRunPattern = /<((?:hp|hs|hc):run)((?:\s[^>]*?)?)\s*\/>/;
5975
6236
  const selfClosingRunMatch = xml.match(selfClosingRunPattern);
5976
6237
  if (selfClosingRunMatch) {
5977
6238
  const tagName = selfClosingRunMatch[1]; // e.g., "hp:run"
@@ -5990,7 +6251,7 @@ class HwpxDocument {
5990
6251
  return this.resetLinesegInXml(updated);
5991
6252
  }
5992
6253
  // 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>)/;
6254
+ const runPattern = /(<(?:hp|hs|hc):run(?:\s[^>]*)?>)([\s\S]*?)(<\/(?:hp|hs|hc):run>)/;
5994
6255
  const runMatch = xml.match(runPattern);
5995
6256
  if (runMatch) {
5996
6257
  const prefix = runMatch[1].match(/<(hp|hs|hc):run/)?.[1] || 'hp';
@@ -5999,7 +6260,7 @@ class HwpxDocument {
5999
6260
  return this.resetLinesegInXml(updated);
6000
6261
  }
6001
6262
  // 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>)/;
6263
+ const subListPattern = /(<(?:hp|hs|hc):subList(?:\s[^>]*)?>[\s\S]*?<(?:hp|hs|hc):p(?:\s[^>]*)?>)([\s\S]*?)(<\/(?:hp|hs|hc):p>)/;
6003
6264
  const subListMatch = xml.match(subListPattern);
6004
6265
  if (subListMatch) {
6005
6266
  const prefix = subListMatch[1].match(/<(hp|hs|hc):subList/)?.[1] || 'hp';
@@ -6012,7 +6273,7 @@ class HwpxDocument {
6012
6273
  }
6013
6274
  }
6014
6275
  // Pattern 5: Cell has only <hp:p> without subList
6015
- const pPattern = /(<(?:hp|hs|hc):p[^>]*>)([\s\S]*?)(<\/(?:hp|hs|hc):p>)/;
6276
+ const pPattern = /(<(?:hp|hs|hc):p(?:\s[^>]*)?>)([\s\S]*?)(<\/(?:hp|hs|hc):p>)/;
6016
6277
  const pMatch = xml.match(pPattern);
6017
6278
  if (pMatch) {
6018
6279
  const prefix = pMatch[1].match(/<(hp|hs|hc):p/)?.[1] || 'hp';
@@ -6034,7 +6295,7 @@ class HwpxDocument {
6034
6295
  const charAttr = charShapeId !== undefined ? ` charPrIDRef="${charShapeId}"` : ' charPrIDRef="0"';
6035
6296
  let xml = cellXml;
6036
6297
  // Find the subList element to replace paragraph content
6037
- const subListStartMatch = xml.match(/<(hp|hs|hc):subList[^>]*>/);
6298
+ const subListStartMatch = xml.match(/<(hp|hs|hc):subList(?:\s[^>]*)?>/);
6038
6299
  if (subListStartMatch) {
6039
6300
  const prefix = subListStartMatch[1];
6040
6301
  const startTag = subListStartMatch[0];
@@ -6068,7 +6329,7 @@ class HwpxDocument {
6068
6329
  // Preserve nested tables
6069
6330
  const nestedTables = this.extractNestedTables(subListContent, prefix);
6070
6331
  // Extract paraPrIDRef and styleIDRef from existing paragraph
6071
- const existingPMatch = subListContent.match(/<(?:hp|hs|hc):p[^>]*paraPrIDRef="([^"]*)"[^>]*styleIDRef="([^"]*)"/);
6332
+ const existingPMatch = subListContent.match(/<(?:hp|hs|hc):p\s[^>]*paraPrIDRef="([^"]*)"[^>]*styleIDRef="([^"]*)"/);
6072
6333
  const paraPrIDRef = existingPMatch?.[1] || '0';
6073
6334
  const styleIDRef = existingPMatch?.[2] || '0';
6074
6335
  const paraId = Math.floor(Math.random() * 2147483647);
@@ -6080,7 +6341,7 @@ class HwpxDocument {
6080
6341
  }
6081
6342
  }
6082
6343
  // Fallback: try to find paragraph directly
6083
- const pStartMatch = xml.match(/<(hp|hs|hc):p[^>]*>/);
6344
+ const pStartMatch = xml.match(/<(hp|hs|hc):p(?:\s[^>]*)?>/);
6084
6345
  if (pStartMatch) {
6085
6346
  const prefix = pStartMatch[1];
6086
6347
  const attrMatch = pStartMatch[0].match(/<(?:hp|hs|hc):p([^>]*)>/);
@@ -6108,7 +6369,7 @@ class HwpxDocument {
6108
6369
  if (depth === 0) {
6109
6370
  lastParagraphEnd = searchIndex;
6110
6371
  const remainingXml = xml.substring(searchIndex);
6111
- const nextPMatch = remainingXml.match(/^\s*<(hp|hs|hc):p[^>]*>/);
6372
+ const nextPMatch = remainingXml.match(/^\s*<(hp|hs|hc):p(?:\s[^>]*)?>/);
6112
6373
  if (!nextPMatch)
6113
6374
  break;
6114
6375
  }
@@ -6139,7 +6400,7 @@ class HwpxDocument {
6139
6400
  const charAttr = charShapeId !== undefined ? ` charPrIDRef="${charShapeId}"` : ' charPrIDRef="0"';
6140
6401
  // Find the OUTER subList element with balanced tag matching
6141
6402
  // This is crucial because cells can contain nested tables with their own subLists
6142
- const subListStartMatch = cellXml.match(/<(hp|hs|hc):subList[^>]*>/);
6403
+ const subListStartMatch = cellXml.match(/<(hp|hs|hc):subList(?:\s[^>]*)?>/);
6143
6404
  if (subListStartMatch) {
6144
6405
  const prefix = subListStartMatch[1];
6145
6406
  const startTag = subListStartMatch[0];
@@ -6177,7 +6438,7 @@ class HwpxDocument {
6177
6438
  // IMPORTANT: Check for nested tables in subList content - preserve them!
6178
6439
  const nestedTables = this.extractNestedTables(subListContent, prefix);
6179
6440
  // Extract paraPrIDRef and styleIDRef from existing paragraph if available
6180
- const existingPMatch = subListContent.match(/<(?:hp|hs|hc):p[^>]*paraPrIDRef="([^"]*)"[^>]*styleIDRef="([^"]*)"/);
6441
+ const existingPMatch = subListContent.match(/<(?:hp|hs|hc):p\s[^>]*paraPrIDRef="([^"]*)"[^>]*styleIDRef="([^"]*)"/);
6181
6442
  const paraPrIDRef = existingPMatch?.[1] || '0';
6182
6443
  const styleIDRef = existingPMatch?.[2] || '0';
6183
6444
  // Generate multiple paragraphs with chunked runs for long lines
@@ -6194,7 +6455,7 @@ class HwpxDocument {
6194
6455
  }
6195
6456
  // If no subList found, try to find just paragraphs and replace
6196
6457
  // Use balanced matching for paragraphs too, since they can contain nested tables
6197
- const pStartMatch = cellXml.match(/<(hp|hs|hc):p[^>]*>/);
6458
+ const pStartMatch = cellXml.match(/<(hp|hs|hc):p(?:\s[^>]*)?>/);
6198
6459
  if (pStartMatch) {
6199
6460
  const prefix = pStartMatch[1];
6200
6461
  const firstPStart = cellXml.indexOf(pStartMatch[0]);
@@ -6228,7 +6489,7 @@ class HwpxDocument {
6228
6489
  lastParagraphEnd = searchIndex;
6229
6490
  // Check if there's another paragraph at top level
6230
6491
  const remainingXml = cellXml.substring(searchIndex);
6231
- const nextPMatch = remainingXml.match(/^\s*<(hp|hs|hc):p[^>]*>/);
6492
+ const nextPMatch = remainingXml.match(/^\s*<(hp|hs|hc):p(?:\s[^>]*)?>/);
6232
6493
  if (!nextPMatch) {
6233
6494
  // No more top-level paragraphs
6234
6495
  break;
@@ -6353,7 +6614,7 @@ class HwpxDocument {
6353
6614
  else {
6354
6615
  // Text not found, fall back to first paragraph
6355
6616
  console.warn(`[HwpxDocument] afterText "${insert.afterText}" not found in cell, using first paragraph`);
6356
- const paragraphMatch = targetCell.xml.match(/<hp:p[^>]*>/);
6617
+ const paragraphMatch = targetCell.xml.match(/<hp:p(?:\s[^>]*)?>/);
6357
6618
  if (!paragraphMatch)
6358
6619
  continue;
6359
6620
  insertPosition = targetCell.xml.indexOf(paragraphMatch[0]) + paragraphMatch[0].length;
@@ -6361,7 +6622,7 @@ class HwpxDocument {
6361
6622
  }
6362
6623
  else {
6363
6624
  // Default: find the first <hp:p> in the cell and insert the image inside it
6364
- const paragraphMatch = targetCell.xml.match(/<hp:p[^>]*>/);
6625
+ const paragraphMatch = targetCell.xml.match(/<hp:p(?:\s[^>]*)?>/);
6365
6626
  if (!paragraphMatch)
6366
6627
  continue;
6367
6628
  insertPosition = targetCell.xml.indexOf(paragraphMatch[0]) + paragraphMatch[0].length;
@@ -6494,25 +6755,38 @@ class HwpxDocument {
6494
6755
  if (!file)
6495
6756
  continue;
6496
6757
  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)
6758
+ // STEP 1: Pre-compute target paragraph ranges BEFORE any modifications.
6759
+ //
6760
+ // Each memory paragraph is mapped to its XML paragraph with the parser's
6761
+ // own rule (parsedParagraphStarts), computed once per section. The offsets
6762
+ // the parser cached at load time are not used: they pair memory paragraphs
6763
+ // with a DIFFERENT list (top-level paragraphs of the raw XML), which drifts
6764
+ // wherever the parser lifts paragraphs out of headers, text boxes or
6765
+ // endnotes. Measured on 325 Hancom-saved sections: 16,271 of 70,677 cached
6766
+ // offsets pointed at another paragraph, and an edit then reported success
6767
+ // while its text went to — or vanished into — the wrong paragraph.
6499
6768
  const paragraphTargets = new Map();
6769
+ const starts = this.parsedParagraphStarts(xml);
6770
+ const elements = this._content.sections[sectionIdx]?.elements ?? [];
6771
+ const slotOf = new Map();
6772
+ let slot = 0;
6773
+ elements.forEach((el, i) => {
6774
+ if (this.anchorKeyOf(el)?.kind === 'paragraph')
6775
+ slotOf.set(i, slot++);
6776
+ });
6777
+ const aligned = slot === starts.length;
6500
6778
  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
- });
6779
+ const k = slotOf.get(elementIndex);
6780
+ if (aligned && k !== undefined) {
6781
+ const start = starts[k];
6782
+ const end = this.findBalancedParagraphEnd(xml, start);
6783
+ if (end !== -1) {
6784
+ paragraphTargets.set(elementIndex, { start, end, xml: xml.slice(start, end) });
6512
6785
  continue;
6513
6786
  }
6514
6787
  }
6515
- // Fallback to full search if no cached position or validation failed
6788
+ // Memory and XML disagree on the paragraph count (should not happen for
6789
+ // parser-produced documents); fall back to id + occurrence search.
6516
6790
  const paragraphId = updates[0]?.paragraphId || '';
6517
6791
  const paragraphOccurrence = updates[0]?.paragraphOccurrence ?? 0;
6518
6792
  const target = this.findTargetParagraphForUpdate(xml, sectionIdx, elementIndex, updates, paragraphId, paragraphOccurrence);
@@ -6531,9 +6805,40 @@ class HwpxDocument {
6531
6805
  });
6532
6806
  // STEP 3: Apply updates using pre-computed positions
6533
6807
  for (const [elementIndex, updates] of sortedEntries) {
6534
- const target = paragraphTargets.get(elementIndex);
6535
- if (!target)
6808
+ const found = paragraphTargets.get(elementIndex);
6809
+ if (!found)
6810
+ continue;
6811
+ // Writes go bottom-to-top, so a start computed before them still names
6812
+ // this paragraph. Its end may not: the parser lifts a text-box paragraph
6813
+ // out as its own element, so the box's paragraph and the paragraph that
6814
+ // holds the box overlap, and the inner one (later start) is written first.
6815
+ // If that changed length, the pre-computed end fell inside the outer
6816
+ // paragraph and its write was dropped (CodeRabbit, PR #17). Re-measure.
6817
+ const end = this.findBalancedParagraphEnd(xml, found.start);
6818
+ if (end === -1)
6536
6819
  continue;
6820
+ const target = { start: found.start, end, xml: xml.slice(found.start, end) };
6821
+ // A whole-paragraph replacement (run 0 of updateParagraphText) is
6822
+ // written by position and replaces every earlier edit of this
6823
+ // paragraph. Run edits made after it are folded into its text, not
6824
+ // applied as a second pass: after a whole replacement the XML has one
6825
+ // text node while memory can still hold several runs (the parser split
6826
+ // "A<hp:tab/>B" into ["A", "", "B"]), so run N no longer names an XML
6827
+ // node and a second pass dropped the edit (CodeRabbit, PR #17).
6828
+ // Updates are in call order here (the list is filled in call order).
6829
+ const lastWhole = updates.map(u => !!u.wholeParagraph).lastIndexOf(true);
6830
+ if (lastWhole !== -1) {
6831
+ const runTexts = [updates[lastWhole].newText];
6832
+ for (const u of updates.slice(lastWhole + 1)) {
6833
+ // Memory after the replacement: run 0 holds the new text, runs
6834
+ // 1.. are empty until edited; an edit sets that run's text.
6835
+ runTexts[u.runIndex] = u.newText;
6836
+ }
6837
+ const text = Array.from(runTexts, t => t ?? '').join('');
6838
+ const current = { start: target.start, end: target.end, xml: xml.slice(target.start, target.end) };
6839
+ xml = this.replaceWholeParagraphText(xml, current, text);
6840
+ continue;
6841
+ }
6537
6842
  // Sort by runIndex to process in order
6538
6843
  updates.sort((a, b) => a.runIndex - b.runIndex);
6539
6844
  // Apply the update directly using pre-computed target location
@@ -6816,10 +7121,10 @@ class HwpxDocument {
6816
7121
  }
6817
7122
  else if (/<hp:t\b[^>]*>/.test(run.xml)) {
6818
7123
  // 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`);
7124
+ newRunXml = run.xml.replace(/(<hp:t(?:\s[^>]*)?>)[^<]*(<\/hp:t>)/, `$1${escapedNew}$2`);
6820
7125
  // Remove any additional <hp:t>...</hp:t> tags to prevent duplication
6821
7126
  let firstReplaced = false;
6822
- newRunXml = newRunXml.replace(/<hp:t[^>]*>[^<]*<\/hp:t>/g, (match) => {
7127
+ newRunXml = newRunXml.replace(/<hp:t(?:\s[^>]*)?>[^<]*<\/hp:t>/g, (match) => {
6823
7128
  if (!firstReplaced) {
6824
7129
  firstReplaced = true;
6825
7130
  return match; // Keep the first one
@@ -7222,6 +7527,55 @@ class HwpxDocument {
7222
7527
  // Final fallback: return index-based result anyway
7223
7528
  return indexBasedTarget;
7224
7529
  }
7530
+ /**
7531
+ * Replace the whole own text of a paragraph (run 0 of updateParagraphText).
7532
+ *
7533
+ * The new text goes into the paragraph's first own text node — the first
7534
+ * <hp:t> with text, outside every nested container — and every other own
7535
+ * text node is emptied, including characters written as elements inside
7536
+ * them (tab, full-width space, line break). The first run's character shape
7537
+ * therefore carries the whole sentence. Nested content (tables, text boxes,
7538
+ * equations, pictures, notes) and all attributes stay byte-for-byte.
7539
+ *
7540
+ * Written by position rather than memory run index: the parser splits one
7541
+ * <hp:t> into several memory runs around tabs and full-width spaces, so run 0
7542
+ * named only a fragment and the rest of the old text survived (measured
7543
+ * 2026-09-25 on 150 Hancom originals: 449 of 11,754 paragraphs).
7544
+ */
7545
+ replaceWholeParagraphText(xml, target, newText) {
7546
+ let paragraphXml = target.xml;
7547
+ const escaped = this.escapeXml(newText);
7548
+ const runs = this.findDirectChildRuns(paragraphXml);
7549
+ // Own text nodes of each run, as (run, has non-empty own text).
7550
+ const hasText = (runXml) => [...this.ownRunText(runXml).matchAll(/<hp:t\b[^>]*>([\s\S]*?)<\/hp:t>/g)]
7551
+ .some(m => /<hp:(?:tab|fwSpace|nbSpace|lineBreak)\b/.test(m[1]) || m[1].replace(/<[^>]+>/g, '') !== '');
7552
+ let firstIdx = runs.findIndex(r => hasText(r.xml));
7553
+ // No own text anywhere: fall back to the first own <hp:t> (empty paragraph).
7554
+ if (firstIdx === -1)
7555
+ firstIdx = runs.findIndex(r => /<hp:t\b/.test(this.ownRunText(r.xml)));
7556
+ for (let i = runs.length - 1; i >= 0; i--) {
7557
+ const run = runs[i];
7558
+ let written = i !== firstIdx; // only the first chosen run receives the text
7559
+ const newRunXml = this.mapOwnRunText(run.xml, part => part.replace(/<hp:t\b([^>]*?)\/>|<hp:t\b([^>]*)>([\s\S]*?)<\/hp:t>/g, (_m, selfAttrs, attrs) => {
7560
+ const text = written ? '' : escaped;
7561
+ written = true;
7562
+ return `<hp:t${selfAttrs ?? attrs ?? ''}>${text}</hp:t>`;
7563
+ }));
7564
+ paragraphXml = paragraphXml.slice(0, run.start) + newRunXml + paragraphXml.slice(run.end);
7565
+ }
7566
+ if (firstIdx === -1) {
7567
+ // The paragraph has no <hp:t> of its own: add one to its first run.
7568
+ const firstRun = runs[0];
7569
+ if (firstRun) {
7570
+ const openEnd = firstRun.xml.indexOf('>') + 1;
7571
+ const withText = firstRun.xml.endsWith('/>')
7572
+ ? firstRun.xml.replace(/\/>$/, `><hp:t>${escaped}</hp:t></hp:run>`)
7573
+ : firstRun.xml.slice(0, openEnd) + `<hp:t>${escaped}</hp:t>` + firstRun.xml.slice(openEnd);
7574
+ paragraphXml = paragraphXml.slice(0, firstRun.start) + withText + paragraphXml.slice(firstRun.end);
7575
+ }
7576
+ }
7577
+ return xml.slice(0, target.start) + paragraphXml + xml.slice(target.end);
7578
+ }
7225
7579
  /**
7226
7580
  * Replace multiple runs in a paragraph element at once.
7227
7581
  * Finds hp:run elements and updates their hp:t content.
@@ -7233,48 +7587,22 @@ class HwpxDocument {
7233
7587
  for (const update of updates) {
7234
7588
  updateMap.set(update.runIndex, update.newText);
7235
7589
  }
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
- }
7590
+ // The paragraph's OWN runs only — its direct children. A paragraph that
7591
+ // holds a table, text box, footnote or endnote also contains the runs of
7592
+ // every paragraph inside those containers. Counting those as its own made
7593
+ // "run N" land in a table cell or endnote: the reported success wrote the
7594
+ // new text into a nested paragraph (or into nothing) and cut the rest.
7595
+ // Measured: 39 of 60 Hancom files lost the text this way (2026-09-24).
7596
+ const runs = this.findDirectChildRuns(paragraphXml);
7269
7597
  // Filter to only runs that have <hp:t> content (matching memory model behavior)
7270
7598
  // 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));
7599
+ const textRuns = runs.filter(run => /<hp:t\b/.test(this.ownRunText(run.xml)));
7272
7600
  // The parser creates a model run per non-empty hp:t, not per hp:run.
7273
7601
  // Merge those updates back into their shared XML run without losing a suffix.
7274
7602
  const xmlRunUpdates = new Map();
7275
7603
  let modelRunIndex = 0;
7276
7604
  for (let i = 0; i < textRuns.length; i++) {
7277
- const textNodes = [...textRuns[i].xml.matchAll(/<hp:t\b[^>]*>([^<]+)<\/hp:t>/g)];
7605
+ const textNodes = [...this.ownRunText(textRuns[i].xml).matchAll(/<hp:t\b[^>]*>([^<]+)<\/hp:t>/g)];
7278
7606
  const count = Math.max(1, textNodes.length);
7279
7607
  let changed = false;
7280
7608
  let escapedText = '';
@@ -7298,19 +7626,112 @@ class HwpxDocument {
7298
7626
  continue;
7299
7627
  const run = textRuns[i];
7300
7628
  const escapedNew = xmlRunUpdates.get(i);
7301
- let newRunXml = run.xml;
7302
7629
  // Write each XML run's combined text once, preserving text-tag attributes.
7630
+ // Only the run's own <hp:t> are rewritten; text inside a table, equation
7631
+ // or text box that sits in the same run is left untouched.
7303
7632
  let textWritten = false;
7304
- newRunXml = newRunXml.replace(/<hp:t\b([^>]*?)\/>|<hp:t\b([^>]*)>[^<]*<\/hp:t>/g, (_match, selfClosingAttrs, attrs) => {
7633
+ const newRunXml = this.mapOwnRunText(run.xml, tXml => tXml.replace(/<hp:t\b([^>]*?)\/>|<hp:t\b([^>]*)>[^<]*<\/hp:t>/g, (_match, selfClosingAttrs, attrs) => {
7305
7634
  const text = textWritten ? '' : escapedNew;
7306
7635
  textWritten = true;
7307
7636
  return `<hp:t${selfClosingAttrs ?? attrs ?? ''}>${text}</hp:t>`;
7308
- });
7637
+ }));
7309
7638
  // Replace in paragraph XML
7310
7639
  paragraphXml = paragraphXml.slice(0, run.start) + newRunXml + paragraphXml.slice(run.end);
7311
7640
  }
7312
7641
  return xml.slice(0, target.start) + paragraphXml + xml.slice(target.end);
7313
7642
  }
7643
+ /** Direct <hp:run> children of a paragraph (runs of nested paragraphs excluded). */
7644
+ findDirectChildRuns(paragraphXml) {
7645
+ const runs = [];
7646
+ const openEnd = paragraphXml.indexOf('>') + 1;
7647
+ let pos = openEnd;
7648
+ let depth = 0; // nesting depth of <hp:p> inside this paragraph
7649
+ const tagRe = /<(\/?)hp:(p|run)\b[^>]*?(\/?)>/g;
7650
+ tagRe.lastIndex = pos;
7651
+ let runStart = -1;
7652
+ let m;
7653
+ while ((m = tagRe.exec(paragraphXml)) !== null) {
7654
+ const [whole, closing, name, selfClosing] = m;
7655
+ if (name === 'p') {
7656
+ if (selfClosing)
7657
+ continue;
7658
+ if (closing) {
7659
+ if (depth === 0)
7660
+ break; // end of this paragraph
7661
+ depth--;
7662
+ }
7663
+ else {
7664
+ depth++;
7665
+ }
7666
+ continue;
7667
+ }
7668
+ if (depth !== 0)
7669
+ continue; // a run of a nested paragraph
7670
+ if (selfClosing) {
7671
+ runs.push({ start: m.index, end: m.index + whole.length, xml: whole });
7672
+ }
7673
+ else if (!closing) {
7674
+ runStart = m.index;
7675
+ }
7676
+ else if (runStart !== -1) {
7677
+ const end = m.index + whole.length;
7678
+ runs.push({ start: runStart, end, xml: paragraphXml.slice(runStart, end) });
7679
+ runStart = -1;
7680
+ }
7681
+ }
7682
+ return runs;
7683
+ }
7684
+ /**
7685
+ * A run's own markup with every nested container (table, equation, text box,
7686
+ * note…) blanked out, so its <hp:t> are the run's own text only.
7687
+ */
7688
+ ownRunText(runXml) {
7689
+ return this.mapOwnRunText(runXml, s => s, true);
7690
+ }
7691
+ /**
7692
+ * Apply `fn` to the parts of a run that are its own text, leaving nested
7693
+ * containers byte-for-byte intact. With `blank`, nested containers are
7694
+ * replaced by an empty marker instead (for reading).
7695
+ */
7696
+ mapOwnRunText(runXml, fn, blank = false) {
7697
+ let out = '';
7698
+ let pos = 0;
7699
+ while (pos < runXml.length) {
7700
+ const rest = runXml.slice(pos);
7701
+ const m = rest.match(HwpxDocument.NESTED_CONTENT);
7702
+ if (!m || m.index === undefined) {
7703
+ out += fn(rest);
7704
+ break;
7705
+ }
7706
+ const openAt = pos + m.index;
7707
+ const name = m[1];
7708
+ out += fn(runXml.slice(pos, openAt));
7709
+ const end = this.findElementEnd(runXml, openAt, name);
7710
+ out += blank ? '<NESTED/>' : runXml.slice(openAt, end);
7711
+ pos = end;
7712
+ }
7713
+ return out;
7714
+ }
7715
+ /** End offset of the <hp:name> element opening at `start` (handles nesting and self-closing). */
7716
+ findElementEnd(xml, start, name) {
7717
+ const tagEnd = xml.indexOf('>', start);
7718
+ if (tagEnd === -1)
7719
+ return xml.length;
7720
+ if (xml[tagEnd - 1] === '/')
7721
+ return tagEnd + 1;
7722
+ const re = new RegExp(`<(/?)hp:${name}\\b[^>]*?(/?)>`, 'g');
7723
+ re.lastIndex = tagEnd + 1;
7724
+ let depth = 1;
7725
+ let m;
7726
+ while ((m = re.exec(xml)) !== null) {
7727
+ if (m[2])
7728
+ continue;
7729
+ depth += m[1] ? -1 : 1;
7730
+ if (depth === 0)
7731
+ return m.index + m[0].length;
7732
+ }
7733
+ return xml.length;
7734
+ }
7314
7735
  /**
7315
7736
  * Replace text in a single run directly using pre-computed target location.
7316
7737
  * Simpler version for single-run updates.
@@ -7324,9 +7745,9 @@ class HwpxDocument {
7324
7745
  // Self-closing: <hp:t/> -> <hp:t>newText</hp:t>
7325
7746
  paragraphXml = paragraphXml.replace(/<hp:t\s*\/>/, `<hp:t>${escapedNew}</hp:t>`);
7326
7747
  }
7327
- else if (/<hp:t[^>]*>/.test(paragraphXml)) {
7748
+ else if (/<hp:t(?:\s[^>]*)?>/.test(paragraphXml)) {
7328
7749
  // Has content or empty: <hp:t>...</hp:t> -> <hp:t>newText</hp:t>
7329
- paragraphXml = paragraphXml.replace(/(<hp:t[^>]*>)[^<]*(<\/hp:t>)/, `$1${escapedNew}$2`);
7750
+ paragraphXml = paragraphXml.replace(/(<hp:t(?:\s[^>]*)?>)[^<]*(<\/hp:t>)/, `$1${escapedNew}$2`);
7330
7751
  }
7331
7752
  else if (/<hp:run\b[^>]*>/.test(paragraphXml)) {
7332
7753
  // No <hp:t> tag exists - add one after the <hp:run> opening tag
@@ -7386,7 +7807,7 @@ class HwpxDocument {
7386
7807
  continue;
7387
7808
  // Found the right paragraph! Replace the text
7388
7809
  // Replace within <hp:t> tags
7389
- const pattern1 = new RegExp(`(<hp:t[^>]*>)${this.escapeRegex(escapedOld)}`);
7810
+ const pattern1 = new RegExp(`(<hp:t(?:\\s[^>]*)?>)${this.escapeRegex(escapedOld)}`);
7390
7811
  let newParagraphContent = paragraphContent.replace(pattern1, `$1${escapedNew}`);
7391
7812
  // Also try standalone text replacement
7392
7813
  const pattern2 = new RegExp(`>${this.escapeRegex(escapedOld)}<`);
@@ -7649,9 +8070,9 @@ class HwpxDocument {
7649
8070
  // Case 1: Self-closing <hp:t/> - replace with full tag containing new text
7650
8071
  newElementContent = elementContent.replace(/<hp:t\s*\/>/, `<hp:t>${escapedNew}</hp:t>`);
7651
8072
  }
7652
- else if (oldText === '' && /<hp:t[^>]*><\/hp:t>/.test(elementContent)) {
8073
+ else if (oldText === '' && /<hp:t(?:\s[^>]*)?><\/hp:t>/.test(elementContent)) {
7653
8074
  // Case 2: Empty <hp:t></hp:t> - fill with new text
7654
- newElementContent = elementContent.replace(/(<hp:t[^>]*>)<\/hp:t>/, `$1${escapedNew}</hp:t>`);
8075
+ newElementContent = elementContent.replace(/(<hp:t(?:\s[^>]*)?>)<\/hp:t>/, `$1${escapedNew}</hp:t>`);
7655
8076
  }
7656
8077
  else if (oldText === '' && !/<hp:t\b[^>]*>/.test(elementContent)) {
7657
8078
  // Case 3: No hp:t tag at all - add one after the first hp:run opening tag
@@ -7659,7 +8080,7 @@ class HwpxDocument {
7659
8080
  }
7660
8081
  else {
7661
8082
  // Case 4: Normal case - replace text within <hp:t> tags (first match only)
7662
- const pattern1 = new RegExp(`(<hp:t[^>]*>)${this.escapeRegex(escapedOld)}`);
8083
+ const pattern1 = new RegExp(`(<hp:t(?:\\s[^>]*)?>)${this.escapeRegex(escapedOld)}`);
7663
8084
  newElementContent = elementContent.replace(pattern1, `$1${escapedNew}`);
7664
8085
  // Also try standalone text replacement if pattern1 didn't match
7665
8086
  if (newElementContent === elementContent) {
@@ -7732,7 +8153,7 @@ class HwpxDocument {
7732
8153
  if (runIndex >= runs.length) {
7733
8154
  // Run index out of bounds, try to replace in any run
7734
8155
  // Replace text within <hp:t> tags (first match only)
7735
- const pattern1 = new RegExp(`(<hp:t[^>]*>)${this.escapeRegex(escapedOld)}`);
8156
+ const pattern1 = new RegExp(`(<hp:t(?:\\s[^>]*)?>)${this.escapeRegex(escapedOld)}`);
7736
8157
  let newParagraphContent = paragraphContent.replace(pattern1, `$1${escapedNew}`);
7737
8158
  // Also try standalone text replacement
7738
8159
  if (newParagraphContent === paragraphContent) {
@@ -7745,7 +8166,7 @@ class HwpxDocument {
7745
8166
  const targetRun = runs[runIndex];
7746
8167
  let newRunContent = targetRun.content;
7747
8168
  // Replace within <hp:t> tags in this run
7748
- const tPattern = new RegExp(`(<hp:t[^>]*>)${this.escapeRegex(escapedOld)}(</hp:t>)`);
8169
+ const tPattern = new RegExp(`(<hp:t(?:\\s[^>]*)?>)${this.escapeRegex(escapedOld)}(</hp:t>)`);
7749
8170
  newRunContent = newRunContent.replace(tPattern, `$1${escapedNew}$2`);
7750
8171
  // If no match, try simpler pattern
7751
8172
  if (newRunContent === targetRun.content) {
@@ -7927,7 +8348,7 @@ class HwpxDocument {
7927
8348
  return tblMatch;
7928
8349
  }
7929
8350
  let rowIndex = 0;
7930
- return tblMatch.replace(/<hp:tr[^>]*>([\s\S]*?)<\/hp:tr>/g, (rowMatch) => {
8351
+ return tblMatch.replace(/<hp:tr(?:\s[^>]*)?>([\s\S]*?)<\/hp:tr>/g, (rowMatch) => {
7931
8352
  if (rowIndex >= table.rows.length) {
7932
8353
  rowIndex++;
7933
8354
  return rowMatch;
@@ -8704,6 +9125,84 @@ class HwpxDocument {
8704
9125
  contentHpf = contentHpf.substring(0, insertPos) + newItem + contentHpf.substring(insertPos);
8705
9126
  this._zip.file('Contents/content.hpf', contentHpf);
8706
9127
  }
9128
+ /**
9129
+ * Apply section inserts/deletes to Contents/sectionN.xml, in call order.
9130
+ *
9131
+ * File numbers must keep matching memory section indices, so an insert
9132
+ * renames later files up one (section1 → section2 …) and a delete removes
9133
+ * its file and renames later files down one. content.hpf gets a manifest
9134
+ * item and a spine itemref per section, and header.xml's secCnt follows.
9135
+ */
9136
+ async applySectionOpsToZip() {
9137
+ if (!this._zip)
9138
+ return;
9139
+ const secPath = (i) => `Contents/section${i}.xml`;
9140
+ const countFiles = () => Object.keys(this._zip.files).filter(n => /^Contents\/section\d+\.xml$/.test(n)).length;
9141
+ const move = async (from, to) => {
9142
+ const f = this._zip.file(secPath(from));
9143
+ if (!f)
9144
+ return;
9145
+ this._zip.file(secPath(to), await f.async('string'));
9146
+ this._zip.remove(secPath(from));
9147
+ };
9148
+ for (const op of this._pendingSectionOps) {
9149
+ const fileCount = countFiles();
9150
+ if (op.op === 'delete') {
9151
+ if (op.at >= fileCount || fileCount <= 1)
9152
+ continue;
9153
+ this._zip.remove(secPath(op.at));
9154
+ for (let i = op.at + 1; i < fileCount; i++)
9155
+ await move(i, i - 1);
9156
+ continue;
9157
+ }
9158
+ // Insert: shift later files up, highest first.
9159
+ for (let i = fileCount - 1; i >= op.at; i--)
9160
+ await move(i, i + 1);
9161
+ // Build the new section from the template section's <hs:sec> wrapper and
9162
+ // its first paragraph's <hp:secPr> (page size, margins, numbering).
9163
+ const templateIndex = op.templateFrom >= op.at ? op.templateFrom + 1 : op.templateFrom;
9164
+ const template = await this._zip.file(secPath(templateIndex))?.async('string');
9165
+ this._zip.file(secPath(op.at), this.buildEmptySectionXml(template));
9166
+ }
9167
+ // Manifest + spine: one item per section file, in order.
9168
+ const hpfFile = this._zip.file('Contents/content.hpf');
9169
+ const total = countFiles();
9170
+ if (hpfFile) {
9171
+ let hpf = await hpfFile.async('string');
9172
+ hpf = hpf.replace(/<opf:item\b[^>]*\bid="section\d+"[^>]*\/>\s*/g, '');
9173
+ hpf = hpf.replace(/<opf:itemref\b[^>]*\bidref="section\d+"[^>]*\/>\s*/g, '');
9174
+ const items = Array.from({ length: total }, (_, i) => `<opf:item id="section${i}" href="Contents/section${i}.xml" media-type="application/xml"/>`).join('');
9175
+ const refs = Array.from({ length: total }, (_, i) => `<opf:itemref idref="section${i}" linear="yes"/>`).join('');
9176
+ hpf = hpf.replace('</opf:manifest>', items + '</opf:manifest>');
9177
+ hpf = hpf.replace('</opf:spine>', refs + '</opf:spine>');
9178
+ this._zip.file('Contents/content.hpf', hpf);
9179
+ }
9180
+ const headerFile = this._zip.file('Contents/header.xml');
9181
+ if (headerFile) {
9182
+ const header = await headerFile.async('string');
9183
+ this._zip.file('Contents/header.xml', header.replace(/\bsecCnt="\d+"/, `secCnt="${total}"`));
9184
+ }
9185
+ }
9186
+ /** A section XML holding one empty paragraph with the template's <hp:secPr>. */
9187
+ buildEmptySectionXml(template) {
9188
+ const declaration = '<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>';
9189
+ const secOpen = template?.match(/<hs:sec\b[^>]*>/)?.[0]
9190
+ ?? '<hs:sec xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph" xmlns:hs="http://www.hancom.co.kr/hwpml/2011/section">';
9191
+ let secPr = '';
9192
+ if (template) {
9193
+ const at = template.indexOf('<hp:secPr');
9194
+ if (at !== -1)
9195
+ secPr = template.slice(at, this.findElementEnd(template, at, 'secPr'));
9196
+ }
9197
+ // A fresh column definition follows secPr in Hancom's own first paragraph.
9198
+ const colPr = template?.match(/<hp:ctrl>\s*<hp:colPr\b[^>]*\/>\s*<\/hp:ctrl>/)?.[0] ?? '';
9199
+ return `${declaration}${secOpen}` +
9200
+ `<hp:p id="0" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">` +
9201
+ `<hp:run charPrIDRef="0">${secPr}${colPr}</hp:run>` +
9202
+ `<hp:run charPrIDRef="0"><hp:t></hp:t></hp:run>` +
9203
+ `<hp:linesegarray><hp:lineseg textpos="0" vertpos="0" vertsize="1000" textheight="1000" baseline="850" spacing="600" horzpos="0" horzsize="0" flags="393216"/></hp:linesegarray>` +
9204
+ `</hp:p></hs:sec>`;
9205
+ }
8707
9206
  /**
8708
9207
  * Add hp:pic tag to section XML
8709
9208
  */
@@ -8806,7 +9305,7 @@ class HwpxDocument {
8806
9305
  for (const table of tables) {
8807
9306
  const tableXml = xml.substring(table.startIndex, table.endIndex);
8808
9307
  // Find cells in this table
8809
- const cellMatches = [...tableXml.matchAll(/<(?:hp|hs):tc[^>]*>([\s\S]*?)<\/(?:hp|hs):tc>/g)];
9308
+ const cellMatches = [...tableXml.matchAll(/<(?:hp|hs):tc(?:\s[^>]*)?>([\s\S]*?)<\/(?:hp|hs):tc>/g)];
8810
9309
  for (const cellMatch of cellMatches) {
8811
9310
  const cellContent = cellMatch[1];
8812
9311
  const textContent = this.extractTextFromCellXml(cellContent);
@@ -8838,7 +9337,7 @@ class HwpxDocument {
8838
9337
  */
8839
9338
  findAllParagraphsInCell(cellXml) {
8840
9339
  const paragraphs = [];
8841
- const paragraphRegex = /<hp:p[^>]*>[\s\S]*?<\/hp:p>/g;
9340
+ const paragraphRegex = /<hp:p(?:\s[^>]*)?>[\s\S]*?<\/hp:p>/g;
8842
9341
  let match;
8843
9342
  while ((match = paragraphRegex.exec(cellXml)) !== null) {
8844
9343
  paragraphs.push({
@@ -9021,7 +9520,7 @@ class HwpxDocument {
9021
9520
  findTblTagIssues(xml) {
9022
9521
  const issues = [];
9023
9522
  // Track table tag positions
9024
- const tblOpenRegex = /<(?:hp|hs|hc):tbl[^>]*>/g;
9523
+ const tblOpenRegex = /<(?:hp|hs|hc):tbl(?:\s[^>]*)?>/g;
9025
9524
  const tblCloseRegex = /<\/(?:hp|hs|hc):tbl>/g;
9026
9525
  const allPositions = [];
9027
9526
  let match;
@@ -9074,11 +9573,11 @@ class HwpxDocument {
9074
9573
  checkNestingErrors(xml) {
9075
9574
  const issues = [];
9076
9575
  // Check for tc outside of tr
9077
- const tcOutsideTr = /<(?:hp|hs|hc):tc[^>]*>(?:(?!<(?:hp|hs|hc):tr[^>]*>).)*?<\/(?:hp|hs|hc):tc>/gs;
9576
+ const tcOutsideTr = /<(?:hp|hs|hc):tc(?:\s[^>]*)?>(?:(?!<(?:hp|hs|hc):tr(?:\s[^>]*)?>).)*?<\/(?:hp|hs|hc):tc>/gs;
9078
9577
  // This is simplified - a full check would need proper nesting validation
9079
9578
  // Check for tr outside of tbl
9080
- const trPattern = /<(?:hp|hs|hc):tr[^>]*>/g;
9081
- const tblPattern = /<(?:hp|hs|hc):tbl[^>]*>/g;
9579
+ const trPattern = /<(?:hp|hs|hc):tr(?:\s[^>]*)?>/g;
9580
+ const tblPattern = /<(?:hp|hs|hc):tbl(?:\s[^>]*)?>/g;
9082
9581
  // Simple check: count if tr appears without preceding tbl
9083
9582
  let match;
9084
9583
  let lastTblPos = -1;
@@ -10621,7 +11120,7 @@ class HwpxDocument {
10621
11120
  return null;
10622
11121
  const targetRowData = rows[targetRow];
10623
11122
  // Extract content inside the row (between <hp:tr...> and </hp:tr>)
10624
- const rowOpenTagMatch = targetRowData.xml.match(/^<(?:hp|hs|hc):tr[^>]*>/);
11123
+ const rowOpenTagMatch = targetRowData.xml.match(/^<(?:hp|hs|hc):tr(?:\s[^>]*)?>/);
10625
11124
  if (!rowOpenTagMatch)
10626
11125
  return null;
10627
11126
  const rowContentStart = rowOpenTagMatch[0].length;
@@ -10635,7 +11134,7 @@ class HwpxDocument {
10635
11134
  return null;
10636
11135
  const targetCellData = cells[targetCol];
10637
11136
  // Extract content inside the cell (between <hp:tc...> and </hp:tc>)
10638
- const cellOpenTagMatch = targetCellData.xml.match(/^<(?:hp|hs|hc):tc[^>]*>/);
11137
+ const cellOpenTagMatch = targetCellData.xml.match(/^<(?:hp|hs|hc):tc(?:\s[^>]*)?>/);
10639
11138
  if (!cellOpenTagMatch)
10640
11139
  return null;
10641
11140
  const cellContentStart = cellOpenTagMatch[0].length;
@@ -10658,6 +11157,131 @@ class HwpxDocument {
10658
11157
  // ============================================================
10659
11158
  // Table Row Insert/Delete XML Persistence
10660
11159
  // ============================================================
11160
+ /**
11161
+ * Scale this table's column widths so they sum to its <hp:sz width>.
11162
+ *
11163
+ * Column widths are read from cells whose colSpan is 1 (the first one seen
11164
+ * per colAddr). Every cell then gets the sum of the scaled widths of the
11165
+ * columns it spans, so merged cells stay aligned. Rounding leftovers go to
11166
+ * the last column so the total is exact. Nested tables are not touched.
11167
+ */
11168
+ fitColumnsToTableWidth(tableXml) {
11169
+ const tableWidth = parseInt(tableXml.match(/^<hp:tbl\b[\s\S]*?<hp:sz width="(\d+)"/)?.[1] ?? '', 10);
11170
+ const colCnt = parseInt(tableXml.match(/^<hp:tbl\b[^>]*\bcolCnt="(\d+)"/)?.[1] ?? '', 10);
11171
+ if (!tableWidth || !colCnt)
11172
+ return tableXml;
11173
+ const rows = this.findAllElementsWithDepth(tableXml, 'tr');
11174
+ const own = [];
11175
+ rows.forEach((row, r) => {
11176
+ for (const cell of this.findAllElementsWithDepth(row.xml, 'tc')) {
11177
+ const tail = cell.xml.lastIndexOf('</hp:subList>');
11178
+ const from = tail === -1 ? 0 : tail;
11179
+ const props = cell.xml.slice(from);
11180
+ const col = parseInt(props.match(/<hp:cellAddr\b[^>]*\bcolAddr="(\d+)"/)?.[1] ?? '-1', 10);
11181
+ const span = parseInt(props.match(/<hp:cellSpan\b[^>]*\bcolSpan="(\d+)"/)?.[1] ?? '1', 10);
11182
+ const sz = props.match(/(<hp:cellSz\b[^>]*\bwidth=")(\d+)(")/);
11183
+ if (col < 0 || !sz || sz.index === undefined)
11184
+ continue;
11185
+ own.push({ row: r, cell, col, span, width: parseInt(sz[2], 10), at: from + sz.index + sz[1].length });
11186
+ }
11187
+ });
11188
+ const widths = new Array(colCnt).fill(0);
11189
+ for (const o of own)
11190
+ if (o.span === 1 && o.col < colCnt && widths[o.col] === 0)
11191
+ widths[o.col] = o.width;
11192
+ if (widths.some(w => w === 0))
11193
+ return tableXml; // cannot derive every column safely
11194
+ const sum = widths.reduce((a, b) => a + b, 0);
11195
+ if (sum === tableWidth)
11196
+ return tableXml;
11197
+ const scaled = widths.map(w => Math.floor((w * tableWidth) / sum));
11198
+ scaled[colCnt - 1] += tableWidth - scaled.reduce((a, b) => a + b, 0);
11199
+ let out = tableXml;
11200
+ for (let r = rows.length - 1; r >= 0; r--) {
11201
+ let rowXml = rows[r].xml;
11202
+ const cellsInRow = own.filter(o => o.row === r).sort((a, b) => b.cell.startIndex - a.cell.startIndex);
11203
+ for (const o of cellsInRow) {
11204
+ const w = scaled.slice(o.col, o.col + o.span).reduce((a, b) => a + b, 0);
11205
+ const newCell = o.cell.xml.slice(0, o.at) + String(w) + o.cell.xml.slice(o.at + String(o.width).length);
11206
+ rowXml = rowXml.slice(0, o.cell.startIndex) + newCell + rowXml.slice(o.cell.endIndex);
11207
+ }
11208
+ out = out.slice(0, rows[r].startIndex) + rowXml + out.slice(rows[r].endIndex);
11209
+ }
11210
+ return out;
11211
+ }
11212
+ /**
11213
+ * Locate one of a cell's OWN address/span attributes (`colAddr`, `rowAddr`,
11214
+ * `colSpan`, `rowSpan`) in `cellXml`, returning the value and the absolute
11215
+ * index of its digits so callers can rewrite it in place.
11216
+ *
11217
+ * Hancom writes them on `<hp:cellAddr>`/`<hp:cellSpan>` after the cell's
11218
+ * sub-list (209/209 corpus files). Hand-made files may put them on the
11219
+ * `<hp:tc>` start tag instead, which the parser also accepts. A nested
11220
+ * table's cells live inside the sub-list, so only the tail is searched for
11221
+ * the child form and only the start tag for the attribute form.
11222
+ */
11223
+ cellOwnAttr(cellXml, name) {
11224
+ const child = name.endsWith('Addr') ? 'cellAddr' : 'cellSpan';
11225
+ const tail = cellXml.lastIndexOf('</hp:subList>');
11226
+ const from = tail === -1 ? 0 : tail;
11227
+ const own = new RegExp(`(<hp:${child}\\b[^>]*\\b${name}=")(\\d+)"`).exec(cellXml.slice(from));
11228
+ if (own) {
11229
+ return { value: parseInt(own[2], 10), at: from + own.index + own[1].length, length: own[2].length };
11230
+ }
11231
+ const startTag = cellXml.slice(0, cellXml.indexOf('>') + 1);
11232
+ const attr = new RegExp(`(\\s${name}=")(\\d+)"`).exec(startTag);
11233
+ if (attr) {
11234
+ return { value: parseInt(attr[2], 10), at: attr.index + attr[1].length, length: attr[2].length };
11235
+ }
11236
+ return null;
11237
+ }
11238
+ /** Rewrite one of a cell's own attributes (see cellOwnAttr); no-op if absent. */
11239
+ setCellOwnAttr(cellXml, name, value) {
11240
+ const a = this.cellOwnAttr(cellXml, name);
11241
+ return a ? cellXml.slice(0, a.at) + String(value) + cellXml.slice(a.at + a.length) : cellXml;
11242
+ }
11243
+ /** A cell's own <hp:cellSz width> (after its sub-list, so never a nested table's). */
11244
+ cellOwnWidth(cellXml) {
11245
+ const tail = cellXml.lastIndexOf('</hp:subList>');
11246
+ const m = cellXml.slice(tail === -1 ? 0 : tail).match(/<hp:cellSz\b[^>]*\bwidth="(\d+)"/);
11247
+ return m ? parseInt(m[1], 10) : null;
11248
+ }
11249
+ /** Rewrite a cell's own <hp:cellSz width>; no-op if the cell has none or width <= 0. */
11250
+ setCellOwnWidth(cellXml, width) {
11251
+ if (width <= 0)
11252
+ return cellXml;
11253
+ const tail = cellXml.lastIndexOf('</hp:subList>');
11254
+ const from = tail === -1 ? 0 : tail;
11255
+ const m = /(<hp:cellSz\b[^>]*\bwidth=")(\d+)"/.exec(cellXml.slice(from));
11256
+ if (!m)
11257
+ return cellXml;
11258
+ const at = from + m.index + m[1].length;
11259
+ return cellXml.slice(0, at) + String(width) + cellXml.slice(at + m[2].length);
11260
+ }
11261
+ /**
11262
+ * Add `delta` to the rowAddr of every cell of THIS table whose rowAddr is
11263
+ * >= fromRow. Nested tables inside cells keep their own addresses.
11264
+ */
11265
+ shiftTableRowAddrs(tableXml, fromRow, delta) {
11266
+ let out = tableXml;
11267
+ const rows = this.findAllElementsWithDepth(out, 'tr');
11268
+ for (let r = rows.length - 1; r >= 0; r--) {
11269
+ const row = rows[r];
11270
+ const cells = this.findAllElementsWithDepth(row.xml, 'tc');
11271
+ let rowXml = row.xml;
11272
+ for (let c = cells.length - 1; c >= 0; c--) {
11273
+ const cell = cells[c];
11274
+ const addr = this.cellOwnAttr(cell.xml, 'rowAddr');
11275
+ if (!addr || addr.value < fromRow)
11276
+ continue;
11277
+ const newCell = this.setCellOwnAttr(cell.xml, 'rowAddr', addr.value + delta);
11278
+ rowXml = rowXml.slice(0, cell.startIndex) + newCell + rowXml.slice(cell.endIndex);
11279
+ }
11280
+ if (rowXml !== row.xml)
11281
+ out = out.slice(0, row.startIndex) + rowXml + out.slice(row.endIndex);
11282
+ }
11283
+ return out;
11284
+ }
10661
11285
  /**
10662
11286
  * Clone a table cell for a newly inserted row: same cell attributes, same
10663
11287
  * first-paragraph formatting, but a single paragraph holding `text`.
@@ -10687,6 +11311,65 @@ class HwpxDocument {
10687
11311
  `</${prefix}:p>`;
10688
11312
  return cellXml.slice(0, subListOpen.index + subListOpen[0].length) + paragraph + cellXml.slice(subListCloseIdx);
10689
11313
  }
11314
+ /**
11315
+ * Source cells for a new row inserted after `afterRow`, one per column
11316
+ * position, in column order, covering every column 0..colCnt-1 exactly once.
11317
+ *
11318
+ * For each column: the cell that STARTS there in the template row (keeping
11319
+ * its colSpan so horizontal merges carry over), otherwise the nearest row
11320
+ * above whose own cell starts there. A column no row starts is skipped by the
11321
+ * colSpan of the cell covering it. Returned XML still carries the source
11322
+ * addresses; the caller rewrites rowAddr/rowSpan.
11323
+ */
11324
+ gridCellsForNewRow(rows, afterRow) {
11325
+ const ownProps = (cellXml) => ({
11326
+ col: this.cellOwnAttr(cellXml, 'colAddr')?.value ?? -1,
11327
+ span: this.cellOwnAttr(cellXml, 'colSpan')?.value ?? 1,
11328
+ });
11329
+ // Cells with no address anywhere are placed by position in their row.
11330
+ const rowCells = rows.map(r => {
11331
+ let next = 0;
11332
+ return this.findAllElementsWithDepth(r.xml, 'tc').map(c => {
11333
+ const p = ownProps(c.xml);
11334
+ const col = p.col >= 0 ? p.col : next;
11335
+ next = col + p.span;
11336
+ return { xml: c.xml, col, span: p.span };
11337
+ });
11338
+ });
11339
+ const colCount = Math.max(0, ...rowCells.flat().map(c => c.col + c.span));
11340
+ const out = [];
11341
+ for (let col = 0; col < colCount;) {
11342
+ let pick;
11343
+ for (let r = afterRow; r >= 0 && !pick; r--)
11344
+ pick = rowCells[r].find(c => c.col === col);
11345
+ // Nothing above starts here (should not happen in a well-formed table):
11346
+ // fall back to any row below so the grid still has no hole.
11347
+ for (let r = afterRow + 1; r < rowCells.length && !pick; r++)
11348
+ pick = rowCells[r].find(c => c.col === col);
11349
+ if (!pick) {
11350
+ col++;
11351
+ continue;
11352
+ }
11353
+ // A cell borrowed from a row above may span columns the template row
11354
+ // splits; keep the template row's split by clamping to the next column
11355
+ // that the template row starts.
11356
+ let span = Math.max(1, pick.span);
11357
+ const nextTemplateStart = rowCells[afterRow].map(c => c.col).filter(c => c > col).sort((a, b) => a - b)[0];
11358
+ if (nextTemplateStart !== undefined && col + span > nextTemplateStart)
11359
+ span = nextTemplateStart - col;
11360
+ // Narrow through the cell's OWN attributes: a nested table's cells come
11361
+ // first in the XML, so replacing the first <hp:cellSpan> changed the
11362
+ // nested cell and left this one overlapping the next template cell.
11363
+ // Its width shrinks to the columns it still covers, so the row keeps
11364
+ // the table width.
11365
+ const xml = span === pick.span
11366
+ ? pick.xml
11367
+ : this.setCellOwnWidth(this.setCellOwnAttr(pick.xml, 'colSpan', span), Math.round((this.cellOwnWidth(pick.xml) ?? 0) * span / pick.span));
11368
+ out.push(xml);
11369
+ col += span;
11370
+ }
11371
+ return out;
11372
+ }
10690
11373
  async applyTableRowInsertsToXml() {
10691
11374
  if (!this._zip)
10692
11375
  return;
@@ -10713,25 +11396,40 @@ class HwpxDocument {
10713
11396
  if (insert.afterRowIndex >= rows.length)
10714
11397
  continue;
10715
11398
  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.
11399
+ // Build the new row from the table's COLUMN GRID, not from the template
11400
+ // row's cells. A row just below a vertical merge has no <hp:tc> for the
11401
+ // merged column (the master above covers it), so cloning its cells gave
11402
+ // the new row a hole there: colCnt=3 but only columns 1-2 present
11403
+ // (CodeRabbit, 2026-09-24). For each column position we take the cell
11404
+ // that starts there in the template row, or — if the template row has
11405
+ // none — the nearest row above that does, cloned as a single-row cell.
11406
+ //
11407
+ // Each new cell keeps its source's formatting but only its FIRST
11408
+ // paragraph, emptied: cloning every paragraph copied multi-line cells
11409
+ // (e.g. "○ a\n○ b\n- c") as three empty lines, so Hancom sized the row
11410
+ // for three lines and the one line of new text sat at the top.
10721
11411
  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);
11412
+ const newRowCells = this.gridCellsForNewRow(rows, insert.afterRowIndex);
11413
+ const trOpen = templateRow.xml.slice(0, templateRow.xml.indexOf('>') + 1);
11414
+ let newRowXml = trOpen + newRowCells.map((cellXml, i) => {
11415
+ const text = insert.cellTexts?.[i] ?? '';
11416
+ // New cells sit on row afterRowIndex+1 and span one row each.
11417
+ const cell = this.setCellOwnAttr(this.cloneCellWithText(cellXml, text), 'rowAddr', newRowAddr);
11418
+ return this.setCellOwnAttr(cell, 'rowSpan', 1);
11419
+ }).join('') + '</hp:tr>';
11420
+ // Shift every existing cell below the insertion point down one row.
11421
+ // Without this the next row kept rowAddr=afterRowIndex+1 — the same as
11422
+ // the new row — and Hancom 2020 hung opening the file (reported
11423
+ // 2026-09-24; renumbering rowAddr by <hp:tr> order made it open).
11424
+ // Only the table's OWN cells are touched: a nested table in a cell has
11425
+ // its own row addresses. The delete path does the mirror of this.
11426
+ const shiftedTableXml = this.shiftTableRowAddrs(tableXml, newRowAddr, +1);
11427
+ // Insert after the template row (positions unchanged by the shift above:
11428
+ // it rewrites digits in place only after re-finding rows).
11429
+ const rowsAfterShift = this.findAllElementsWithDepth(shiftedTableXml, 'tr');
11430
+ const anchorRow = rowsAfterShift[insert.afterRowIndex];
11431
+ const insertPos = anchorRow.startIndex + anchorRow.xml.length;
11432
+ const newTableXml = shiftedTableXml.substring(0, insertPos) + '\n' + newRowXml + shiftedTableXml.substring(insertPos);
10735
11433
  // Update rowCnt attribute
10736
11434
  const updatedTableXml = newTableXml.replace(/rowCnt="(\d+)"/, (_m, cnt) => `rowCnt="${parseInt(cnt) + 1}"`);
10737
11435
  xml = xml.substring(0, tables[insert.tableIndex].startIndex) + updatedTableXml + xml.substring(tables[insert.tableIndex].endIndex);
@@ -10909,6 +11607,13 @@ class HwpxDocument {
10909
11607
  }
10910
11608
  // Update colCnt
10911
11609
  tableXml = tableXml.replace(/colCnt="(\d+)"/, (_m, cnt) => `colCnt="${parseInt(cnt) + 1}"`);
11610
+ // Keep the table inside its original width. The new column cloned the
11611
+ // template column's width, so the columns summed to more than the
11612
+ // table: reported 2026-09-24, 4 × 11765 + 11765 = 58825 > body 51024
11613
+ // while <hp:sz width> still said 47060, and the table ran past the
11614
+ // right margin. Scale every column by the same factor so the total is
11615
+ // exactly the table's width again.
11616
+ tableXml = this.fitColumnsToTableWidth(tableXml);
10912
11617
  xml = xml.substring(0, tables[insert.tableIndex].startIndex) + tableXml + xml.substring(tables[insert.tableIndex].endIndex);
10913
11618
  }
10914
11619
  this._zip.file(sectionPath, xml);
@@ -11174,6 +11879,8 @@ exports.HwpxDocument = HwpxDocument;
11174
11879
  // Constants for magic numbers
11175
11880
  HwpxDocument.NESTED_CHECK_LOOKBACK = 500;
11176
11881
  HwpxDocument.SEARCH_SKIP_OFFSET = 10;
11882
+ /** Container elements whose content belongs to OTHER paragraphs or objects. */
11883
+ HwpxDocument.NESTED_CONTENT = /<hp:(tbl|subList|equation|pic|rect|ellipse|polygon|curve|arc|line|container|drawText|textart|ole|footNote|endNote|header|footer)\b/;
11177
11884
  /**
11178
11885
  * Default chunk size for splitting long text (in characters).
11179
11886
  * Texts longer than this will be split into multiple <hp:run> elements.