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