@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.
- package/CHANGELOG.md +100 -0
- package/README.md +13 -3
- package/dist/HwpxDocument.d.ts +139 -0
- package/dist/HwpxDocument.js +938 -231
- package/dist/HwpxParser.d.ts +13 -0
- package/dist/HwpxParser.js +50 -1
- package/dist/ToolResult.d.ts +14 -0
- package/dist/ToolResult.js +27 -0
- package/dist/XmlWellFormed.d.ts +5 -0
- package/dist/XmlWellFormed.js +52 -0
- package/dist/index.js +455 -473
- package/package.json +8 -2
package/dist/HwpxDocument.js
CHANGED
|
@@ -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
|
|
506
|
-
if (!
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
if (
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2206
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
//
|
|
3883
|
-
|
|
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[^>]
|
|
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[^>]
|
|
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
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
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[^>]
|
|
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[^>]
|
|
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
|
-
|
|
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='${
|
|
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[^>]
|
|
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
|
-
|
|
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[^>]
|
|
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)([^>]
|
|
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)([^>]
|
|
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[^>]
|
|
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[^>]
|
|
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[^>]
|
|
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
|
|
6498
|
-
//
|
|
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
|
-
|
|
6502
|
-
|
|
6503
|
-
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
|
|
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
|
-
//
|
|
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
|
|
6535
|
-
if (!
|
|
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[^>]
|
|
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[^>]
|
|
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
|
-
//
|
|
7237
|
-
//
|
|
7238
|
-
|
|
7239
|
-
|
|
7240
|
-
|
|
7241
|
-
|
|
7242
|
-
|
|
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(
|
|
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 =
|
|
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[^>]
|
|
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[^>]
|
|
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[^>]
|
|
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[^>]
|
|
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[^>]
|
|
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[^>]
|
|
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[^>]
|
|
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[^>]
|
|
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[^>]
|
|
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[^>]
|
|
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[^>]
|
|
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[^>]
|
|
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[^>]
|
|
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[^>]
|
|
9081
|
-
const tblPattern = /<(?:hp|hs|hc):tbl[^>]
|
|
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
|
-
//
|
|
10717
|
-
//
|
|
10718
|
-
//
|
|
10719
|
-
//
|
|
10720
|
-
//
|
|
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
|
|
10723
|
-
|
|
10724
|
-
|
|
10725
|
-
const
|
|
10726
|
-
|
|
10727
|
-
const
|
|
10728
|
-
|
|
10729
|
-
}
|
|
10730
|
-
//
|
|
10731
|
-
|
|
10732
|
-
//
|
|
10733
|
-
|
|
10734
|
-
|
|
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.
|