@kimdayoun/hwpx-mcp 0.3.4 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -2
- package/README.md +2 -2
- package/dist/HwpxDocument.d.ts +16 -0
- package/dist/HwpxDocument.js +110 -29
- package/dist/HwpxParser.d.ts +13 -0
- package/dist/HwpxParser.js +50 -1
- package/dist/ToolResult.d.ts +14 -0
- package/dist/ToolResult.js +27 -0
- package/dist/index.js +438 -456
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,51 @@
|
|
|
5
5
|
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/) 를 따르고,
|
|
6
6
|
버전은 [유의적 버전](https://semver.org/lang/ko/) 을 따릅니다.
|
|
7
7
|
|
|
8
|
+
## [0.3.5] - 2026-09-25
|
|
9
|
+
|
|
10
|
+
0.3.4 의 ⑤ 수정이 MCP 도구 호출 경로에 닿지 않았던 것을 바로잡았습니다. 이번에는 회신 ①~⑥ 을
|
|
11
|
+
단위·모듈·회귀·종단간 시험으로 모두 옮겼고, 버전 매트릭스에 0.3.4 를 넣었습니다.
|
|
12
|
+
같은 시험 16개에서 0.3.0 은 3개, 0.3.3 은 7개, 0.3.4 는 13개, 이 버전은 16개를 통과합니다.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **`update_paragraph_text` 로 글자 모양이 섞인 문단을 통째로 바꾸면 뒤쪽이 굵게 바뀌던
|
|
17
|
+
문제가 0.3.4 에서도 그대로였던 것.** 0.3.4 는 문서 쪽 함수를 고쳤지만, 도구는 run 이 둘
|
|
18
|
+
이상인 문단을 받으면 그 함수를 부르지 않고 글을 원래 run 들에 나눠 담는 쪽으로 넘겼습니다.
|
|
19
|
+
신고와 같은 호출로 재면 0.3.3 과 0.3.4 모두 새 글 35자가 굵은 run 에 들어갔고, 한/글 2024
|
|
20
|
+
화면에서 셋째 줄 가운데부터 굵게 보였습니다. 이제 run 0 교체는 늘 문단 전체를 첫 run 의
|
|
21
|
+
글자 모양 하나로 씁니다. run 마다 글자 모양을 지키려면 `update_paragraph_text_preserve_styles`
|
|
22
|
+
를 씁니다. 한/글 2020 이 저장한 원본의 첫 섞인 문단에서 0.3.3 은 83건 중 3건, 0.3.4 는 4건,
|
|
23
|
+
이 버전은 82건 중 82건이 제대로 바뀌었습니다.
|
|
24
|
+
- **문단을 통째로 바꿔도 앞 공백·탭 뒤 글이 남거나, 문단이 사라지던 문제.** 한/글은 글 하나를
|
|
25
|
+
고정폭 빈칸·탭 앞뒤로 여러 조각으로 읽는데, 첫 조각만 바꿨습니다. 보도자료 본문 문단은
|
|
26
|
+
0.3.4 에서 저장 뒤 빈 줄만 남았습니다. 이제 문단의 자기 글 자리에 새 글을 쓰고 나머지 조각을
|
|
27
|
+
비웁니다. 표·글상자·수식·그림·각주는 그대로 둡니다.
|
|
28
|
+
- **글상자를 품은 문단이 글상자 글로 읽히고, 고치면 성공이라 답하면서 글상자 밖에 쓰던 문제.**
|
|
29
|
+
문단을 읽을 때 run 을 문단 전체에서 찾아, 글상자 속 문단의 `</hp:run>` 에서 run 이 끝났습니다.
|
|
30
|
+
그래서 "[글상자] 자기 글" 문단은 글상자 글로 읽혔고, 고치면 새 글이 글상자 옆에 따로 찍혔으며
|
|
31
|
+
(한/글 2024 에서 확인), 다시 열면 옛 글상자 글이 나왔습니다. 이제 문단은 글상자·머리말·표 같은
|
|
32
|
+
중첩 내용을 뺀 자기 글로 읽습니다. 글상자 안 글은 따로 올라오는 속 문단으로 고치면 글상자 안에
|
|
33
|
+
들어갑니다. 한/글 원본 275건에서 자기 글이 읽을 때 빠지던 문단이 41개에서 1개(HWP 5 변환본의
|
|
34
|
+
옛 한글 자모)로 줄었고, 중첩 내용을 품은 문단을 통째로 바꾸고 다시 열었을 때 새 글이 안 보이던
|
|
35
|
+
경우가 72건 중 18건에서 0건이 됐습니다. 머리말을 품은 첫 문단도 이제 본문 글로 읽혀, 고쳐도
|
|
36
|
+
머리말 글이 그대로입니다.
|
|
37
|
+
- **문단을 통째로 바꾼 뒤 같은 문단의 다른 조각을 고치면 그 글이 저장본에서 빠지던 문제.**
|
|
38
|
+
탭이 든 문단에서 run 0 을 바꾸고 run 2 를 고치면 메모리는 두 글을 다 가졌지만 저장본에는
|
|
39
|
+
run 0 글만 남았습니다(0.3.4 는 run 0 글까지 빠졌습니다). 이제 호출한 순서대로 합쳐 씁니다.
|
|
40
|
+
- **글상자 속 문단과 그 글상자를 품은 문단을 한 번에 고치면 바깥 문단 쓰기가 빠지던 문제.**
|
|
41
|
+
속 문단 글이 길어지면 바깥 문단이 끝나는 위치를 잘못 잡아, 성공이라 답하고 옛 글을 남겼습니다.
|
|
42
|
+
이제 문단마다 쓰기 직전에 끝 위치를 다시 잽니다.
|
|
43
|
+
|
|
44
|
+
한/글 원본 200건에서 문단 통째 교체를 재면, 파서가 읽은 글과 문단 자기 글이 같은 문단은
|
|
45
|
+
0.3.4 가 144건 중 135건, 이 버전이 144건 모두 제자리에 들어갔습니다. 둘이 다른 문단 12건은
|
|
46
|
+
0.3.4 가 모두 어긋났고, 이 버전은 12건 모두 제자리에 들어갔습니다.
|
|
47
|
+
|
|
48
|
+
### 알려진 제한
|
|
49
|
+
|
|
50
|
+
- 표 칸 글 쓰기에서, 칸 안에 문단이 여럿이거나 그림만 있는 칸은 글이 원래 자리에 들어가지
|
|
51
|
+
않을 수 있습니다(한/글 원본 209건 중 67건, 0.3.3 과 같음).
|
|
52
|
+
|
|
8
53
|
## [0.3.4] - 2026-09-24
|
|
9
54
|
|
|
10
55
|
0.3.3 을 실제 업무 양식·실문서에 쓴 2차 사용 회신(6건)을 재현해 고쳤습니다.
|
|
@@ -47,8 +92,10 @@
|
|
|
47
92
|
병합하거나 1열 표를 세로로 병합하면 저장본에 칸이 하나도 없는 행이 생겼고, 한/글 2024 는
|
|
48
93
|
이 파일에서 PDF 를 만들지 못했습니다. 한/글 원본 275건에는 칸 없는 행이 한 건도 없습니다.
|
|
49
94
|
이제 이런 병합은 어느 행이 비는지 알려 주고 거부합니다. 0.3.3 에도 있던 문제입니다.
|
|
50
|
-
- **글자 모양이 섞인 문단을 통째로 바꾸면 뒤쪽이 굵게 바뀌던 문제.**
|
|
51
|
-
첫 run 의 글자 모양으로 문단 전체를
|
|
95
|
+
- **글자 모양이 섞인 문단을 통째로 바꾸면 뒤쪽이 굵게 바뀌던 문제.** 문서 쪽 `updateParagraphText`
|
|
96
|
+
는 run 0 을 바꾸면 첫 run 의 글자 모양으로 문단 전체를 쓰도록 고쳤습니다. 다만 MCP 도구
|
|
97
|
+
`update_paragraph_text` 는 이 함수를 거치지 않아 0.3.4 에서도 증상이 남았고, 0.3.5 에서
|
|
98
|
+
고쳤습니다.
|
|
52
99
|
- **실패한 호출도 `isError: false` 로 오던 문제.**
|
|
53
100
|
|
|
54
101
|
### 알려진 제한
|
package/README.md
CHANGED
|
@@ -309,11 +309,11 @@ npm run test:module # 모듈: 문서 API → 저장 → 다시 열기로
|
|
|
309
309
|
npm run test:regression # 회귀: 신고 1건당 파일 1개, 신고 문장을 주석으로 붙임
|
|
310
310
|
npm run test:e2e # 종단간: 빌드한 MCP 서버를 stdio 로 띄워 호출
|
|
311
311
|
npm run test:security # 빌드 후 실제 MCP 저장 경로 공격·반복 저장·실패 정리 검사
|
|
312
|
-
npm run test:versions -- 0.3.
|
|
312
|
+
npm run test:versions -- 0.3.4 local # 같은 e2e 를 게시된 버전과 로컬 빌드에 돌려 표로 비교
|
|
313
313
|
```
|
|
314
314
|
|
|
315
315
|
e2e 는 `HWPX_MCP_SERVER` 로 대상 서버를 고릅니다. `local`(기본)은 이 저장소의
|
|
316
|
-
`dist/index.js`, `npm:0.3.
|
|
316
|
+
`dist/index.js`, `npm:0.3.4` 는 게시된 버전입니다. `local` 서버를 띄울 Node 는
|
|
317
317
|
`HWPX_MCP_NODE` 로 바꿉니다(CI 는 Node 18·22 로 돌립니다).
|
|
318
318
|
|
|
319
319
|
문단 저장 회귀 테스트는 개인 PC의 문서 경로 대신 합성 HWPX를 사용합니다.
|
package/dist/HwpxDocument.d.ts
CHANGED
|
@@ -1277,6 +1277,22 @@ export declare class HwpxDocument {
|
|
|
1277
1277
|
* Returns the paragraph's start, end, and XML content in the original document.
|
|
1278
1278
|
*/
|
|
1279
1279
|
private findTargetParagraphForUpdate;
|
|
1280
|
+
/**
|
|
1281
|
+
* Replace the whole own text of a paragraph (run 0 of updateParagraphText).
|
|
1282
|
+
*
|
|
1283
|
+
* The new text goes into the paragraph's first own text node — the first
|
|
1284
|
+
* <hp:t> with text, outside every nested container — and every other own
|
|
1285
|
+
* text node is emptied, including characters written as elements inside
|
|
1286
|
+
* them (tab, full-width space, line break). The first run's character shape
|
|
1287
|
+
* therefore carries the whole sentence. Nested content (tables, text boxes,
|
|
1288
|
+
* equations, pictures, notes) and all attributes stay byte-for-byte.
|
|
1289
|
+
*
|
|
1290
|
+
* Written by position rather than memory run index: the parser splits one
|
|
1291
|
+
* <hp:t> into several memory runs around tabs and full-width spaces, so run 0
|
|
1292
|
+
* named only a fragment and the rest of the old text survived (measured
|
|
1293
|
+
* 2026-09-25 on 150 Hancom originals: 449 of 11,754 paragraphs).
|
|
1294
|
+
*/
|
|
1295
|
+
private replaceWholeParagraphText;
|
|
1280
1296
|
/**
|
|
1281
1297
|
* Replace multiple runs in a paragraph element at once.
|
|
1282
1298
|
* Finds hp:run elements and updates their hp:t content.
|
package/dist/HwpxDocument.js
CHANGED
|
@@ -564,35 +564,36 @@ class HwpxDocument {
|
|
|
564
564
|
// Track for XML update - always add if we have a zip (HWPX file)
|
|
565
565
|
// Similar to updateTableCell which always tracks changes
|
|
566
566
|
if (this._zip) {
|
|
567
|
-
const oldText = paragraph.runs[runIndex].text || '';
|
|
568
567
|
const paragraphOccurrence = this.getParagraphOccurrence(sectionIndex, elementIndex, paragraph.id || '');
|
|
569
|
-
this._pendingDirectTextUpdates.push({
|
|
570
|
-
sectionIndex,
|
|
571
|
-
elementIndex,
|
|
572
|
-
paragraphId: paragraph.id || '', // Use stable paragraph ID for reliable identification
|
|
573
|
-
paragraphOccurrence,
|
|
574
|
-
paragraph,
|
|
575
|
-
runIndex,
|
|
576
|
-
oldText,
|
|
577
|
-
newText: text
|
|
578
|
-
});
|
|
579
|
-
// When updating run 0, clear other runs (full paragraph replacement)
|
|
580
568
|
if (runIndex === 0) {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
569
|
+
// Whole-paragraph replacement is written by position (see
|
|
570
|
+
// wholeParagraph): the new text goes into the paragraph's first own
|
|
571
|
+
// text node and its other own text nodes are emptied. A paragraph that
|
|
572
|
+
// holds a text box reads as its own text only; the box's text is the
|
|
573
|
+
// next element, so editing it writes inside the box.
|
|
574
|
+
this._pendingDirectTextUpdates.push({
|
|
575
|
+
sectionIndex,
|
|
576
|
+
elementIndex,
|
|
577
|
+
paragraphId: paragraph.id || '',
|
|
578
|
+
paragraphOccurrence,
|
|
579
|
+
paragraph,
|
|
580
|
+
runIndex: 0,
|
|
581
|
+
oldText: paragraph.runs.map(r => r.text || '').join(''),
|
|
582
|
+
newText: text,
|
|
583
|
+
wholeParagraph: true,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
this._pendingDirectTextUpdates.push({
|
|
588
|
+
sectionIndex,
|
|
589
|
+
elementIndex,
|
|
590
|
+
paragraphId: paragraph.id || '', // Use stable paragraph ID for reliable identification
|
|
591
|
+
paragraphOccurrence,
|
|
592
|
+
paragraph,
|
|
593
|
+
runIndex,
|
|
594
|
+
oldText: paragraph.runs[runIndex].text || '',
|
|
595
|
+
newText: text
|
|
596
|
+
});
|
|
596
597
|
}
|
|
597
598
|
}
|
|
598
599
|
this.saveState();
|
|
@@ -6804,9 +6805,40 @@ class HwpxDocument {
|
|
|
6804
6805
|
});
|
|
6805
6806
|
// STEP 3: Apply updates using pre-computed positions
|
|
6806
6807
|
for (const [elementIndex, updates] of sortedEntries) {
|
|
6807
|
-
const
|
|
6808
|
-
if (!
|
|
6808
|
+
const found = paragraphTargets.get(elementIndex);
|
|
6809
|
+
if (!found)
|
|
6809
6810
|
continue;
|
|
6811
|
+
// Writes go bottom-to-top, so a start computed before them still names
|
|
6812
|
+
// this paragraph. Its end may not: the parser lifts a text-box paragraph
|
|
6813
|
+
// out as its own element, so the box's paragraph and the paragraph that
|
|
6814
|
+
// holds the box overlap, and the inner one (later start) is written first.
|
|
6815
|
+
// If that changed length, the pre-computed end fell inside the outer
|
|
6816
|
+
// paragraph and its write was dropped (CodeRabbit, PR #17). Re-measure.
|
|
6817
|
+
const end = this.findBalancedParagraphEnd(xml, found.start);
|
|
6818
|
+
if (end === -1)
|
|
6819
|
+
continue;
|
|
6820
|
+
const target = { start: found.start, end, xml: xml.slice(found.start, end) };
|
|
6821
|
+
// A whole-paragraph replacement (run 0 of updateParagraphText) is
|
|
6822
|
+
// written by position and replaces every earlier edit of this
|
|
6823
|
+
// paragraph. Run edits made after it are folded into its text, not
|
|
6824
|
+
// applied as a second pass: after a whole replacement the XML has one
|
|
6825
|
+
// text node while memory can still hold several runs (the parser split
|
|
6826
|
+
// "A<hp:tab/>B" into ["A", "", "B"]), so run N no longer names an XML
|
|
6827
|
+
// node and a second pass dropped the edit (CodeRabbit, PR #17).
|
|
6828
|
+
// Updates are in call order here (the list is filled in call order).
|
|
6829
|
+
const lastWhole = updates.map(u => !!u.wholeParagraph).lastIndexOf(true);
|
|
6830
|
+
if (lastWhole !== -1) {
|
|
6831
|
+
const runTexts = [updates[lastWhole].newText];
|
|
6832
|
+
for (const u of updates.slice(lastWhole + 1)) {
|
|
6833
|
+
// Memory after the replacement: run 0 holds the new text, runs
|
|
6834
|
+
// 1.. are empty until edited; an edit sets that run's text.
|
|
6835
|
+
runTexts[u.runIndex] = u.newText;
|
|
6836
|
+
}
|
|
6837
|
+
const text = Array.from(runTexts, t => t ?? '').join('');
|
|
6838
|
+
const current = { start: target.start, end: target.end, xml: xml.slice(target.start, target.end) };
|
|
6839
|
+
xml = this.replaceWholeParagraphText(xml, current, text);
|
|
6840
|
+
continue;
|
|
6841
|
+
}
|
|
6810
6842
|
// Sort by runIndex to process in order
|
|
6811
6843
|
updates.sort((a, b) => a.runIndex - b.runIndex);
|
|
6812
6844
|
// Apply the update directly using pre-computed target location
|
|
@@ -7495,6 +7527,55 @@ class HwpxDocument {
|
|
|
7495
7527
|
// Final fallback: return index-based result anyway
|
|
7496
7528
|
return indexBasedTarget;
|
|
7497
7529
|
}
|
|
7530
|
+
/**
|
|
7531
|
+
* Replace the whole own text of a paragraph (run 0 of updateParagraphText).
|
|
7532
|
+
*
|
|
7533
|
+
* The new text goes into the paragraph's first own text node — the first
|
|
7534
|
+
* <hp:t> with text, outside every nested container — and every other own
|
|
7535
|
+
* text node is emptied, including characters written as elements inside
|
|
7536
|
+
* them (tab, full-width space, line break). The first run's character shape
|
|
7537
|
+
* therefore carries the whole sentence. Nested content (tables, text boxes,
|
|
7538
|
+
* equations, pictures, notes) and all attributes stay byte-for-byte.
|
|
7539
|
+
*
|
|
7540
|
+
* Written by position rather than memory run index: the parser splits one
|
|
7541
|
+
* <hp:t> into several memory runs around tabs and full-width spaces, so run 0
|
|
7542
|
+
* named only a fragment and the rest of the old text survived (measured
|
|
7543
|
+
* 2026-09-25 on 150 Hancom originals: 449 of 11,754 paragraphs).
|
|
7544
|
+
*/
|
|
7545
|
+
replaceWholeParagraphText(xml, target, newText) {
|
|
7546
|
+
let paragraphXml = target.xml;
|
|
7547
|
+
const escaped = this.escapeXml(newText);
|
|
7548
|
+
const runs = this.findDirectChildRuns(paragraphXml);
|
|
7549
|
+
// Own text nodes of each run, as (run, has non-empty own text).
|
|
7550
|
+
const hasText = (runXml) => [...this.ownRunText(runXml).matchAll(/<hp:t\b[^>]*>([\s\S]*?)<\/hp:t>/g)]
|
|
7551
|
+
.some(m => /<hp:(?:tab|fwSpace|nbSpace|lineBreak)\b/.test(m[1]) || m[1].replace(/<[^>]+>/g, '') !== '');
|
|
7552
|
+
let firstIdx = runs.findIndex(r => hasText(r.xml));
|
|
7553
|
+
// No own text anywhere: fall back to the first own <hp:t> (empty paragraph).
|
|
7554
|
+
if (firstIdx === -1)
|
|
7555
|
+
firstIdx = runs.findIndex(r => /<hp:t\b/.test(this.ownRunText(r.xml)));
|
|
7556
|
+
for (let i = runs.length - 1; i >= 0; i--) {
|
|
7557
|
+
const run = runs[i];
|
|
7558
|
+
let written = i !== firstIdx; // only the first chosen run receives the text
|
|
7559
|
+
const newRunXml = this.mapOwnRunText(run.xml, part => part.replace(/<hp:t\b([^>]*?)\/>|<hp:t\b([^>]*)>([\s\S]*?)<\/hp:t>/g, (_m, selfAttrs, attrs) => {
|
|
7560
|
+
const text = written ? '' : escaped;
|
|
7561
|
+
written = true;
|
|
7562
|
+
return `<hp:t${selfAttrs ?? attrs ?? ''}>${text}</hp:t>`;
|
|
7563
|
+
}));
|
|
7564
|
+
paragraphXml = paragraphXml.slice(0, run.start) + newRunXml + paragraphXml.slice(run.end);
|
|
7565
|
+
}
|
|
7566
|
+
if (firstIdx === -1) {
|
|
7567
|
+
// The paragraph has no <hp:t> of its own: add one to its first run.
|
|
7568
|
+
const firstRun = runs[0];
|
|
7569
|
+
if (firstRun) {
|
|
7570
|
+
const openEnd = firstRun.xml.indexOf('>') + 1;
|
|
7571
|
+
const withText = firstRun.xml.endsWith('/>')
|
|
7572
|
+
? firstRun.xml.replace(/\/>$/, `><hp:t>${escaped}</hp:t></hp:run>`)
|
|
7573
|
+
: firstRun.xml.slice(0, openEnd) + `<hp:t>${escaped}</hp:t>` + firstRun.xml.slice(openEnd);
|
|
7574
|
+
paragraphXml = paragraphXml.slice(0, firstRun.start) + withText + paragraphXml.slice(firstRun.end);
|
|
7575
|
+
}
|
|
7576
|
+
}
|
|
7577
|
+
return xml.slice(0, target.start) + paragraphXml + xml.slice(target.end);
|
|
7578
|
+
}
|
|
7498
7579
|
/**
|
|
7499
7580
|
* Replace multiple runs in a paragraph element at once.
|
|
7500
7581
|
* Finds hp:run elements and updates their hp:t content.
|
package/dist/HwpxParser.d.ts
CHANGED
|
@@ -31,6 +31,19 @@ export declare class HwpxParser {
|
|
|
31
31
|
private static parseColumnDef;
|
|
32
32
|
private static parseImageEffects;
|
|
33
33
|
private static parseParagraphsSimple;
|
|
34
|
+
/**
|
|
35
|
+
* The paragraph with every nested container (table, text box, drawing
|
|
36
|
+
* object, header/footer, caption, equation, note…) cut out, leaving only its
|
|
37
|
+
* own runs and text. Section paragraphs are read from this: the paragraphs
|
|
38
|
+
* inside those containers are listed as elements of their own, and the save
|
|
39
|
+
* path writes a paragraph's text into exactly these own <hp:t>
|
|
40
|
+
* (HwpxDocument.ownRunText). Reading the whole paragraph instead ended its
|
|
41
|
+
* run at the first nested </hp:run>: a paragraph "[text box] own text" read
|
|
42
|
+
* as the text box's text, its own text was never seen, and a whole-paragraph
|
|
43
|
+
* edit reopened as the old text box text (CodeRabbit, PR #17; 41 of 16,733
|
|
44
|
+
* paragraphs with own text in 275 Hancom originals lost it on reading).
|
|
45
|
+
*/
|
|
46
|
+
private static withoutNestedContent;
|
|
34
47
|
private static parseParagraph;
|
|
35
48
|
private static parseRun;
|
|
36
49
|
private static processTextContent;
|
package/dist/HwpxParser.js
CHANGED
|
@@ -1283,7 +1283,9 @@ class HwpxParser {
|
|
|
1283
1283
|
elements.sort((a, b) => a.index - b.index);
|
|
1284
1284
|
for (const el of elements) {
|
|
1285
1285
|
if (el.type === 'p') {
|
|
1286
|
-
|
|
1286
|
+
// Own content only: the paragraphs inside its text boxes, headers and
|
|
1287
|
+
// captions are elements of their own (withoutNestedContent).
|
|
1288
|
+
const paragraph = this.parseParagraph(this.withoutNestedContent(el.xml));
|
|
1287
1289
|
// Store XML position from original XML for direct updates
|
|
1288
1290
|
// This enables fast paragraph updates without re-parsing during save()
|
|
1289
1291
|
if (el.originalXmlPosition) {
|
|
@@ -1864,6 +1866,53 @@ class HwpxParser {
|
|
|
1864
1866
|
}
|
|
1865
1867
|
return paragraphs;
|
|
1866
1868
|
}
|
|
1869
|
+
/**
|
|
1870
|
+
* The paragraph with every nested container (table, text box, drawing
|
|
1871
|
+
* object, header/footer, caption, equation, note…) cut out, leaving only its
|
|
1872
|
+
* own runs and text. Section paragraphs are read from this: the paragraphs
|
|
1873
|
+
* inside those containers are listed as elements of their own, and the save
|
|
1874
|
+
* path writes a paragraph's text into exactly these own <hp:t>
|
|
1875
|
+
* (HwpxDocument.ownRunText). Reading the whole paragraph instead ended its
|
|
1876
|
+
* run at the first nested </hp:run>: a paragraph "[text box] own text" read
|
|
1877
|
+
* as the text box's text, its own text was never seen, and a whole-paragraph
|
|
1878
|
+
* edit reopened as the old text box text (CodeRabbit, PR #17; 41 of 16,733
|
|
1879
|
+
* paragraphs with own text in 275 Hancom originals lost it on reading).
|
|
1880
|
+
*/
|
|
1881
|
+
static withoutNestedContent(paragraphXml) {
|
|
1882
|
+
const nested = /<hp:(tbl|subList|equation|pic|rect|ellipse|polygon|curve|arc|line|container|drawText|textart|ole|footNote|endNote|header|footer)\b/;
|
|
1883
|
+
const openEnd = paragraphXml.indexOf('>') + 1;
|
|
1884
|
+
let rest = paragraphXml.slice(openEnd);
|
|
1885
|
+
let own = '';
|
|
1886
|
+
for (;;) {
|
|
1887
|
+
const m = rest.match(nested);
|
|
1888
|
+
if (!m || m.index === undefined) {
|
|
1889
|
+
own += rest;
|
|
1890
|
+
break;
|
|
1891
|
+
}
|
|
1892
|
+
own += rest.slice(0, m.index);
|
|
1893
|
+
const re = new RegExp(`<(/?)hp:${m[1]}\\b[^>]*?(/?)>`, 'g');
|
|
1894
|
+
re.lastIndex = m.index;
|
|
1895
|
+
let depth = 0;
|
|
1896
|
+
let end = rest.length;
|
|
1897
|
+
let t;
|
|
1898
|
+
while ((t = re.exec(rest)) !== null) {
|
|
1899
|
+
if (t[2]) {
|
|
1900
|
+
if (depth === 0) {
|
|
1901
|
+
end = t.index + t[0].length;
|
|
1902
|
+
break;
|
|
1903
|
+
}
|
|
1904
|
+
continue;
|
|
1905
|
+
}
|
|
1906
|
+
depth += t[1] ? -1 : 1;
|
|
1907
|
+
if (depth === 0) {
|
|
1908
|
+
end = t.index + t[0].length;
|
|
1909
|
+
break;
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
rest = rest.slice(end);
|
|
1913
|
+
}
|
|
1914
|
+
return paragraphXml.slice(0, openEnd) + own;
|
|
1915
|
+
}
|
|
1867
1916
|
static parseParagraph(xml) {
|
|
1868
1917
|
// Extract only the opening <hp:p ...> tag to get paragraph attributes
|
|
1869
1918
|
const pTagMatch = xml.match(/^<hp:p\s+([^>]*)>/);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool-result envelope and argument checks, kept out of index.ts so they
|
|
3
|
+
* can be tested without starting the stdio server.
|
|
4
|
+
*/
|
|
5
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
+
export declare function success(data: unknown): CallToolResult;
|
|
7
|
+
export declare function error(message: string): CallToolResult;
|
|
8
|
+
/**
|
|
9
|
+
* The declared-required arguments of `toolName` that `args` leaves undefined or
|
|
10
|
+
* null. Reporting them by name replaced a generic handler failure: omitting
|
|
11
|
+
* `section_index` used to surface as "Failed to insert paragraph", which reads
|
|
12
|
+
* like document corruption.
|
|
13
|
+
*/
|
|
14
|
+
export declare function findMissingArgs(requiredByTool: ReadonlyMap<string, readonly string[]>, toolName: string, args: Record<string, unknown> | undefined): string[];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.success = success;
|
|
4
|
+
exports.error = error;
|
|
5
|
+
exports.findMissingArgs = findMissingArgs;
|
|
6
|
+
function success(data) {
|
|
7
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
8
|
+
}
|
|
9
|
+
function error(message) {
|
|
10
|
+
// isError tells the MCP client the call failed. Without it, a missing
|
|
11
|
+
// argument or a refused write came back as a normal result whose body merely
|
|
12
|
+
// contained {"error": …}, and agents treated it as success (reported
|
|
13
|
+
// 2026-09-24). The JSON body is kept for clients that read it.
|
|
14
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: message }) }], isError: true };
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The declared-required arguments of `toolName` that `args` leaves undefined or
|
|
18
|
+
* null. Reporting them by name replaced a generic handler failure: omitting
|
|
19
|
+
* `section_index` used to surface as "Failed to insert paragraph", which reads
|
|
20
|
+
* like document corruption.
|
|
21
|
+
*/
|
|
22
|
+
function findMissingArgs(requiredByTool, toolName, args) {
|
|
23
|
+
const required = requiredByTool.get(toolName);
|
|
24
|
+
if (!required || required.length === 0)
|
|
25
|
+
return [];
|
|
26
|
+
return required.filter(key => args?.[key] === undefined || args?.[key] === null);
|
|
27
|
+
}
|