@lotics/docx 0.1.0

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.
Files changed (107) hide show
  1. package/package.json +40 -0
  2. package/src/fixtures/.gitkeep +0 -0
  3. package/src/fixtures/lotics_generated_contract.docx +0 -0
  4. package/src/fonts/bundled.ts +123 -0
  5. package/src/fonts/registry.test.ts +233 -0
  6. package/src/fonts/registry.ts +219 -0
  7. package/src/fonts/types.ts +83 -0
  8. package/src/index.ts +16 -0
  9. package/src/layout/engine.test.ts +430 -0
  10. package/src/layout/engine.ts +566 -0
  11. package/src/layout/page_geometry.ts +43 -0
  12. package/src/layout/types.ts +159 -0
  13. package/src/load.test.ts +144 -0
  14. package/src/load.ts +142 -0
  15. package/src/model/default_numbering.ts +101 -0
  16. package/src/model/default_styles.ts +201 -0
  17. package/src/model/numbering_table.ts +52 -0
  18. package/src/model/properties.ts +328 -0
  19. package/src/model/sections.ts +94 -0
  20. package/src/model/style_resolution.test.ts +219 -0
  21. package/src/model/style_resolution.ts +113 -0
  22. package/src/model/style_table.ts +22 -0
  23. package/src/model/theme.ts +156 -0
  24. package/src/model/types.ts +55 -0
  25. package/src/parse/drawing.ts +157 -0
  26. package/src/parse/font_table.ts +132 -0
  27. package/src/parse/footnotes.ts +60 -0
  28. package/src/parse/header_footer.test.ts +264 -0
  29. package/src/parse/header_footer.ts +66 -0
  30. package/src/parse/numbering.ts +187 -0
  31. package/src/parse/parser.ts +184 -0
  32. package/src/parse/relationships.ts +83 -0
  33. package/src/parse/sections.test.ts +192 -0
  34. package/src/parse/sections.ts +182 -0
  35. package/src/parse/styles.ts +149 -0
  36. package/src/parse/theme.test.ts +86 -0
  37. package/src/parse/theme.ts +112 -0
  38. package/src/pm/bubble_menu.ts +117 -0
  39. package/src/pm/commands.test.ts +185 -0
  40. package/src/pm/commands.ts +697 -0
  41. package/src/pm/commands_insert.test.ts +183 -0
  42. package/src/pm/docx_to_pm.test.ts +330 -0
  43. package/src/pm/docx_to_pm.ts +643 -0
  44. package/src/pm/drag_handle.ts +166 -0
  45. package/src/pm/format_painter.test.ts +91 -0
  46. package/src/pm/format_painter.ts +109 -0
  47. package/src/pm/header_footer_doc.ts +24 -0
  48. package/src/pm/hyperlinks.test.ts +234 -0
  49. package/src/pm/image_registry.test.ts +81 -0
  50. package/src/pm/image_registry.ts +100 -0
  51. package/src/pm/images.test.ts +257 -0
  52. package/src/pm/link_popover.ts +159 -0
  53. package/src/pm/mark_commands.ts +60 -0
  54. package/src/pm/marks.ts +169 -0
  55. package/src/pm/nodes.ts +258 -0
  56. package/src/pm/numbering.test.ts +210 -0
  57. package/src/pm/numbering_plugin.test.ts +71 -0
  58. package/src/pm/numbering_plugin.ts +96 -0
  59. package/src/pm/outline.ts +41 -0
  60. package/src/pm/page_break.test.ts +80 -0
  61. package/src/pm/page_layout.test.ts +87 -0
  62. package/src/pm/pagination_plugin.test.ts +155 -0
  63. package/src/pm/pagination_plugin.ts +590 -0
  64. package/src/pm/phase5.test.ts +271 -0
  65. package/src/pm/phase6.test.ts +215 -0
  66. package/src/pm/placeholder_plugin.ts +24 -0
  67. package/src/pm/plugins.ts +91 -0
  68. package/src/pm/pm_to_docx.ts +0 -0
  69. package/src/pm/roundtrip.test.ts +332 -0
  70. package/src/pm/schema.test.ts +188 -0
  71. package/src/pm/schema.ts +79 -0
  72. package/src/pm/search.ts +46 -0
  73. package/src/pm/table_attrs.ts +48 -0
  74. package/src/pm/table_borders.test.ts +117 -0
  75. package/src/pm/table_borders.ts +130 -0
  76. package/src/pm/table_convert.test.ts +221 -0
  77. package/src/pm/table_convert.ts +541 -0
  78. package/src/pm/table_decorations.ts +132 -0
  79. package/src/pm/table_handles.ts +163 -0
  80. package/src/pm/template_marker.ts +47 -0
  81. package/src/pm/template_plugin.ts +65 -0
  82. package/src/pm/templates.test.ts +162 -0
  83. package/src/render/clipboard.test.ts +115 -0
  84. package/src/render/clipboard.ts +200 -0
  85. package/src/render/editable_view.test.ts +173 -0
  86. package/src/render/footnotes_view.ts +94 -0
  87. package/src/render/header_footer_view.ts +95 -0
  88. package/src/render/link_mark_view.ts +26 -0
  89. package/src/render/media_resolver.ts +61 -0
  90. package/src/render/node_views.ts +296 -0
  91. package/src/render/numbering_counter.ts +149 -0
  92. package/src/render/page_chrome.test.ts +262 -0
  93. package/src/render/page_chrome.ts +343 -0
  94. package/src/render/page_styles.ts +234 -0
  95. package/src/render/paragraph_view.test.ts +162 -0
  96. package/src/render/paragraph_view.ts +141 -0
  97. package/src/render/ruler.ts +110 -0
  98. package/src/render/style_registry.ts +33 -0
  99. package/src/render/table_dom.test.ts +171 -0
  100. package/src/render/table_dom.ts +288 -0
  101. package/src/render/units.ts +18 -0
  102. package/src/render/view.test.ts +165 -0
  103. package/src/render/view.ts +607 -0
  104. package/src/roundtrip.test.ts +179 -0
  105. package/src/serialize/default_parts.ts +128 -0
  106. package/src/serialize/header_footer_pm.ts +82 -0
  107. package/src/serialize/serializer.ts +114 -0
