@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,697 @@
1
+ import type { Command, EditorState, Transaction } from "prosemirror-state";
2
+ import type { Node as PMNode } from "prosemirror-model";
3
+ import {
4
+ EMPTY_PARAGRAPH_PROPERTIES,
5
+ EMPTY_RUN_PROPERTIES,
6
+ type ParagraphProperties,
7
+ } from "../model/properties";
8
+ import {
9
+ resolveParagraphProperties,
10
+ resolveRunProperties,
11
+ } from "../model/style_resolution";
12
+ import type { StyleTable } from "../model/style_table";
13
+ import type {
14
+ Orientation,
15
+ PageMargins,
16
+ PageSize,
17
+ SectionProperties,
18
+ } from "../model/sections";
19
+
20
+ function paragraphRange(state: EditorState): { from: number; to: number } {
21
+ const { from, to } = state.selection;
22
+ const $from = state.doc.resolve(from);
23
+ const startBlock = $from.before($from.depth);
24
+ const $to = state.doc.resolve(to);
25
+ const endBlock = $to.after($to.depth);
26
+ return { from: startBlock, to: endBlock };
27
+ }
28
+
29
+ function forEachParagraphInRange(
30
+ state: EditorState,
31
+ visit: (node: PMNode, pos: number) => void,
32
+ ): void {
33
+ const { from, to } = paragraphRange(state);
34
+ state.doc.nodesBetween(from, to, (node, pos) => {
35
+ if (node.type.name === "paragraph") {
36
+ visit(node, pos);
37
+ return false;
38
+ }
39
+ return true;
40
+ });
41
+ }
42
+
43
+ function patchParagraphProperties(
44
+ tr: Transaction,
45
+ pos: number,
46
+ node: PMNode,
47
+ patch: (current: ParagraphProperties) => ParagraphProperties,
48
+ styleTable: StyleTable | null,
49
+ ): void {
50
+ const current =
51
+ (node.attrs.properties as ParagraphProperties | null) ??
52
+ { ...EMPTY_PARAGRAPH_PROPERTIES };
53
+ const next = patch(current);
54
+ const resolvedProperties = styleTable
55
+ ? resolveParagraphProperties(styleTable, next)
56
+ : null;
57
+ const resolvedBaseRun = styleTable
58
+ ? resolveRunProperties(styleTable, next.styleId, { ...EMPTY_RUN_PROPERTIES })
59
+ : null;
60
+ tr.setNodeMarkup(pos, undefined, {
61
+ ...node.attrs,
62
+ properties: next,
63
+ resolvedProperties,
64
+ resolvedBaseRun,
65
+ });
66
+ }
67
+
68
+ export type CommandContext = {
69
+ styleTable?: StyleTable | null;
70
+ };
71
+
72
+ export function getActiveParagraphProperties(
73
+ state: EditorState,
74
+ ): ParagraphProperties | null {
75
+ const $from = state.doc.resolve(state.selection.from);
76
+ for (let depth = $from.depth; depth >= 0; depth--) {
77
+ const node = $from.node(depth);
78
+ if (node.type.name === "paragraph") {
79
+ return (node.attrs.properties as ParagraphProperties | null) ?? null;
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ export function setParagraphStyle(
86
+ styleId: string | null,
87
+ ctx: CommandContext = {},
88
+ ): Command {
89
+ return (state, dispatch) => {
90
+ let touched = false;
91
+ const tr = state.tr;
92
+ forEachParagraphInRange(state, (node, pos) => {
93
+ patchParagraphProperties(
94
+ tr,
95
+ pos,
96
+ node,
97
+ (cur) => ({ ...cur, styleId }),
98
+ ctx.styleTable ?? null,
99
+ );
100
+ touched = true;
101
+ });
102
+ if (!touched) return false;
103
+ if (dispatch) dispatch(tr);
104
+ return true;
105
+ };
106
+ }
107
+
108
+ export function applyHeading(
109
+ level: number,
110
+ ctx: CommandContext = {},
111
+ ): Command {
112
+ if (level < 1 || level > 6) {
113
+ return () => false;
114
+ }
115
+ return setParagraphStyle(`Heading${level}`, ctx);
116
+ }
117
+
118
+ export function clearParagraphStyle(ctx: CommandContext = {}): Command {
119
+ return setParagraphStyle(null, ctx);
120
+ }
121
+
122
+ export function setParagraphAlignment(
123
+ alignment: ParagraphProperties["alignment"],
124
+ ctx: CommandContext = {},
125
+ ): Command {
126
+ return (state, dispatch) => {
127
+ let touched = false;
128
+ const tr = state.tr;
129
+ forEachParagraphInRange(state, (node, pos) => {
130
+ patchParagraphProperties(
131
+ tr,
132
+ pos,
133
+ node,
134
+ (cur) => ({ ...cur, alignment }),
135
+ ctx.styleTable ?? null,
136
+ );
137
+ touched = true;
138
+ });
139
+ if (!touched) return false;
140
+ if (dispatch) dispatch(tr);
141
+ return true;
142
+ };
143
+ }
144
+
145
+ export function setParagraphNumbering(
146
+ numbering: { numId: number; ilvl: number } | null,
147
+ ctx: CommandContext = {},
148
+ ): Command {
149
+ return (state, dispatch) => {
150
+ let touched = false;
151
+ const tr = state.tr;
152
+ forEachParagraphInRange(state, (node, pos) => {
153
+ patchParagraphProperties(
154
+ tr,
155
+ pos,
156
+ node,
157
+ (cur) => ({ ...cur, numbering }),
158
+ ctx.styleTable ?? null,
159
+ );
160
+ touched = true;
161
+ });
162
+ if (!touched) return false;
163
+ if (dispatch) dispatch(tr);
164
+ return true;
165
+ };
166
+ }
167
+
168
+ const DEFAULT_BULLET_NUM_ID = 1;
169
+ const DEFAULT_NUMBERED_NUM_ID = 2;
170
+
171
+ export function toggleBulletList(ctx: CommandContext = {}): Command {
172
+ return toggleListImpl(DEFAULT_BULLET_NUM_ID, ctx);
173
+ }
174
+
175
+ export function toggleNumberedList(ctx: CommandContext = {}): Command {
176
+ return toggleListImpl(DEFAULT_NUMBERED_NUM_ID, ctx);
177
+ }
178
+
179
+ function toggleListImpl(numId: number, ctx: CommandContext): Command {
180
+ return (state, dispatch) => {
181
+ let touched = false;
182
+ const tr = state.tr;
183
+ let allInList = true;
184
+ forEachParagraphInRange(state, (node) => {
185
+ const props = node.attrs.properties as ParagraphProperties | null;
186
+ const numbering = props?.numbering ?? null;
187
+ if (!numbering || numbering.numId !== numId) allInList = false;
188
+ });
189
+ forEachParagraphInRange(state, (node, pos) => {
190
+ patchParagraphProperties(
191
+ tr,
192
+ pos,
193
+ node,
194
+ (cur) => ({
195
+ ...cur,
196
+ numbering: allInList ? null : { numId, ilvl: cur.numbering?.ilvl ?? 0 },
197
+ }),
198
+ ctx.styleTable ?? null,
199
+ );
200
+ touched = true;
201
+ });
202
+ if (!touched) return false;
203
+ if (dispatch) dispatch(tr);
204
+ return true;
205
+ };
206
+ }
207
+
208
+ export function increaseListIndent(ctx: CommandContext = {}): Command {
209
+ return (state, dispatch) => {
210
+ let touched = false;
211
+ const tr = state.tr;
212
+ forEachParagraphInRange(state, (node, pos) => {
213
+ const props = node.attrs.properties as ParagraphProperties | null;
214
+ const numbering = props?.numbering;
215
+ if (!numbering) return;
216
+ const nextLevel = Math.min(numbering.ilvl + 1, 8);
217
+ if (nextLevel === numbering.ilvl) return;
218
+ patchParagraphProperties(
219
+ tr,
220
+ pos,
221
+ node,
222
+ (cur) => ({
223
+ ...cur,
224
+ numbering: { numId: numbering.numId, ilvl: nextLevel },
225
+ }),
226
+ ctx.styleTable ?? null,
227
+ );
228
+ touched = true;
229
+ });
230
+ if (!touched) return false;
231
+ if (dispatch) dispatch(tr);
232
+ return true;
233
+ };
234
+ }
235
+
236
+ // ── Spacing & indentation commands ───────────────────────────────────────
237
+
238
+ const POINT_TO_TWIPS = 20;
239
+ const TAB_TWIPS = 720; // 0.5"
240
+
241
+ export function setLineSpacing(
242
+ multiplier: number,
243
+ ctx: CommandContext = {},
244
+ ): Command {
245
+ return (state, dispatch) => {
246
+ let touched = false;
247
+ const tr = state.tr;
248
+ forEachParagraphInRange(state, (node, pos) => {
249
+ patchParagraphProperties(
250
+ tr,
251
+ pos,
252
+ node,
253
+ (cur) => ({
254
+ ...cur,
255
+ spacing: {
256
+ before: cur.spacing?.before ?? null,
257
+ after: cur.spacing?.after ?? null,
258
+ line: Math.round(240 * multiplier),
259
+ lineRule: "auto",
260
+ },
261
+ }),
262
+ ctx.styleTable ?? null,
263
+ );
264
+ touched = true;
265
+ });
266
+ if (!touched) return false;
267
+ if (dispatch) dispatch(tr);
268
+ return true;
269
+ };
270
+ }
271
+
272
+ export function setParagraphSpacing(
273
+ before: number | null,
274
+ after: number | null,
275
+ ctx: CommandContext = {},
276
+ ): Command {
277
+ return (state, dispatch) => {
278
+ let touched = false;
279
+ const tr = state.tr;
280
+ forEachParagraphInRange(state, (node, pos) => {
281
+ patchParagraphProperties(
282
+ tr,
283
+ pos,
284
+ node,
285
+ (cur) => ({
286
+ ...cur,
287
+ spacing: {
288
+ before: before === null ? null : before * POINT_TO_TWIPS,
289
+ after: after === null ? null : after * POINT_TO_TWIPS,
290
+ line: cur.spacing?.line ?? null,
291
+ lineRule: cur.spacing?.lineRule ?? null,
292
+ },
293
+ }),
294
+ ctx.styleTable ?? null,
295
+ );
296
+ touched = true;
297
+ });
298
+ if (!touched) return false;
299
+ if (dispatch) dispatch(tr);
300
+ return true;
301
+ };
302
+ }
303
+
304
+ function applyIndentDelta(
305
+ delta: number,
306
+ ctx: CommandContext,
307
+ ): Command {
308
+ return (state, dispatch) => {
309
+ let touched = false;
310
+ const tr = state.tr;
311
+ forEachParagraphInRange(state, (node, pos) => {
312
+ patchParagraphProperties(
313
+ tr,
314
+ pos,
315
+ node,
316
+ (cur) => {
317
+ const baseLeft = cur.indent?.left ?? 0;
318
+ const nextLeft = Math.max(0, baseLeft + delta);
319
+ return {
320
+ ...cur,
321
+ indent: {
322
+ left: nextLeft,
323
+ right: cur.indent?.right ?? null,
324
+ firstLine: cur.indent?.firstLine ?? null,
325
+ hanging: cur.indent?.hanging ?? null,
326
+ },
327
+ };
328
+ },
329
+ ctx.styleTable ?? null,
330
+ );
331
+ touched = true;
332
+ });
333
+ if (!touched) return false;
334
+ if (dispatch) dispatch(tr);
335
+ return true;
336
+ };
337
+ }
338
+
339
+ export function increaseIndent(ctx: CommandContext = {}): Command {
340
+ return applyIndentDelta(TAB_TWIPS, ctx);
341
+ }
342
+
343
+ export function decreaseIndent(ctx: CommandContext = {}): Command {
344
+ return applyIndentDelta(-TAB_TWIPS, ctx);
345
+ }
346
+
347
+ export function decreaseListIndent(ctx: CommandContext = {}): Command {
348
+ return (state, dispatch) => {
349
+ let touched = false;
350
+ const tr = state.tr;
351
+ forEachParagraphInRange(state, (node, pos) => {
352
+ const props = node.attrs.properties as ParagraphProperties | null;
353
+ const numbering = props?.numbering;
354
+ if (!numbering) return;
355
+ if (numbering.ilvl === 0) return;
356
+ patchParagraphProperties(
357
+ tr,
358
+ pos,
359
+ node,
360
+ (cur) => ({
361
+ ...cur,
362
+ numbering: { numId: numbering.numId, ilvl: numbering.ilvl - 1 },
363
+ }),
364
+ ctx.styleTable ?? null,
365
+ );
366
+ touched = true;
367
+ });
368
+ if (!touched) return false;
369
+ if (dispatch) dispatch(tr);
370
+ return true;
371
+ };
372
+ }
373
+
374
+ // ── Page layout commands ──────────────────────────────────────────────────
375
+
376
+ function findSectionBreakAfter(
377
+ state: EditorState,
378
+ fromPos: number,
379
+ ): { node: PMNode; pos: number } | null {
380
+ let found: { node: PMNode; pos: number } | null = null;
381
+ state.doc.descendants((node, pos) => {
382
+ if (found) return false;
383
+ if (node.type.name !== "section_break") return true;
384
+ if (pos < fromPos) return false;
385
+ found = { node, pos };
386
+ return false;
387
+ });
388
+ if (found) return found;
389
+ state.doc.descendants((node, pos) => {
390
+ if (found) return false;
391
+ if (node.type.name === "section_break") {
392
+ found = { node, pos };
393
+ }
394
+ return false;
395
+ });
396
+ return found;
397
+ }
398
+
399
+ function patchSectionProperties(
400
+ tr: Transaction,
401
+ pos: number,
402
+ node: PMNode,
403
+ patch: (current: SectionProperties) => SectionProperties,
404
+ ): void {
405
+ const current = node.attrs.properties as SectionProperties;
406
+ const next = patch(current);
407
+ tr.setNodeMarkup(pos, undefined, { ...node.attrs, properties: next });
408
+ }
409
+
410
+ export function setPageSize(size: PageSize): Command {
411
+ return (state, dispatch) => {
412
+ const target = findSectionBreakAfter(state, state.selection.from);
413
+ if (!target) return false;
414
+ const tr = state.tr;
415
+ patchSectionProperties(tr, target.pos, target.node, (cur) => ({
416
+ ...cur,
417
+ pageSize: size,
418
+ }));
419
+ if (dispatch) dispatch(tr);
420
+ return true;
421
+ };
422
+ }
423
+
424
+ export function setPageMargins(margins: PageMargins): Command {
425
+ return (state, dispatch) => {
426
+ const target = findSectionBreakAfter(state, state.selection.from);
427
+ if (!target) return false;
428
+ const tr = state.tr;
429
+ patchSectionProperties(tr, target.pos, target.node, (cur) => ({
430
+ ...cur,
431
+ margins,
432
+ }));
433
+ if (dispatch) dispatch(tr);
434
+ return true;
435
+ };
436
+ }
437
+
438
+ // ── List Enter handling ──────────────────────────────────────────────────
439
+
440
+ /**
441
+ * Enter inside a list paragraph:
442
+ * - if the paragraph is empty, exit the list (clear numbering)
443
+ * - otherwise, split the paragraph keeping the same numbering
444
+ */
445
+ export function listEnter(ctx: CommandContext = {}): Command {
446
+ return (state, dispatch) => {
447
+ const { selection } = state;
448
+ if (!selection.empty) return false;
449
+ const $from = selection.$from;
450
+ const node = $from.parent;
451
+ if (node.type.name !== "paragraph") return false;
452
+ const props = node.attrs.properties as ParagraphProperties | null;
453
+ const numbering = props?.numbering;
454
+ if (!numbering) return false;
455
+
456
+ if (node.content.size > 0) {
457
+ // Non-empty list item: yield to baseKeymap's Enter so the paragraph
458
+ // splits via the standard machinery (which copies attrs, including
459
+ // numbering, to the new paragraph).
460
+ return false;
461
+ }
462
+
463
+ // Empty list item: exit the list by clearing numbering on this paragraph.
464
+ const tr = state.tr;
465
+ patchParagraphProperties(
466
+ tr,
467
+ $from.before($from.depth),
468
+ node,
469
+ (cur) => ({ ...cur, numbering: null }),
470
+ ctx.styleTable ?? null,
471
+ );
472
+ if (dispatch) dispatch(tr);
473
+ return true;
474
+ };
475
+ }
476
+
477
+ // ── Generic insertion commands ───────────────────────────────────────────
478
+
479
+ export function insertHardBreak(): Command {
480
+ return (state, dispatch) => {
481
+ const node = state.schema.nodes.hard_break.create();
482
+ const tr = state.tr.replaceSelectionWith(node, false);
483
+ if (dispatch) dispatch(tr);
484
+ return true;
485
+ };
486
+ }
487
+
488
+ export function insertPageBreak(): Command {
489
+ return (state, dispatch) => {
490
+ const para = state.schema.nodes.paragraph.create({
491
+ properties: { ...EMPTY_PARAGRAPH_PROPERTIES, pageBreakBefore: true },
492
+ unknownProperties: [],
493
+ resolvedProperties: null,
494
+ resolvedBaseRun: null,
495
+ numberingLabel: null,
496
+ });
497
+ const tr = state.tr.replaceSelectionWith(para, false);
498
+ if (dispatch) dispatch(tr);
499
+ return true;
500
+ };
501
+ }
502
+
503
+ // Default tblPr and tblGrid for fresh tables — matches what Word emits for
504
+ // "Insert Table" with the TableGrid style (single 4px borders all around,
505
+ // equal column widths summing to a 6" content area at 1440 dxa/inch).
506
+ function buildDefaultTablePropertiesXml(): import("@lotics/ooxml/xml").XmlElement {
507
+ const border = (tag: string) => ({
508
+ [tag]: [],
509
+ ":@": {
510
+ "@_w:val": "single",
511
+ "@_w:sz": "4",
512
+ "@_w:space": "0",
513
+ "@_w:color": "auto",
514
+ },
515
+ });
516
+ return {
517
+ "w:tblPr": [
518
+ { "w:tblStyle": [], ":@": { "@_w:val": "TableGrid" } },
519
+ { "w:tblW": [], ":@": { "@_w:w": "0", "@_w:type": "auto" } },
520
+ {
521
+ "w:tblBorders": [
522
+ border("w:top"),
523
+ border("w:left"),
524
+ border("w:bottom"),
525
+ border("w:right"),
526
+ border("w:insideH"),
527
+ border("w:insideV"),
528
+ ],
529
+ },
530
+ ],
531
+ };
532
+ }
533
+
534
+ function buildDefaultTableGridXml(
535
+ cols: number,
536
+ totalDxa = 8640, // 6"
537
+ ): import("@lotics/ooxml/xml").XmlElement {
538
+ const colWidth = Math.floor(totalDxa / Math.max(cols, 1));
539
+ const gridCols = Array.from({ length: cols }, () => ({
540
+ "w:gridCol": [],
541
+ ":@": { "@_w:w": String(colWidth) },
542
+ }));
543
+ return { "w:tblGrid": gridCols };
544
+ }
545
+
546
+ export function insertTable(rows: number, cols: number): Command {
547
+ return (state, dispatch) => {
548
+ const { schema } = state;
549
+ const cellNodes = Array.from({ length: cols }, () =>
550
+ schema.nodes.table_cell.create({}, [schema.nodes.paragraph.create()]),
551
+ );
552
+ const rowNodes = Array.from({ length: rows }, () =>
553
+ schema.nodes.table_row.create({}, cellNodes),
554
+ );
555
+ const table = schema.nodes.table.create(
556
+ {
557
+ tableProperties: buildDefaultTablePropertiesXml(),
558
+ columnGrid: buildDefaultTableGridXml(cols),
559
+ },
560
+ rowNodes,
561
+ );
562
+ const tr = state.tr.replaceSelectionWith(table, false);
563
+ if (dispatch) dispatch(tr);
564
+ return true;
565
+ };
566
+ }
567
+
568
+ // Word's HR shape: a paragraph with <w:pBdr><w:bottom .../></w:pBdr>. The
569
+ // pBdr element is held in unknownProperties so partitionParagraphProperties
570
+ // preserves it through the docx ↔ PM round-trip without needing a typed
571
+ // model field.
572
+ const HR_PBDR_XML = {
573
+ "w:pBdr": [
574
+ {
575
+ "w:bottom": [],
576
+ ":@": {
577
+ "@_w:val": "single",
578
+ "@_w:sz": "6",
579
+ "@_w:space": "1",
580
+ "@_w:color": "auto",
581
+ },
582
+ },
583
+ ],
584
+ };
585
+
586
+ export function insertHorizontalRule(): Command {
587
+ return (state, dispatch) => {
588
+ const para = state.schema.nodes.paragraph.create({
589
+ properties: { ...EMPTY_PARAGRAPH_PROPERTIES },
590
+ unknownProperties: [HR_PBDR_XML],
591
+ resolvedProperties: null,
592
+ resolvedBaseRun: null,
593
+ numberingLabel: null,
594
+ });
595
+ const tr = state.tr.replaceSelectionWith(para, false);
596
+ if (dispatch) dispatch(tr);
597
+ return true;
598
+ };
599
+ }
600
+
601
+ /**
602
+ * Insert a link into the doc.
603
+ *
604
+ * Currently only **internal anchors** (`#bookmarkName`) round-trip cleanly —
605
+ * external URLs require allocating a relationship in the document's rels
606
+ * map, which there is no plumbing for at the insertion site yet. Passing an
607
+ * external URL is rejected with `false` so the caller can show a useful
608
+ * error rather than silently producing invalid OOXML.
609
+ */
610
+ export function insertLink(href: string, text?: string): Command {
611
+ return (state, dispatch) => {
612
+ const linkMark = state.schema.marks.link;
613
+ if (!linkMark) return false;
614
+ if (!href.startsWith("#")) {
615
+ // External hyperlinks need a relationship id allocator. Refuse rather
616
+ // than emitting <w:hyperlink w:anchor="https://..."> which is invalid.
617
+ return false;
618
+ }
619
+ const anchor = href.slice(1);
620
+ if (anchor.length === 0) return false;
621
+ const mark = linkMark.create({ relationshipId: null, anchor });
622
+ const display = text ?? href;
623
+ const node = state.schema.text(display, [mark]);
624
+ const tr = state.tr.replaceSelectionWith(node, false);
625
+ if (dispatch) dispatch(tr);
626
+ return true;
627
+ };
628
+ }
629
+
630
+ // ── Template insertion commands ──────────────────────────────────────────
631
+
632
+ function insertTextAtSelection(text: string): Command {
633
+ return (state, dispatch) => {
634
+ const tr = state.tr.replaceSelectionWith(state.schema.text(text), false);
635
+ if (dispatch) dispatch(tr);
636
+ return true;
637
+ };
638
+ }
639
+
640
+ export function insertVariable(name: string): Command {
641
+ if (!name) return () => false;
642
+ return insertTextAtSelection(`{{${name}}}`);
643
+ }
644
+
645
+ export function insertLoopVariable(loopVar: string, field: string): Command {
646
+ if (!loopVar || !field) return () => false;
647
+ return insertTextAtSelection(`{{$${loopVar}.${field}}}`);
648
+ }
649
+
650
+ export function wrapSelectionInLoop(loopVar: string, arrayName: string): Command {
651
+ if (!loopVar || !arrayName) return () => false;
652
+ return (state, dispatch) => {
653
+ const { from, to } = state.selection;
654
+ if (from === to) return false;
655
+ const tr = state.tr;
656
+ const startText = state.schema.text(`{{FOR ${loopVar} IN ${arrayName}}}`);
657
+ const endText = state.schema.text(`{{END-FOR ${arrayName}}}`);
658
+ tr.insert(to, endText);
659
+ tr.insert(from, startText);
660
+ if (dispatch) dispatch(tr);
661
+ return true;
662
+ };
663
+ }
664
+
665
+ export function wrapSelectionInConditional(condition: string): Command {
666
+ if (!condition) return () => false;
667
+ return (state, dispatch) => {
668
+ const { from, to } = state.selection;
669
+ if (from === to) return false;
670
+ const tr = state.tr;
671
+ tr.insert(to, state.schema.text(`{{END-IF ${condition}}}`));
672
+ tr.insert(from, state.schema.text(`{{IF ${condition}}}`));
673
+ if (dispatch) dispatch(tr);
674
+ return true;
675
+ };
676
+ }
677
+
678
+ export function setPageOrientation(orientation: Orientation): Command {
679
+ return (state, dispatch) => {
680
+ const target = findSectionBreakAfter(state, state.selection.from);
681
+ if (!target) return false;
682
+ const tr = state.tr;
683
+ patchSectionProperties(tr, target.pos, target.node, (cur) => {
684
+ if (cur.pageSize.orientation === orientation) return cur;
685
+ return {
686
+ ...cur,
687
+ pageSize: {
688
+ width: cur.pageSize.height,
689
+ height: cur.pageSize.width,
690
+ orientation,
691
+ },
692
+ };
693
+ });
694
+ if (dispatch) dispatch(tr);
695
+ return true;
696
+ };
697
+ }