@kimdayoun/hwpx-mcp 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,106 @@
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
+
53
+ ## [0.3.4] - 2026-09-24
54
+
55
+ 0.3.3 을 실제 업무 양식·실문서에 쓴 2차 사용 회신(6건)을 재현해 고쳤습니다.
56
+ 같은 시험을 게시된 버전과 로컬 빌드에 돌리는 버전 매트릭스를 만들었고,
57
+ 0.3.0 은 3/11, 0.3.3 은 5/11, 이 버전은 11/11 을 통과합니다.
58
+ 변경 내역은 [#16](https://github.com/Dayoooun/hwpx-mcp/pull/16) 에 있습니다.
59
+
60
+ ### Fixed
61
+
62
+ - **행을 추가한 파일을 한/글이 열지 못하고 멈추던 문제.** 새 행 아래 행들의
63
+ `rowAddr` 를 다시 매기지 않아 같은 번호가 두 행에 생겼습니다. 사용자 신고는 한/글 2020,
64
+ 재현은 한/글 2024 에서 했습니다. 한/글 원본 142건의
65
+ 첫 표에서 0.3.3 은 142건 모두 번호가 겹쳤고, 이제 0건입니다. 세로 병합 한가운데에
66
+ 넣으면 거부하고, 병합 아래에 넣은 새 행도 모든 열을 덮습니다.
67
+ - **표를 품은 문단을 `update_paragraph_text_preserve_styles` 로 고치면 문서가 깨지던 문제.**
68
+ 문단 위치 캐시가 한/글 원본 문단 70,677개 중 16,271개에서 다른 문단을 가리켰고,
69
+ run 을 셀 때 표 칸 안 문단까지 셌습니다. 원본 60건 기준 제대로 바뀐 문단 3 → 55,
70
+ 표 칸 글자가 바뀐 경우 44 → 0. 표 요소에 `update_paragraph_text` 를 걸면
71
+ 성공이라 답하던 것도 이제 실패로 알려 줍니다.
72
+ - **`save_document(verify_integrity)` 가 깨진 XML 을 통과시키던 문제.** 글자 모양 세 가지만
73
+ 보던 검사를 실제 XML 파싱으로 바꿨고, 실패하면 저장하지 않습니다. 한/글 원본 275건
74
+ (XML 2,046개)에서 잘못 거부한 것은 0건입니다.
75
+ - **그림이 든 표 칸에 글을 쓰면 저장본이 깨지던 문제.** 칸 글 정규식이 그림 안
76
+ `<hc:transMatrix>` 를 `<hc:t>` 로 읽었습니다. 한/글 원본 209건 중 18건에서
77
+ 재현됐고 이제 0건입니다.
78
+ - **구역이 둘 이상인 문서에서 표 번호가 도구마다 다르던 문제.** `get_table_map` 이
79
+ `table_index_in_section` 도 함께 돌려주고, 도구 설명에 어느 번호를 쓰는지 적었습니다.
80
+ - **`insert_section`·`delete_section` 이 파일에 저장되지 않던 문제.** 두 구역 문서를
81
+ 저장하고 다시 열면 1구역만 남았습니다.
82
+ - **열을 추가하면 표가 본문 폭을 넘던 문제.** 원래 표 폭 안에서 칸 폭을 같은 비율로
83
+ 다시 나눕니다.
84
+ - **표 편집을 여러 번 한 뒤 저장하면 편집 순서가 뒤바뀌던 문제.** 저장할 때 칸 쓰기,
85
+ 병합, 행·열 추가와 삭제를 호출한 순서가 아니라 종류별로 묶어 적용했습니다.
86
+ 그래서 새로 넣은 행이나 열에 쓴 글이 빠지거나 다른 칸에 들어갔고, 행을 두 번 넣거나
87
+ 지우면 두 번째 위치가 어긋났으며, 행을 넣은 뒤 그 아래를 병합하면 병합이 빠졌습니다.
88
+ 0.3.3 에도 있던 문제로, 시험한 여섯 경우 모두 0.3.3 에서 틀리고 이 버전에서 맞습니다.
89
+ 열을 지운 뒤 칸에 쓴 글이 빠지던 것(0.3.3 도 같음)과, 행을 지운 뒤 병합 옆에 행을 넣을 때
90
+ 병합을 가르는지 한 행 어긋나게 판단하던 것도 함께 고쳤습니다.
91
+ - **병합이 한 행의 칸을 모두 덮으면 한/글이 파일을 변환하지 못하던 문제.** 두 행을 전체 폭으로
92
+ 병합하거나 1열 표를 세로로 병합하면 저장본에 칸이 하나도 없는 행이 생겼고, 한/글 2024 는
93
+ 이 파일에서 PDF 를 만들지 못했습니다. 한/글 원본 275건에는 칸 없는 행이 한 건도 없습니다.
94
+ 이제 이런 병합은 어느 행이 비는지 알려 주고 거부합니다. 0.3.3 에도 있던 문제입니다.
95
+ - **글자 모양이 섞인 문단을 통째로 바꾸면 뒤쪽이 굵게 바뀌던 문제.** 문서 쪽 `updateParagraphText`
96
+ 는 run 0 을 바꾸면 첫 run 의 글자 모양으로 문단 전체를 쓰도록 고쳤습니다. 다만 MCP 도구
97
+ `update_paragraph_text` 는 이 함수를 거치지 않아 0.3.4 에서도 증상이 남았고, 0.3.5 에서
98
+ 고쳤습니다.
99
+ - **실패한 호출도 `isError: false` 로 오던 문제.**
100
+
101
+ ### 알려진 제한
102
+
103
+ - 표 칸 글 쓰기에서, 칸 안에 문단이 여럿이거나 그림만 있는 칸은 글이 원래 자리에
104
+ 들어가지 않을 수 있습니다(한/글 원본 209건 중 67건, 0.3.3 과 같음).
105
+ - 새로 만든 표는 바깥 여백 때문에 한/글에서 오른쪽 여백선을 1.16pt 넘습니다.
106
+ 이 버전 이전부터 있던 동작입니다.
107
+
8
108
  ## [0.3.3] - 2026-09-24
9
109
 
10
110
  0.3.2 는 npm 에 게시하지 않았습니다. 게시 전에 윈도우 한/글로 직접 렌더하다가,
package/README.md CHANGED
@@ -298,14 +298,24 @@ AI 동작:
298
298
 
299
299
  ## 테스트
300
300
 
301
+ 테스트는 네 층으로 나뉘고, CI(`.github/workflows/mcp-server-ci.yml`)가 층마다 따로 돌려
302
+ 하나라도 실패하면 merge 를 막습니다.
303
+
301
304
  ```bash
302
305
  cd mcp-server
303
- npm test # vitest 단위 테스트
306
+ npm test # 모든 층을 한 번에 (vitest)
307
+ npm run test:unit # 단위: src/**/*.test.ts, tests/unit
308
+ npm run test:module # 모듈: 문서 API → 저장 → 다시 열기로 판정
309
+ npm run test:regression # 회귀: 신고 1건당 파일 1개, 신고 문장을 주석으로 붙임
310
+ npm run test:e2e # 종단간: 빌드한 MCP 서버를 stdio 로 띄워 호출
304
311
  npm run test:security # 빌드 후 실제 MCP 저장 경로 공격·반복 저장·실패 정리 검사
305
- node test-mcp-e2e.mjs # 16개 기본 E2E 테스트
306
- node test-new-persistence-e2e.mjs # 8개 persistence E2E 테스트
312
+ npm run test:versions -- 0.3.4 local # 같은 e2e 를 게시된 버전과 로컬 빌드에 돌려 표로 비교
307
313
  ```
308
314
 
315
+ e2e 는 `HWPX_MCP_SERVER` 로 대상 서버를 고릅니다. `local`(기본)은 이 저장소의
316
+ `dist/index.js`, `npm:0.3.4` 는 게시된 버전입니다. `local` 서버를 띄울 Node 는
317
+ `HWPX_MCP_NODE` 로 바꿉니다(CI 는 Node 18·22 로 돌립니다).
318
+
309
319
  문단 저장 회귀 테스트는 개인 PC의 문서 경로 대신 합성 HWPX를 사용합니다.
310
320
  여러 `hp:t`로 나뉜 텍스트·빈 문자열·XML 특수문자·동일한 문단 ID/본문의 대상 선택을 검사하며,
311
321
  중첩 표 삭제는 최상위 표 목록과 셀 내부 중첩 표의 보존을 각각 확인합니다.
@@ -70,6 +70,12 @@ export declare class HwpxDocument {
70
70
  private _redoStack;
71
71
  private _pendingTextReplacements;
72
72
  private _pendingDirectTextUpdates;
73
+ /**
74
+ * `col` is the cell's position in the memory row; `colAddr` is its grid column.
75
+ * They differ after a merge: memory keeps covered cells, the XML drops them.
76
+ * The XML writer finds the target by colAddr so a write made after a merge
77
+ * lands in the right cell (writes now replay in call order).
78
+ */
73
79
  private _pendingTableCellUpdates;
74
80
  private _pendingNestedTableInserts;
75
81
  private _pendingImageInserts;
@@ -96,10 +102,25 @@ export declare class HwpxDocument {
96
102
  private _pendingTableRowDeletes;
97
103
  private _pendingTableColumnInserts;
98
104
  private _pendingTableColumnDeletes;
105
+ /**
106
+ * Call order of every pending edit that names a table cell or row/column by
107
+ * index. Each such index is relative to the table as it was at call time,
108
+ * so save must replay these edits in call order (applyTableOpsInCallOrder).
109
+ * A WeakMap keeps the queue element types unchanged and drops entries with
110
+ * their ops (undo, section delete).
111
+ */
112
+ private _tableOpSeq;
113
+ private _tableOpCounter;
99
114
  private _pendingParagraphCopies;
100
115
  private _pendingParagraphMoves;
101
116
  private _pendingHeaderUpdates;
102
117
  private _pendingFooterUpdates;
118
+ /**
119
+ * New sections to materialise as Contents/sectionN.xml on save, in call
120
+ * order. `templateFrom` is the section whose <hp:secPr> (page size, margins)
121
+ * the new section copies — Hancom's own "insert section" does the same.
122
+ */
123
+ private _pendingSectionOps;
103
124
  private _charPrCache;
104
125
  private _originalCharPrCount?;
105
126
  private constructor();
@@ -130,6 +151,8 @@ export declare class HwpxDocument {
130
151
  get isDirty(): boolean;
131
152
  get zip(): JSZip | null;
132
153
  get content(): HwpxContent;
154
+ /** Push a table-structure or table-cell edit and remember its call order. */
155
+ private queueTableOp;
133
156
  private saveState;
134
157
  private serializeContent;
135
158
  private deserializeContent;
@@ -353,9 +376,19 @@ export declare class HwpxDocument {
353
376
  /**
354
377
  * Get table map with headers - maps table indices to their header paragraphs
355
378
  * Returns array of table info including the header text from the preceding paragraph
379
+ *
380
+ * Two indices are returned because they differ once a document has more than
381
+ * one section:
382
+ * - `table_index_in_section` — what every table tool (update_table_cell,
383
+ * get_table_cell, insert_table_row, …) expects together with
384
+ * `section_index`. Use this one.
385
+ * - `table_index` — position across the whole document, kept for callers
386
+ * that list tables. Passing it to a table tool in section 1+ addresses a
387
+ * DIFFERENT table (reported 2026-09-24: map said 5, the tool needed 4).
356
388
  */
357
389
  getTableMap(): Array<{
358
390
  table_index: number;
391
+ table_index_in_section: number;
359
392
  section_index: number;
360
393
  header: string;
361
394
  rows: number;
@@ -893,6 +926,12 @@ export declare class HwpxDocument {
893
926
  pageSettings: PageSettings;
894
927
  }[];
895
928
  insertSection(afterSectionIndex: number): number;
929
+ /**
930
+ * Add `delta` to every section number held by a pending edit that is >= from.
931
+ * Covers all pending arrays generically: any numeric field whose name is
932
+ * sectionIndex or ends in "Section"/"SectionIndex" (source/target pairs).
933
+ */
934
+ private shiftPendingSectionIndices;
896
935
  deleteSection(sectionIndex: number): boolean;
897
936
  getStyles(): {
898
937
  id: number;
@@ -1074,6 +1113,19 @@ export declare class HwpxDocument {
1074
1113
  * Generate an empty cell XML for split operations.
1075
1114
  */
1076
1115
  private generateEmptyCell;
1116
+ /**
1117
+ * Replay every pending table edit (cell text, merge/split, nested table,
1118
+ * cell image, cell hanging indent, row/column insert/delete) in call order.
1119
+ *
1120
+ * Each index an edit carries is relative to the table as it was when the
1121
+ * edit was made. Applying by kind (all cell writes, then all row inserts,
1122
+ * then all column inserts ...) wrote text into the pre-insert layout; and
1123
+ * the row appliers sort their own queue by index, which reorders two
1124
+ * inserts or two deletes on the same table. So edits that change a table's
1125
+ * row/column layout run one at a time. Runs of layout-preserving edits
1126
+ * (cell text, indents, images, nested tables) go to their applier together.
1127
+ */
1128
+ private applyTableOpsInCallOrder;
1077
1129
  /**
1078
1130
  * Apply table cell updates to XML while preserving original structure.
1079
1131
  * This function modifies only the text content of specific cells,
@@ -1225,11 +1277,44 @@ export declare class HwpxDocument {
1225
1277
  * Returns the paragraph's start, end, and XML content in the original document.
1226
1278
  */
1227
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;
1228
1296
  /**
1229
1297
  * Replace multiple runs in a paragraph element at once.
1230
1298
  * Finds hp:run elements and updates their hp:t content.
1231
1299
  */
1232
1300
  private replaceRunsInParagraphDirect;
1301
+ /** Container elements whose content belongs to OTHER paragraphs or objects. */
1302
+ private static readonly NESTED_CONTENT;
1303
+ /** Direct <hp:run> children of a paragraph (runs of nested paragraphs excluded). */
1304
+ private findDirectChildRuns;
1305
+ /**
1306
+ * A run's own markup with every nested container (table, equation, text box,
1307
+ * note…) blanked out, so its <hp:t> are the run's own text only.
1308
+ */
1309
+ private ownRunText;
1310
+ /**
1311
+ * Apply `fn` to the parts of a run that are its own text, leaving nested
1312
+ * containers byte-for-byte intact. With `blank`, nested containers are
1313
+ * replaced by an empty marker instead (for reading).
1314
+ */
1315
+ private mapOwnRunText;
1316
+ /** End offset of the <hp:name> element opening at `start` (handles nesting and self-closing). */
1317
+ private findElementEnd;
1233
1318
  /**
1234
1319
  * Replace text in a single run directly using pre-computed target location.
1235
1320
  * Simpler version for single-run updates.
@@ -1411,6 +1496,17 @@ export declare class HwpxDocument {
1411
1496
  * Add image entry to content.hpf manifest
1412
1497
  */
1413
1498
  private addImageToContentHpf;
1499
+ /**
1500
+ * Apply section inserts/deletes to Contents/sectionN.xml, in call order.
1501
+ *
1502
+ * File numbers must keep matching memory section indices, so an insert
1503
+ * renames later files up one (section1 → section2 …) and a delete removes
1504
+ * its file and renames later files down one. content.hpf gets a manifest
1505
+ * item and a spine itemref per section, and header.xml's secCnt follows.
1506
+ */
1507
+ private applySectionOpsToZip;
1508
+ /** A section XML holding one empty paragraph with the template's <hp:secPr>. */
1509
+ private buildEmptySectionXml;
1414
1510
  /**
1415
1511
  * Add hp:pic tag to section XML
1416
1512
  */
@@ -1725,6 +1821,38 @@ export declare class HwpxDocument {
1725
1821
  * @returns Cell XML content and its position, or null if not found
1726
1822
  */
1727
1823
  private findTableCellInXml;
1824
+ /**
1825
+ * Scale this table's column widths so they sum to its <hp:sz width>.
1826
+ *
1827
+ * Column widths are read from cells whose colSpan is 1 (the first one seen
1828
+ * per colAddr). Every cell then gets the sum of the scaled widths of the
1829
+ * columns it spans, so merged cells stay aligned. Rounding leftovers go to
1830
+ * the last column so the total is exact. Nested tables are not touched.
1831
+ */
1832
+ private fitColumnsToTableWidth;
1833
+ /**
1834
+ * Locate one of a cell's OWN address/span attributes (`colAddr`, `rowAddr`,
1835
+ * `colSpan`, `rowSpan`) in `cellXml`, returning the value and the absolute
1836
+ * index of its digits so callers can rewrite it in place.
1837
+ *
1838
+ * Hancom writes them on `<hp:cellAddr>`/`<hp:cellSpan>` after the cell's
1839
+ * sub-list (209/209 corpus files). Hand-made files may put them on the
1840
+ * `<hp:tc>` start tag instead, which the parser also accepts. A nested
1841
+ * table's cells live inside the sub-list, so only the tail is searched for
1842
+ * the child form and only the start tag for the attribute form.
1843
+ */
1844
+ private cellOwnAttr;
1845
+ /** Rewrite one of a cell's own attributes (see cellOwnAttr); no-op if absent. */
1846
+ private setCellOwnAttr;
1847
+ /** A cell's own <hp:cellSz width> (after its sub-list, so never a nested table's). */
1848
+ private cellOwnWidth;
1849
+ /** Rewrite a cell's own <hp:cellSz width>; no-op if the cell has none or width <= 0. */
1850
+ private setCellOwnWidth;
1851
+ /**
1852
+ * Add `delta` to the rowAddr of every cell of THIS table whose rowAddr is
1853
+ * >= fromRow. Nested tables inside cells keep their own addresses.
1854
+ */
1855
+ private shiftTableRowAddrs;
1728
1856
  /**
1729
1857
  * Clone a table cell for a newly inserted row: same cell attributes, same
1730
1858
  * first-paragraph formatting, but a single paragraph holding `text`.
@@ -1733,6 +1861,17 @@ export declare class HwpxDocument {
1733
1861
  * charPrIDRef is kept so the new text matches the template cell's font.
1734
1862
  */
1735
1863
  private cloneCellWithText;
1864
+ /**
1865
+ * Source cells for a new row inserted after `afterRow`, one per column
1866
+ * position, in column order, covering every column 0..colCnt-1 exactly once.
1867
+ *
1868
+ * For each column: the cell that STARTS there in the template row (keeping
1869
+ * its colSpan so horizontal merges carry over), otherwise the nearest row
1870
+ * above whose own cell starts there. A column no row starts is skipped by the
1871
+ * colSpan of the cell covering it. Returned XML still carries the source
1872
+ * addresses; the caller rewrites rowAddr/rowSpan.
1873
+ */
1874
+ private gridCellsForNewRow;
1736
1875
  private applyTableRowInsertsToXml;
1737
1876
  private applyTableRowDeletesToXml;
1738
1877
  private applyTableColumnInsertsToXml;