@@ -0,0 +1,541 @@
1
+ import type { Node as PMNode } from "prosemirror-model";
2
+ import {
3
+ getAttr,
4
+ getChildren,
5
+ getTagName,
6
+ type XmlElement,
7
+ } from "@lotics/ooxml/xml";
8
+ import { docxSchema } from "./schema";
9
+
10
+ export type ConvertBlockToPM = (xml: XmlElement) => PMNode | null;
11
+
12
+ function parseIntOrNull(value: string | undefined): number | null {
13
+ if (value === undefined) return null;
14
+ const n = Number.parseInt(value, 10);
15
+ return Number.isFinite(n) ? n : null;
16
+ }
17
+
18
+ /**
19
+ * Read the `w:tblW` element's `w:type` attribute. Word recognizes "auto",
20
+ * "dxa" (fixed twentieths-of-a-point), "pct" (percentage of available
21
+ * width), and "nil". We treat anything that isn't "auto" as fixed-width
22
+ * for column-sizing purposes.
23
+ */
24
+ function readTableWidthType(
25
+ tblPr: XmlElement | null,
26
+ ): "auto" | "fixed" | "nil" | null {
27
+ if (!tblPr) return null;
28
+ for (const child of getChildren(tblPr)) {
29
+ if (getTagName(child) !== "w:tblW") continue;
30
+ const type = getAttr(child, "w:type");
31
+ if (type === "auto") return "auto";
32
+ if (type === "nil") return "nil";
33
+ return "fixed";
34
+ }
35
+ return null;
36
+ }
37
+
38
+ function parseGridWidths(tblGrid: XmlElement | null): number[] {
39
+ if (!tblGrid) return [];
40
+ const widths: number[] = [];
41
+ for (const child of getChildren(tblGrid)) {
42
+ if (getTagName(child) !== "w:gridCol") continue;
43
+ const w = parseIntOrNull(getAttr(child, "w:w"));
44
+ if (w !== null) widths.push(w);
45
+ }
46
+ return widths;
47
+ }
48
+
49
+ type CellXml = {
50
+ tcXml: XmlElement;
51
+ gridSpan: number;
52
+ vMerge: "restart" | "continue" | null;
53
+ };
54
+
55
+ function readCellXml(tc: XmlElement): CellXml {
56
+ let gridSpan = 1;
57
+ let vMerge: "restart" | "continue" | null = null;
58
+ for (const child of getChildren(tc)) {
59
+ if (getTagName(child) !== "w:tcPr") continue;
60
+ for (const propChild of getChildren(child)) {
61
+ const tag = getTagName(propChild);
62
+ if (tag === "w:gridSpan") {
63
+ const v = parseIntOrNull(getAttr(propChild, "w:val"));
64
+ if (v !== null && v > 0) gridSpan = v;
65
+ } else if (tag === "w:vMerge") {
66
+ const v = getAttr(propChild, "w:val");
67
+ vMerge = v === "restart" ? "restart" : "continue";
68
+ }
69
+ }
70
+ break;
71
+ }
72
+ return { tcXml: tc, gridSpan, vMerge };
73
+ }
74
+
75
+ type CellAttrInputs = {
76
+ colspan: number;
77
+ rowspan: number;
78
+ colwidth: number[] | null;
79
+ gridSpan: number | null;
80
+ vMerge: "restart" | null;
81
+ borders: unknown;
82
+ shading: unknown;
83
+ vAlign: unknown;
84
+ cellWidth: unknown;
85
+ background: string | null;
86
+ };
87
+
88
+ function readBorder(el: XmlElement | null): unknown {
89
+ if (!el) return null;
90
+ const styleVal = getAttr(el, "w:val") ?? "single";
91
+ const colorRaw = getAttr(el, "w:color");
92
+ const color = !colorRaw || colorRaw === "auto" ? null : colorRaw;
93
+ const sizeAttr = getAttr(el, "w:sz");
94
+ const sizePts = sizeAttr
95
+ ? Number.parseInt(sizeAttr, 10) / 8
96
+ : 0.5;
97
+ return { style: styleVal, color, sizePts: Number.isFinite(sizePts) ? sizePts : 0.5 };
98
+ }
99
+
100
+ function readBorders(tcPr: XmlElement | null): unknown {
101
+ if (!tcPr) return null;
102
+ let borders: XmlElement | null = null;
103
+ for (const child of getChildren(tcPr)) {
104
+ if (getTagName(child) === "w:tcBorders") {
105
+ borders = child;
106
+ break;
107
+ }
108
+ }
109
+ if (!borders) return null;
110
+ const out: Record<string, unknown> = {};
111
+ for (const child of getChildren(borders)) {
112
+ const tag = getTagName(child);
113
+ if (tag === "w:top") out.top = readBorder(child);
114
+ else if (tag === "w:bottom") out.bottom = readBorder(child);
115
+ else if (tag === "w:left") out.left = readBorder(child);
116
+ else if (tag === "w:right") out.right = readBorder(child);
117
+ }
118
+ return Object.keys(out).length > 0 ? out : null;
119
+ }
120
+
121
+ function readShading(tcPr: XmlElement | null): unknown {
122
+ if (!tcPr) return null;
123
+ for (const child of getChildren(tcPr)) {
124
+ if (getTagName(child) !== "w:shd") continue;
125
+ const fill = getAttr(child, "w:fill");
126
+ if (!fill) return null;
127
+ const pattern = getAttr(child, "w:val") ?? undefined;
128
+ const colorRaw = getAttr(child, "w:color");
129
+ const color = !colorRaw || colorRaw === "auto" ? null : colorRaw;
130
+ return pattern ? { fill, pattern, color } : { fill, color };
131
+ }
132
+ return null;
133
+ }
134
+
135
+ function readVAlign(tcPr: XmlElement | null): unknown {
136
+ if (!tcPr) return null;
137
+ for (const child of getChildren(tcPr)) {
138
+ if (getTagName(child) !== "w:vAlign") continue;
139
+ const v = getAttr(child, "w:val");
140
+ if (v === "top" || v === "center" || v === "bottom") return v;
141
+ }
142
+ return null;
143
+ }
144
+
145
+ function readCellWidth(tcPr: XmlElement | null): unknown {
146
+ if (!tcPr) return null;
147
+ for (const child of getChildren(tcPr)) {
148
+ if (getTagName(child) !== "w:tcW") continue;
149
+ const value = parseIntOrNull(getAttr(child, "w:w"));
150
+ const typeRaw = getAttr(child, "w:type") ?? "dxa";
151
+ if (value === null) return null;
152
+ if (typeRaw === "dxa" || typeRaw === "pct" || typeRaw === "auto" || typeRaw === "nil") {
153
+ return { value, type: typeRaw };
154
+ }
155
+ return { value, type: "dxa" };
156
+ }
157
+ return null;
158
+ }
159
+
160
+ function tcPrOf(tc: XmlElement): XmlElement | null {
161
+ for (const child of getChildren(tc)) {
162
+ if (getTagName(child) === "w:tcPr") return child;
163
+ }
164
+ return null;
165
+ }
166
+
167
+ function buildCellAttrs(
168
+ cell: CellXml,
169
+ rowspan: number,
170
+ colwidth: number[] | null,
171
+ ): CellAttrInputs {
172
+ const tcPr = tcPrOf(cell.tcXml);
173
+ return {
174
+ colspan: cell.gridSpan,
175
+ rowspan,
176
+ colwidth,
177
+ gridSpan: cell.gridSpan > 1 ? cell.gridSpan : null,
178
+ vMerge: cell.vMerge === "restart" && rowspan > 1 ? "restart" : null,
179
+ borders: readBorders(tcPr),
180
+ shading: readShading(tcPr),
181
+ vAlign: readVAlign(tcPr),
182
+ cellWidth: readCellWidth(tcPr),
183
+ background: null,
184
+ };
185
+ }
186
+
187
+ function cellContentNodes(
188
+ cell: XmlElement,
189
+ convertBlock: ConvertBlockToPM,
190
+ ): PMNode[] {
191
+ const out: PMNode[] = [];
192
+ for (const child of getChildren(cell)) {
193
+ const tag = getTagName(child);
194
+ if (tag === "w:tcPr") continue;
195
+ const node = convertBlock(child);
196
+ if (node) out.push(node);
197
+ }
198
+ if (out.length === 0) {
199
+ out.push(docxSchema.nodes.paragraph.create());
200
+ }
201
+ return out;
202
+ }
203
+
204
+ export function xmlToTableNode(
205
+ tableXml: XmlElement,
206
+ convertBlock: ConvertBlockToPM,
207
+ ): PMNode | null {
208
+ if (getTagName(tableXml) !== "w:tbl") return null;
209
+
210
+ let tblPr: XmlElement | null = null;
211
+ let tblGrid: XmlElement | null = null;
212
+ const trXmls: XmlElement[] = [];
213
+ for (const child of getChildren(tableXml)) {
214
+ const tag = getTagName(child);
215
+ if (tag === "w:tblPr") tblPr = child;
216
+ else if (tag === "w:tblGrid") tblGrid = child;
217
+ else if (tag === "w:tr") trXmls.push(child);
218
+ }
219
+
220
+ const rawGridWidths = parseGridWidths(tblGrid);
221
+ // OOXML `tblPr > w:tblW` describes the table's overall width. "auto"
222
+ // and "nil" (no width specified) both mean "shrink/grow to content" —
223
+ // Word renders both at content's natural width. We drop colwidths so
224
+ // CSS `table-layout: auto` sizes cells. "dxa" and "pct" are honored
225
+ // via the gridCol widths.
226
+ const tableWidthType = readTableWidthType(tblPr);
227
+ const gridWidths =
228
+ tableWidthType === "auto" || tableWidthType === "nil"
229
+ ? []
230
+ : rawGridWidths;
231
+
232
+ const rowsCells: CellXml[][] = trXmls.map((tr) => {
233
+ const cells: CellXml[] = [];
234
+ for (const child of getChildren(tr)) {
235
+ if (getTagName(child) !== "w:tc") continue;
236
+ cells.push(readCellXml(child));
237
+ }
238
+ return cells;
239
+ });
240
+
241
+ const rowSpans: number[][] = rowsCells.map((row) => row.map(() => 1));
242
+
243
+ for (let r = 0; r < rowsCells.length; r++) {
244
+ let colIdx = 0;
245
+ for (let c = 0; c < rowsCells[r].length; c++) {
246
+ const cell = rowsCells[r][c];
247
+ if (cell.vMerge === "restart") {
248
+ let span = 1;
249
+ const startCol = colIdx;
250
+ for (let nr = r + 1; nr < rowsCells.length; nr++) {
251
+ let nextColIdx = 0;
252
+ let matched = false;
253
+ for (let nc = 0; nc < rowsCells[nr].length; nc++) {
254
+ const nextCell = rowsCells[nr][nc];
255
+ if (nextColIdx === startCol && nextCell.vMerge === "continue") {
256
+ span += 1;
257
+ matched = true;
258
+ break;
259
+ }
260
+ nextColIdx += nextCell.gridSpan;
261
+ }
262
+ if (!matched) break;
263
+ }
264
+ rowSpans[r][c] = span;
265
+ }
266
+ colIdx += cell.gridSpan;
267
+ }
268
+ }
269
+
270
+ const tableRowNodes: PMNode[] = [];
271
+ for (let r = 0; r < rowsCells.length; r++) {
272
+ let colIdx = 0;
273
+ const cellNodes: PMNode[] = [];
274
+ for (let c = 0; c < rowsCells[r].length; c++) {
275
+ const cell = rowsCells[r][c];
276
+ if (cell.vMerge === "continue") {
277
+ colIdx += cell.gridSpan;
278
+ continue;
279
+ }
280
+
281
+ const startCol = colIdx;
282
+ const endCol = colIdx + cell.gridSpan;
283
+ const colwidth =
284
+ gridWidths.length >= endCol
285
+ ? gridWidths.slice(startCol, endCol)
286
+ : null;
287
+
288
+ const attrs = buildCellAttrs(cell, rowSpans[r][c], colwidth);
289
+ const content = cellContentNodes(cell.tcXml, convertBlock);
290
+ cellNodes.push(
291
+ docxSchema.nodes.table_cell.create(attrs, content),
292
+ );
293
+ colIdx += cell.gridSpan;
294
+ }
295
+ if (cellNodes.length === 0) {
296
+ cellNodes.push(
297
+ docxSchema.nodes.table_cell.create(null, [
298
+ docxSchema.nodes.paragraph.create(),
299
+ ]),
300
+ );
301
+ }
302
+ tableRowNodes.push(
303
+ docxSchema.nodes.table_row.create(
304
+ { rowProperties: null },
305
+ cellNodes,
306
+ ),
307
+ );
308
+ }
309
+
310
+ return docxSchema.nodes.table.create(
311
+ {
312
+ tableProperties: tblPr ?? null,
313
+ columnGrid: tblGrid ?? null,
314
+ },
315
+ tableRowNodes,
316
+ );
317
+ }
318
+
319
+ export type PMNodeToBlockXml = (node: PMNode) => XmlElement[];
320
+
321
+ function buildBordersXml(borders: unknown): XmlElement | null {
322
+ if (!borders || typeof borders !== "object") return null;
323
+ const sides: Array<["w:top" | "w:right" | "w:bottom" | "w:left", string]> = [
324
+ ["w:top", "top"],
325
+ ["w:right", "right"],
326
+ ["w:bottom", "bottom"],
327
+ ["w:left", "left"],
328
+ ];
329
+ const children: XmlElement[] = [];
330
+ const obj = borders as Record<string, unknown>;
331
+ for (const [tag, key] of sides) {
332
+ const side = obj[key];
333
+ if (!side || typeof side !== "object") continue;
334
+ const sideObj = side as Record<string, unknown>;
335
+ const style =
336
+ typeof sideObj.style === "string" ? sideObj.style : "single";
337
+ const color =
338
+ typeof sideObj.color === "string" ? sideObj.color : "auto";
339
+ const sizePts =
340
+ typeof sideObj.sizePts === "number" ? sideObj.sizePts : 0.5;
341
+ children.push({
342
+ [tag]: [],
343
+ ":@": {
344
+ "@_w:val": style,
345
+ "@_w:sz": String(Math.round(sizePts * 8)),
346
+ "@_w:space": "0",
347
+ "@_w:color": color,
348
+ },
349
+ });
350
+ }
351
+ if (children.length === 0) return null;
352
+ return { "w:tcBorders": children };
353
+ }
354
+
355
+ function buildShadingXml(shading: unknown): XmlElement | null {
356
+ if (!shading || typeof shading !== "object") return null;
357
+ const obj = shading as Record<string, unknown>;
358
+ if (typeof obj.fill !== "string") return null;
359
+ const attrs: Record<string, string> = {
360
+ "@_w:val": typeof obj.pattern === "string" ? obj.pattern : "clear",
361
+ "@_w:fill": obj.fill,
362
+ };
363
+ if (typeof obj.color === "string" && obj.color) {
364
+ attrs["@_w:color"] = obj.color;
365
+ } else {
366
+ attrs["@_w:color"] = "auto";
367
+ }
368
+ return { "w:shd": [], ":@": attrs };
369
+ }
370
+
371
+ function buildCellWidthXml(cellWidth: unknown): XmlElement | null {
372
+ if (!cellWidth || typeof cellWidth !== "object") return null;
373
+ const obj = cellWidth as Record<string, unknown>;
374
+ if (typeof obj.value !== "number") return null;
375
+ const type =
376
+ obj.type === "dxa" || obj.type === "pct" || obj.type === "auto" || obj.type === "nil"
377
+ ? obj.type
378
+ : "dxa";
379
+ return {
380
+ "w:tcW": [],
381
+ ":@": { "@_w:w": String(obj.value), "@_w:type": type },
382
+ };
383
+ }
384
+
385
+ function buildTcPrXml(cell: PMNode): XmlElement | null {
386
+ const children: XmlElement[] = [];
387
+ const cw = buildCellWidthXml(cell.attrs.cellWidth);
388
+ if (cw) children.push(cw);
389
+ const span = cell.attrs.colspan as number | undefined;
390
+ if (typeof span === "number" && span > 1) {
391
+ children.push({ "w:gridSpan": [], ":@": { "@_w:val": String(span) } });
392
+ }
393
+ const vMergeKind = cell.attrs.vMerge as string | null | undefined;
394
+ if (vMergeKind === "restart") {
395
+ children.push({ "w:vMerge": [], ":@": { "@_w:val": "restart" } });
396
+ } else if (vMergeKind === "continue") {
397
+ children.push({ "w:vMerge": [] });
398
+ }
399
+ const borders = buildBordersXml(cell.attrs.borders);
400
+ if (borders) children.push(borders);
401
+ const shading = buildShadingXml(cell.attrs.shading);
402
+ if (shading) children.push(shading);
403
+ const vAlign = cell.attrs.vAlign as string | null | undefined;
404
+ if (vAlign === "top" || vAlign === "center" || vAlign === "bottom") {
405
+ children.push({ "w:vAlign": [], ":@": { "@_w:val": vAlign } });
406
+ }
407
+ return children.length > 0 ? { "w:tcPr": children } : null;
408
+ }
409
+
410
+ type RowCell = {
411
+ origin: PMNode;
412
+ colspan: number;
413
+ rowspan: number;
414
+ rowOffset: number;
415
+ };
416
+
417
+ type GridSlot = RowCell | null;
418
+
419
+ function buildLogicalGrid(table: PMNode): GridSlot[][] {
420
+ const rows: GridSlot[][] = [];
421
+ table.forEach((row, _offset, rowIdx) => {
422
+ if (row.type.name !== "table_row") return;
423
+ if (!rows[rowIdx]) rows[rowIdx] = [];
424
+ let colIdx = 0;
425
+ row.forEach((cell) => {
426
+ if (cell.type.name !== "table_cell" && cell.type.name !== "table_header") return;
427
+ while (rows[rowIdx][colIdx]) colIdx += 1;
428
+ const colspan = (cell.attrs.colspan as number | undefined) ?? 1;
429
+ const rowspan = (cell.attrs.rowspan as number | undefined) ?? 1;
430
+ for (let dr = 0; dr < rowspan; dr++) {
431
+ const r = rowIdx + dr;
432
+ if (!rows[r]) rows[r] = [];
433
+ for (let dc = 0; dc < colspan; dc++) {
434
+ rows[r][colIdx + dc] = { origin: cell, colspan, rowspan, rowOffset: dr };
435
+ }
436
+ }
437
+ colIdx += colspan;
438
+ });
439
+ });
440
+ return rows;
441
+ }
442
+
443
+ function buildContinuationCellXml(originCell: PMNode): XmlElement {
444
+ const tcPr: XmlElement = { "w:tcPr": [{ "w:vMerge": [] }] };
445
+ const colspan = (originCell.attrs.colspan as number | undefined) ?? 1;
446
+ if (colspan > 1) {
447
+ (tcPr["w:tcPr"] as XmlElement[]).push({
448
+ "w:gridSpan": [],
449
+ ":@": { "@_w:val": String(colspan) },
450
+ });
451
+ }
452
+ return { "w:tc": [tcPr, { "w:p": [] }] };
453
+ }
454
+
455
+ function buildOriginCellXml(
456
+ cell: PMNode,
457
+ isVMergeOrigin: boolean,
458
+ convertParagraphToXml: PMNodeToBlockXml,
459
+ ): XmlElement {
460
+ const tcChildren: XmlElement[] = [];
461
+
462
+ const tcPr = buildTcPrXml(cell);
463
+ if (tcPr) {
464
+ if (isVMergeOrigin) {
465
+ const tcPrChildren = tcPr["w:tcPr"] as XmlElement[];
466
+ const hasVMerge = tcPrChildren.some((c) => getTagName(c) === "w:vMerge");
467
+ if (!hasVMerge) {
468
+ tcPrChildren.push({
469
+ "w:vMerge": [],
470
+ ":@": { "@_w:val": "restart" },
471
+ });
472
+ }
473
+ }
474
+ tcChildren.push(tcPr);
475
+ } else if (isVMergeOrigin) {
476
+ tcChildren.push({
477
+ "w:tcPr": [{ "w:vMerge": [], ":@": { "@_w:val": "restart" } }],
478
+ });
479
+ }
480
+
481
+ cell.forEach((blockNode) => {
482
+ if (blockNode.type.name === "table") {
483
+ tcChildren.push(tableNodeToXml(blockNode, convertParagraphToXml));
484
+ return;
485
+ }
486
+ const blockXmls = convertParagraphToXml(blockNode);
487
+ for (const xml of blockXmls) tcChildren.push(xml);
488
+ });
489
+
490
+ if (tcChildren.every((c) => getTagName(c) === "w:tcPr")) {
491
+ tcChildren.push({ "w:p": [] });
492
+ }
493
+
494
+ return { "w:tc": tcChildren };
495
+ }
496
+
497
+ export function tableNodeToXml(
498
+ table: PMNode,
499
+ convertParagraphToXml: PMNodeToBlockXml,
500
+ ): XmlElement {
501
+ const children: XmlElement[] = [];
502
+
503
+ const tblPr = table.attrs.tableProperties as XmlElement | null;
504
+ if (tblPr) children.push(tblPr);
505
+
506
+ const tblGrid = table.attrs.columnGrid as XmlElement | null;
507
+ if (tblGrid) children.push(tblGrid);
508
+
509
+ const grid = buildLogicalGrid(table);
510
+
511
+ for (let r = 0; r < grid.length; r++) {
512
+ const row = grid[r];
513
+ const trChildren: XmlElement[] = [];
514
+ let c = 0;
515
+ while (c < row.length) {
516
+ const slot = row[c];
517
+ if (!slot) {
518
+ c += 1;
519
+ continue;
520
+ }
521
+ const isOriginRow = slot.rowOffset === 0;
522
+ const prevSlot = c > 0 ? row[c - 1] : null;
523
+ const isFirstColOfCell = !prevSlot || prevSlot.origin !== slot.origin;
524
+ if (!isFirstColOfCell) {
525
+ c += 1;
526
+ continue;
527
+ }
528
+ if (isOriginRow) {
529
+ trChildren.push(
530
+ buildOriginCellXml(slot.origin, slot.rowspan > 1, convertParagraphToXml),
531
+ );
532
+ } else {
533
+ trChildren.push(buildContinuationCellXml(slot.origin));
534
+ }
535
+ c += slot.colspan;
536
+ }
537
+ children.push({ "w:tr": trChildren.length > 0 ? trChildren : [{ "w:tc": [{ "w:p": [] }] }] });
538
+ }
539
+
540
+ return { "w:tbl": children };
541
+ }
@@ -0,0 +1,132 @@
1
+ import { Plugin, PluginKey } from "prosemirror-state";
2
+ import { Decoration, DecorationSet } from "prosemirror-view";
3
+ import type { Node as PMNode } from "prosemirror-model";
4
+ import type { XmlElement } from "@lotics/ooxml/xml";
5
+ import {
6
+ borderToCss,
7
+ DEFAULT_TABLE_BORDERS,
8
+ tableBordersFromTblPr,
9
+ type Border,
10
+ type TableBorders,
11
+ } from "./table_borders";
12
+ import type {
13
+ TableCellBorders,
14
+ TableCellShading,
15
+ VAlignKind,
16
+ } from "./table_attrs";
17
+
18
+ /**
19
+ * Decoration plugin that paints OOXML-aware table styling onto cells.
20
+ *
21
+ * The plugin walks every `table` node in the doc and emits a `Decoration.node`
22
+ * per cell with an inline `style` carrying border / background-color /
23
+ * vertical-align values resolved against:
24
+ * - the cell's own `borders` / `shading` / `vAlign` attrs
25
+ * - the table's `tableProperties` (parsed `<w:tblBorders>`)
26
+ * - Word's TableGrid default (single 0.5pt black) when neither specifies
27
+ *
28
+ * Decorations leave the editor's contentDOM tree untouched — `prosemirror-
29
+ * tables` continues to own the &lt;table&gt; / &lt;tr&gt; / &lt;td&gt; DOM.
30
+ * That avoids the NodeView / contentDOM conflicts that crash the editor.
31
+ *
32
+ * Border resolution order per cell edge:
33
+ * cell.borders.top > table.tblBorders.top (first row)
34
+ * > table.tblBorders.insideH (interior row)
35
+ * cell.borders.left > table.tblBorders.left (first col)
36
+ * > table.tblBorders.insideV (interior col)
37
+ * …mirror for bottom / right.
38
+ */
39
+ export function tableDecorationsPlugin(): Plugin {
40
+ return new Plugin({
41
+ key: new PluginKey("docx-table-decorations"),
42
+ props: {
43
+ decorations(state) {
44
+ const decorations: Decoration[] = [];
45
+ state.doc.descendants((node, pos) => {
46
+ if (node.type.name !== "table") return true;
47
+ collectTableDecorations(node, pos, decorations);
48
+ return false;
49
+ });
50
+ return DecorationSet.create(state.doc, decorations);
51
+ },
52
+ },
53
+ });
54
+ }
55
+
56
+ function collectTableDecorations(
57
+ table: PMNode,
58
+ tablePos: number,
59
+ out: Decoration[],
60
+ ): void {
61
+ const tblPr = table.attrs.tableProperties as XmlElement | null;
62
+ const parsed = tableBordersFromTblPr(tblPr);
63
+ const tblBorders =
64
+ Object.keys(parsed).length > 0 ? parsed : DEFAULT_TABLE_BORDERS;
65
+ const rowCount = table.childCount;
66
+
67
+ // PM table layout: table → row → cell → block content. Walk by index.
68
+ let rowOffset = 1; // skip the table opening token
69
+ table.forEach((row, _r, rowIndex) => {
70
+ const isFirstRow = rowIndex === 0;
71
+ const isLastRow = rowIndex === rowCount - 1;
72
+ const colCount = row.childCount;
73
+ let colOffset = rowOffset + 1; // skip the row opening token
74
+ row.forEach((cell, _c, colIndex) => {
75
+ const isFirstCol = colIndex === 0;
76
+ const isLastCol = colIndex === colCount - 1;
77
+ const cellAbsPos = tablePos + colOffset;
78
+
79
+ const cellBorders = (cell.attrs.borders as TableCellBorders | null) ?? null;
80
+ const shading = cell.attrs.shading as TableCellShading | null;
81
+ const background = cell.attrs.background as string | null;
82
+ const vAlign = cell.attrs.vAlign as VAlignKind | null;
83
+
84
+ const top =
85
+ cellBorders?.top ??
86
+ (isFirstRow ? tblBorders.top : tblBorders.insideH);
87
+ const bottom =
88
+ cellBorders?.bottom ??
89
+ (isLastRow ? tblBorders.bottom : tblBorders.insideH);
90
+ const left =
91
+ cellBorders?.left ??
92
+ (isFirstCol ? tblBorders.left : tblBorders.insideV);
93
+ const right =
94
+ cellBorders?.right ??
95
+ (isLastCol ? tblBorders.right : tblBorders.insideV);
96
+
97
+ const styleParts: string[] = [];
98
+ if (top) styleParts.push(`border-top: ${borderToCss(top as Border)}`);
99
+ if (bottom) styleParts.push(`border-bottom: ${borderToCss(bottom as Border)}`);
100
+ if (left) styleParts.push(`border-left: ${borderToCss(left as Border)}`);
101
+ if (right) styleParts.push(`border-right: ${borderToCss(right as Border)}`);
102
+
103
+ if (shading?.fill && shading.fill !== "auto") {
104
+ styleParts.push(`background-color: #${shading.fill}`);
105
+ } else if (background) {
106
+ styleParts.push(`background-color: ${background}`);
107
+ }
108
+
109
+ if (vAlign) {
110
+ styleParts.push(
111
+ `vertical-align: ${vAlign === "center" ? "middle" : vAlign}`,
112
+ );
113
+ }
114
+
115
+ if (styleParts.length > 0) {
116
+ out.push(
117
+ Decoration.node(cellAbsPos, cellAbsPos + cell.nodeSize, {
118
+ style: styleParts.join("; "),
119
+ }),
120
+ );
121
+ }
122
+ colOffset += cell.nodeSize;
123
+ });
124
+ rowOffset += row.nodeSize;
125
+ });
126
+ void [tblBorders];
127
+ }
128
+
129
+ // Suppress unused-warning — `TableBorders` is used as an *implicit* return
130
+ // type via tableBordersFromTblPr; keep the explicit re-export so external
131
+ // callers can construct expected fixtures.
132
+ export type { TableBorders };