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