@stll/folio-core 0.8.0 → 0.9.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 (152) hide show
  1. package/README.md +39 -0
  2. package/dist/ai-edits/apply.js +18 -4
  3. package/dist/ai-edits/headless.d.ts +31 -4
  4. package/dist/ai-edits/headless.js +148 -35
  5. package/dist/ai-edits/index.d.ts +3 -3
  6. package/dist/ai-edits/index.js +2 -1
  7. package/dist/ai-edits/snapshot.js +40 -3
  8. package/dist/ai-edits/types.d.ts +2 -1
  9. package/dist/compat/eigenpal.d.ts +6 -4
  10. package/dist/compat/eigenpal.js +5 -3
  11. package/dist/controller/latestRequestGate.d.ts +13 -0
  12. package/dist/controller/latestRequestGate.js +16 -0
  13. package/dist/controller/layoutPipeline.js +12 -1
  14. package/dist/document-operations.d.ts +10 -3
  15. package/dist/document-operations.js +29 -14
  16. package/dist/docx/blockContentParser.js +2 -2
  17. package/dist/docx/commentParser.js +1 -1
  18. package/dist/docx/compatibility.d.ts +3 -7
  19. package/dist/docx/compatibility.js +0 -11
  20. package/dist/docx/encryption/agileDecryption.js +1 -1
  21. package/dist/docx/encryption/encryptionInfo.js +1 -1
  22. package/dist/docx/encryption/openEncryptedDocx.js +1 -1
  23. package/dist/docx/fontTableParser.d.ts +6 -0
  24. package/dist/docx/fontTableParser.js +72 -0
  25. package/dist/docx/groupDrawingParser.js +7 -2
  26. package/dist/docx/headerFooterParser.js +1 -1
  27. package/dist/docx/normalizeBaseDirection.js +1 -1
  28. package/dist/docx/numberingParser.js +1 -0
  29. package/dist/docx/paragraphParser.js +12 -1
  30. package/dist/docx/parser.d.ts +1 -1
  31. package/dist/docx/parser.js +12 -9
  32. package/dist/docx/rezip.js +70 -5
  33. package/dist/docx/runConsolidator.js +4 -0
  34. package/dist/docx/runParser.js +19 -1
  35. package/dist/docx/selectiveSave.js +3 -3
  36. package/dist/docx/serializer/fontTableSerializer.d.ts +6 -0
  37. package/dist/docx/serializer/fontTableSerializer.js +26 -0
  38. package/dist/docx/serializer/headerFooterSerializer.js +1 -1
  39. package/dist/docx/serializer/paragraphSerializer.js +4 -0
  40. package/dist/docx/serializer/runSerializer.js +7 -0
  41. package/dist/docx/serializer/settingsSerializer.d.ts +6 -0
  42. package/dist/docx/serializer/settingsSerializer.js +16 -0
  43. package/dist/docx/serializer/stylesSerializer.d.ts +6 -0
  44. package/dist/docx/serializer/stylesSerializer.js +56 -0
  45. package/dist/docx/serializer/tableSerializer.js +25 -26
  46. package/dist/docx/serializer/themeSerializer.d.ts +6 -0
  47. package/dist/docx/serializer/themeSerializer.js +36 -0
  48. package/dist/docx/settingsParser.js +46 -0
  49. package/dist/docx/styleParser.js +26 -1
  50. package/dist/docx/tableParser.js +11 -1
  51. package/dist/docx/vmlImageParser.js +1 -0
  52. package/dist/i18n/messages/catalogs.gen.d.ts +5916 -5916
  53. package/dist/i18n/messages/catalogs.gen.js +5910 -5910
  54. package/dist/index.d.ts +6 -4
  55. package/dist/index.js +5 -3
  56. package/dist/layout-bridge/convert/footnoteLayout.d.ts +3 -0
  57. package/dist/layout-bridge/convert/footnoteLayout.js +3 -1
  58. package/dist/layout-bridge/convert/headerFooterLayout.d.ts +3 -0
  59. package/dist/layout-bridge/convert/headerFooterLayout.js +6 -1
  60. package/dist/layout-bridge/convert/toFlowBlocks.d.ts +14 -2
  61. package/dist/layout-bridge/convert/toFlowBlocks.js +51 -14
  62. package/dist/layout-bridge/engine/measuring/index.d.ts +2 -1
  63. package/dist/layout-bridge/engine/measuring/index.js +2 -1
  64. package/dist/layout-engine/index.js +36 -28
  65. package/dist/layout-engine/measure/cache.js +11 -3
  66. package/dist/layout-engine/measure/font-metrics.worker.js +1 -0
  67. package/dist/layout-engine/measure/index.d.ts +2 -1
  68. package/dist/layout-engine/measure/index.js +2 -1
  69. package/dist/layout-engine/measure/lineBreakProvider.d.ts +33 -0
  70. package/dist/layout-engine/measure/lineBreakProvider.js +252 -0
  71. package/dist/layout-engine/measure/lineBreaks.d.ts +7 -2
  72. package/dist/layout-engine/measure/lineBreaks.js +11 -18
  73. package/dist/layout-engine/measure/listMarkerWidth.d.ts +1 -0
  74. package/dist/layout-engine/measure/listMarkerWidth.js +12 -6
  75. package/dist/layout-engine/measure/measureBlocks.d.ts +1 -2
  76. package/dist/layout-engine/measure/measureBlocks.js +6 -36
  77. package/dist/layout-engine/measure/measureContainer.js +32 -9
  78. package/dist/layout-engine/measure/measureHelpers.js +5 -2
  79. package/dist/layout-engine/measure/measureParagraph.js +335 -72
  80. package/dist/layout-engine/measure/measureTypes.d.ts +2 -1
  81. package/dist/layout-engine/measure/measureWorker.d.ts +2 -2
  82. package/dist/layout-engine/measure/measureWorker.js +8 -19
  83. package/dist/layout-engine/measure/measureWorkerProtocol.d.ts +3 -14
  84. package/dist/layout-engine/measure/tableCellFloating.d.ts +10 -3
  85. package/dist/layout-engine/measure/tableCellFloating.js +37 -29
  86. package/dist/layout-engine/measure/tableCellFlow.d.ts +27 -0
  87. package/dist/layout-engine/measure/tableCellFlow.js +73 -0
  88. package/dist/layout-engine/measure/tableCellGrid.js +1 -1
  89. package/dist/layout-engine/measure/textMeasurementPolicy.d.ts +18 -0
  90. package/dist/layout-engine/measure/textMeasurementPolicy.js +24 -0
  91. package/dist/layout-engine/renderedBreakReconciliation.d.ts +6 -1
  92. package/dist/layout-engine/renderedBreakReconciliation.js +34 -11
  93. package/dist/layout-engine/tableRowBreak.d.ts +5 -2
  94. package/dist/layout-engine/tableRowBreak.js +62 -29
  95. package/dist/layout-engine/types.d.ts +40 -7
  96. package/dist/layout-painter/anchoredImagePosition.d.ts +23 -0
  97. package/dist/layout-painter/anchoredImagePosition.js +95 -0
  98. package/dist/layout-painter/borderStroke.d.ts +23 -0
  99. package/dist/layout-painter/borderStroke.js +39 -0
  100. package/dist/layout-painter/renderImage.d.ts +5 -4
  101. package/dist/layout-painter/renderImage.js +18 -4
  102. package/dist/layout-painter/renderPage.d.ts +2 -27
  103. package/dist/layout-painter/renderPage.js +13 -99
  104. package/dist/layout-painter/renderParagraph.js +41 -23
  105. package/dist/layout-painter/renderTable.d.ts +2 -0
  106. package/dist/layout-painter/renderTable.js +223 -43
  107. package/dist/managers/DocumentLoaderManager.d.ts +1 -1
  108. package/dist/managers/DocumentLoaderManager.js +2 -2
  109. package/dist/prosemirror/attrs/index.d.ts +4 -2
  110. package/dist/prosemirror/attrs/index.js +21 -2
  111. package/dist/prosemirror/commands/propertyChangeScope.js +1 -1
  112. package/dist/prosemirror/conversion/fromProseDoc.js +33 -18
  113. package/dist/prosemirror/conversion/toProseDoc.js +50 -18
  114. package/dist/prosemirror/extensions/StarterKit.js +2 -0
  115. package/dist/prosemirror/extensions/core/ParagraphExtension.js +7 -2
  116. package/dist/prosemirror/extensions/features/AutoBidiDetectionExtension.js +1 -1
  117. package/dist/prosemirror/extensions/features/ListExtension.js +1 -0
  118. package/dist/prosemirror/extensions/marks/FootnoteRefExtension.js +1 -0
  119. package/dist/prosemirror/extensions/marks/HighlightExtension.js +1 -1
  120. package/dist/prosemirror/extensions/marks/LanguageExtension.d.ts +7 -0
  121. package/dist/prosemirror/extensions/marks/LanguageExtension.js +30 -0
  122. package/dist/prosemirror/extensions/marks/RunShadingExtension.js +1 -1
  123. package/dist/prosemirror/extensions/marks/TextColorExtension.js +1 -1
  124. package/dist/prosemirror/extensions/marks/TrackedChangeExtensions.js +1 -1
  125. package/dist/prosemirror/extensions/marks/markUtils.js +12 -0
  126. package/dist/prosemirror/extensions/nodes/ImageExtension.js +2 -1
  127. package/dist/prosemirror/extensions/nodes/TableExtension.js +4 -2
  128. package/dist/prosemirror/schema/index.d.ts +2 -2
  129. package/dist/prosemirror/schema/marks.d.ts +6 -1
  130. package/dist/prosemirror/schema/nodes.d.ts +13 -11
  131. package/dist/prosemirror/styles/resolvedStyleAttrs.js +1 -0
  132. package/dist/prosemirror/utils/tabCalculator.d.ts +3 -1
  133. package/dist/prosemirror/utils/tabCalculator.js +12 -10
  134. package/dist/prosemirror/validation.js +4 -1
  135. package/dist/redline.js +13 -3
  136. package/dist/server.d.ts +6 -3
  137. package/dist/server.js +5 -2
  138. package/dist/style-sets/extract.d.ts +35 -0
  139. package/dist/style-sets/extract.js +123 -0
  140. package/dist/style-sets/stellaStyle.d.ts +13 -0
  141. package/dist/style-sets/stellaStyle.js +516 -0
  142. package/dist/style-sets/types.d.ts +31 -0
  143. package/dist/style-sets/types.js +5 -0
  144. package/dist/utils/createDocument.d.ts +12 -2
  145. package/dist/utils/createDocument.js +42 -31
  146. package/dist/utils/fontResolver.js +6 -6
  147. package/dist/utils/formatToStyle.js +1 -1
  148. package/dist/utils/paragraphFormattingMerge.d.ts +14 -3
  149. package/dist/utils/paragraphFormattingMerge.js +14 -4
  150. package/package.json +5 -1
  151. package/dist/docx/capabilities.d.ts +0 -41
  152. package/dist/docx/capabilities.js +0 -322
@@ -1,28 +1,24 @@
1
- import { isFloatingTextBoxBlock } from "./types.js";
2
1
  import { measureParagraph } from "./measure/measureParagraph.js";
2
+ import { isEmptyParagraph } from "./paragraphSpacing.js";
3
+ import { createTableCellFlowState, placeTableCellBlock } from "./measure/tableCellFlow.js";
3
4
  import { buildTableCellFloatingZones, getTableCellContentWidth, getTableCellFloatingImages } from "./measure/tableCellFloating.js";
4
5
  //#region src/layout-engine/tableRowBreak.ts
5
6
  /**
6
7
  * Table row-break geometry. Ported from eigenpal/docx-editor#698 (folio subset).
7
8
  *
8
- * Word lets a table row break across a page boundary ("allow row to break
9
- * across pages", on by default). When a row is taller than a whole page it
10
- * cannot fit anywhere, so the portion that fits stays and the rest continues on
11
- * the next page broken between whole text lines, never through a glyph.
12
- * Without this folio forced the oversized row onto one page and the overflow
13
- * was clipped (content lost; upstream #570).
9
+ * Table rows may break across flow regions unless `cantSplit` is set. When a
10
+ * permitted row exceeds the current region, the portion that fits stays and
11
+ * the rest continues in the next region, broken between whole text lines and
12
+ * never through a glyph. This also prevents oversized rows from overflowing
13
+ * one page and clipping content (upstream #570).
14
14
  *
15
15
  * This computes, per row, the safe break offsets (the y of every line bottom in
16
16
  * the row's cells) so the paginator can snap a break to the deepest whole line
17
17
  * that still fits. Vertically-merged cells that span into a row are not modeled
18
18
  * here yet (a separate follow-up); each row uses its own cells' content.
19
19
  */
20
+ const BREAK_OFFSET_EPSILON = .01;
20
21
  const DEFAULT_TABLE_CELL_PADDING_TOP = 1;
21
- function getAtomicBlockHeight(measure) {
22
- if ("totalHeight" in measure) return measure.totalHeight;
23
- if ("height" in measure) return measure.height;
24
- return 0;
25
- }
26
22
  function isInsideRange(offset, range) {
27
23
  return offset > range.top && offset < range.bottom;
28
24
  }
@@ -39,6 +35,10 @@ function shiftCellGeometry(geometry, offset) {
39
35
  unsafeRanges: geometry.unsafeRanges.map((range) => ({
40
36
  top: range.top + offset,
41
37
  bottom: range.bottom + offset
38
+ })),
39
+ suppressibleLeadingRanges: geometry.suppressibleLeadingRanges.map((range) => ({
40
+ top: range.top + offset,
41
+ bottom: range.bottom + offset
42
42
  }))
43
43
  };
44
44
  }
@@ -46,22 +46,28 @@ function shiftCellGeometry(geometry, offset) {
46
46
  function cellBreakGeometry(cell, measure) {
47
47
  const bottoms = [];
48
48
  const unsafeRanges = [];
49
+ const suppressibleLeadingRanges = [];
49
50
  const cellBlocks = cell?.blocks;
50
51
  const blockMeasures = measure.blocks;
51
52
  const padTop = cell?.padding?.top ?? DEFAULT_TABLE_CELL_PADDING_TOP;
52
53
  const contentWidth = getTableCellContentWidth(cell, measure);
53
54
  const floatingZones = buildTableCellFloatingZones(cell !== void 0 ? getTableCellFloatingImages(cell, measure, contentWidth) : [], contentWidth);
54
- let y = padTop;
55
- let paragraphY = 0;
55
+ const flowState = createTableCellFlowState();
56
56
  for (let i = 0; i < blockMeasures.length; i++) {
57
57
  let blockMeasure = blockMeasures[i];
58
58
  if (blockMeasure?.kind === "paragraph") {
59
59
  const block = cellBlocks?.[i];
60
- if (block?.kind === "paragraph" && floatingZones.length > 0) blockMeasure = measureParagraph(block, contentWidth, {
60
+ if (block?.kind !== "paragraph") continue;
61
+ if (floatingZones.length > 0) blockMeasure = measureParagraph(block, contentWidth, {
61
62
  floatingZones,
62
- paragraphYOffset: paragraphY
63
+ paragraphYOffset: placeTableCellBlock({ ...flowState }, block, blockMeasure).contentTop
64
+ });
65
+ const placement = placeTableCellBlock(flowState, block, blockMeasure);
66
+ if (placement.leadingSpacing > 0 && isEmptyParagraph(block)) suppressibleLeadingRanges.push({
67
+ top: padTop + placement.top,
68
+ bottom: padTop + placement.contentTop
63
69
  });
64
- const blockTop = y;
70
+ let y = padTop + placement.contentTop;
65
71
  for (const line of blockMeasure.lines) {
66
72
  y += line.floatSkipBefore ?? 0;
67
73
  const top = y;
@@ -72,16 +78,15 @@ function cellBreakGeometry(cell, measure) {
72
78
  });
73
79
  bottoms.push(y);
74
80
  }
75
- y = blockTop + blockMeasure.totalHeight;
76
- paragraphY += blockMeasure.totalHeight;
81
+ bottoms.push(padTop + flowState.height);
77
82
  } else if (blockMeasure) {
78
83
  const block = cellBlocks?.[i];
79
- if (block?.kind === "textBox" && isFloatingTextBoxBlock(block)) continue;
80
- const blockHeight = getAtomicBlockHeight(blockMeasure);
84
+ if (!block) continue;
85
+ const placement = placeTableCellBlock(flowState, block, blockMeasure);
86
+ const blockHeight = placement.contentHeight;
81
87
  if (blockHeight > 0) {
82
- const top = y;
83
- y += blockHeight;
84
- paragraphY += blockHeight;
88
+ const top = padTop + placement.contentTop;
89
+ const y = top + blockHeight;
85
90
  unsafeRanges.push({
86
91
  top,
87
92
  bottom: y
@@ -92,9 +97,26 @@ function cellBreakGeometry(cell, measure) {
92
97
  }
93
98
  return {
94
99
  bottoms,
95
- unsafeRanges
100
+ unsafeRanges,
101
+ suppressibleLeadingRanges
96
102
  };
97
103
  }
104
+ const continuationSkipForCell = (geometry, offset) => {
105
+ if (!geometry.unsafeRanges.some(({ bottom }) => bottom > offset + BREAK_OFFSET_EPSILON)) return;
106
+ const leadingRange = geometry.suppressibleLeadingRanges.find(({ top, bottom }) => top <= offset + BREAK_OFFSET_EPSILON && bottom > offset + BREAK_OFFSET_EPSILON);
107
+ return leadingRange ? leadingRange.bottom - offset : 0;
108
+ };
109
+ /**
110
+ * Leading spacing before an empty paragraph is consumed when a split row
111
+ * resumes at the top of a fresh flow region. Only a whitespace band shared by
112
+ * every cell with remaining content is suppressible; non-empty paragraphs,
113
+ * line boxes, cell padding, and explicit row height retain authored space.
114
+ */
115
+ const continuationSkipAfter = (geometries, offset) => {
116
+ const skips = geometries.map((geometry) => continuationSkipForCell(geometry, offset)).filter((skip) => skip !== void 0);
117
+ if (skips.length === 0 || skips.some((skip) => skip <= BREAK_OFFSET_EPSILON)) return 0;
118
+ return Math.min(...skips);
119
+ };
98
120
  /** Build break geometry for a table from its block + measure. */
99
121
  function buildTableRowBreakInfo(block, measure) {
100
122
  const rowCount = measure.rows.length;
@@ -106,6 +128,7 @@ function buildTableRowBreakInfo(block, measure) {
106
128
  }
107
129
  rowTops.push(acc);
108
130
  const breakOffsets = [];
131
+ const continuationSkips = [];
109
132
  for (let r = 0; r < rowCount; r++) {
110
133
  const rowHeight = measure.rows[r]?.height ?? 0;
111
134
  const offsets = /* @__PURE__ */ new Set();
@@ -121,14 +144,24 @@ function buildTableRowBreakInfo(block, measure) {
121
144
  cellGeometries.push(geometry);
122
145
  for (const b of geometry.bottoms) if (b > 0 && b < rowHeight) offsets.add(b);
123
146
  }
124
- const safeOffsets = [...offsets].filter((offset) => offset === rowHeight || cellGeometries.every((geometry) => geometry.unsafeRanges.every((range) => !isInsideRange(offset, range))));
125
- breakOffsets.push(safeOffsets.sort((a, b) => a - b));
147
+ const sortedOffsets = [...offsets].filter((offset) => offset === rowHeight || cellGeometries.every((geometry) => geometry.unsafeRanges.every((range) => !isInsideRange(offset, range)))).sort((a, b) => a - b);
148
+ breakOffsets.push(sortedOffsets);
149
+ continuationSkips.push(sortedOffsets.map((offset) => continuationSkipAfter(cellGeometries, offset)));
126
150
  }
127
151
  return {
128
152
  rowTops,
129
- breakOffsets
153
+ breakOffsets,
154
+ continuationSkips
130
155
  };
131
156
  }
157
+ /** Leading empty-paragraph whitespace to consume when a row resumes after `offset`. */
158
+ function getRowContinuationSkip(info, rowIndex, offset) {
159
+ const offsets = info.breakOffsets[rowIndex];
160
+ const skips = info.continuationSkips[rowIndex];
161
+ const index = offsets?.findIndex((candidate) => Math.abs(candidate - offset) <= BREAK_OFFSET_EPSILON);
162
+ if (index === void 0 || index < 0) return 0;
163
+ return skips?.[index] ?? 0;
164
+ }
132
165
  /**
133
166
  * Given a row and how much of it has already been placed (`fromOffset`), return
134
167
  * how many more px can be placed ending on a whole line, without exceeding
@@ -147,4 +180,4 @@ function snapRowBreak(info, rowIndex, fromOffset, maxSlice) {
147
180
  return best;
148
181
  }
149
182
  //#endregion
150
- export { buildTableRowBreakInfo, snapRowBreak };
183
+ export { buildTableRowBreakInfo, getRowContinuationSkip, snapRowBreak };
@@ -1,5 +1,5 @@
1
1
  import { OutlineStyleAttr } from "../types/documentEnumValues.js";
2
- import { ImagePosition, ImageWrap, SdtProperties, SdtType, ShapeTextBody, TableWidthType } from "@stll/docx-core/model";
2
+ import { ImagePosition, ImageWrap, SdtProperties, SdtType, ShapeTextBody, TableCellFormatting, TableWidthType } from "@stll/docx-core/model";
3
3
 
4
4
  //#region src/layout-engine/types.d.ts
5
5
  declare namespace types_d_exports {
@@ -38,7 +38,12 @@ type RunFormatting = {
38
38
  * `segmentByScript`) so wrapping stays correct. Absent means CJK falls back
39
39
  * to `fontFamily`.
40
40
  */
41
- eastAsiaFontFamily?: string;
41
+ eastAsiaFontFamily?: string; /** Run language metadata resolved from `w:lang`. */
42
+ language?: {
43
+ val?: string;
44
+ eastAsia?: string;
45
+ bidi?: string;
46
+ };
42
47
  fontSize?: number;
43
48
  letterSpacing?: number;
44
49
  superscript?: boolean;
@@ -164,7 +169,8 @@ type ImageRun = {
164
169
  distTop?: number;
165
170
  distBottom?: number;
166
171
  distLeft?: number;
167
- distRight?: number;
172
+ distRight?: number; /** Use the image box as the exact line height for an embedded-object preview. */
173
+ exactLineHeight?: boolean;
168
174
  /**
169
175
  * wp:srcRect crop fractions in [0, 1]; emit as CSS `clip-path: inset(...)`
170
176
  * to match Word's visible region. eigenpal #424 (image-crop subset).
@@ -286,7 +292,26 @@ type ListNumPr = {
286
292
  * Paragraph block attributes.
287
293
  */
288
294
  type ParagraphAttrs = {
289
- alignment?: "left" | "center" | "right" | "justify"; /** OOXML outline level (`w:outlineLvl`), where zero is the top level. */
295
+ alignment?: "left" | "center" | "right" | "justify"; /** East Asian first/last-character restrictions (`w:kinsoku`). */
296
+ kinsoku?: boolean; /** Hanging punctuation (`w:overflowPunct`), retained for layout policy. */
297
+ overflowPunctuation?: boolean; /** Exempt this paragraph from document automatic hyphenation. */
298
+ suppressAutoHyphens?: boolean; /** Document-wide automatic hyphenation controls retained for line layout. */
299
+ automaticHyphenation?: {
300
+ enabled: true;
301
+ doNotHyphenateCaps?: boolean;
302
+ consecutiveLineLimit?: number;
303
+ }; /** Document-wide custom line-edge characters and compatibility behavior. */
304
+ lineBreakRules?: {
305
+ noLineBreaksBefore?: {
306
+ language?: string;
307
+ characters: string;
308
+ };
309
+ noLineBreaksAfter?: {
310
+ language?: string;
311
+ characters: string;
312
+ };
313
+ useLegacyEthiopicAmharicRules?: boolean;
314
+ }; /** OOXML outline level (`w:outlineLvl`), where zero is the top level. */
290
315
  outlineLevel?: number;
291
316
  spacing?: ParagraphSpacing; /** Spacing sides resolved from OOXML automatic paragraph spacing. */
292
317
  automaticSpacing?: {
@@ -338,7 +363,8 @@ type ParagraphAttrs = {
338
363
  listIsBullet?: boolean;
339
364
  listMarkerHidden?: boolean;
340
365
  listMarkerFontFamily?: string;
341
- listMarkerFontSize?: number; /** Horizontal alignment of the marker around the paragraph's list anchor. */
366
+ listMarkerFontSize?: number;
367
+ listMarkerBold?: boolean; /** Horizontal alignment of the marker around the paragraph's list anchor. */
342
368
  listMarkerAlignment?: "left" | "center" | "right";
343
369
  /**
344
370
  * `w:suff` (§17.9.25) — what follows the marker before body text.
@@ -413,6 +439,8 @@ type CellBorders = {
413
439
  bottom?: CellBorderSpec;
414
440
  left?: CellBorderSpec;
415
441
  right?: CellBorderSpec;
442
+ topLeftToBottomRight?: CellBorderSpec;
443
+ topRightToBottomLeft?: CellBorderSpec;
416
444
  };
417
445
  /**
418
446
  * A table cell with content.
@@ -424,6 +452,7 @@ type TableCell = {
424
452
  rowSpan?: number;
425
453
  width?: number;
426
454
  verticalAlign?: "top" | "center" | "bottom";
455
+ textDirection?: NonNullable<TableCellFormatting["textDirection"]>;
427
456
  background?: string;
428
457
  borders?: CellBorders; /** Per-cell padding in pixels (from w:tcMar or table-level w:tblCellMar) */
429
458
  padding?: {
@@ -449,7 +478,8 @@ type TableRow = {
449
478
  gridAfter?: number;
450
479
  height?: number;
451
480
  heightRule?: "auto" | "atLeast" | "exact";
452
- isHeader?: boolean;
481
+ isHeader?: boolean; /** `w:cantSplit`: keep this row in one flow region. */
482
+ cantSplit?: boolean;
453
483
  hidden?: boolean;
454
484
  };
455
485
  /**
@@ -669,7 +699,10 @@ type MeasuredLine = {
669
699
  * no usable horizontal room at the natural line Y. Painters render this
670
700
  * as marginTop on the line element; measurement adds it to totalHeight.
671
701
  */
672
- floatSkipBefore?: number;
702
+ floatSkipBefore?: number; /** Decorative hyphen inserted at a dictionary break without changing source text. */
703
+ discretionaryHyphen?: {
704
+ runIndex: number;
705
+ };
673
706
  };
674
707
  /**
675
708
  * Measurement result for a paragraph block.
@@ -0,0 +1,23 @@
1
+ import { ImageRun } from "../layout-engine/types.js";
2
+
3
+ //#region src/layout-painter/anchoredImagePosition.d.ts
4
+ type PageGeometry = {
5
+ pageWidth: number;
6
+ pageHeight: number;
7
+ marginLeft: number;
8
+ marginTop: number;
9
+ marginRight: number;
10
+ marginBottom: number;
11
+ contentWidth: number;
12
+ contentHeight: number;
13
+ };
14
+ /** Coordinates relative to the page content area's top-left corner. */
15
+ type AnchoredImagePosition = {
16
+ x: number;
17
+ y: number;
18
+ side: "left" | "right";
19
+ };
20
+ /** Resolve an anchored image into page-content-relative coordinates. */
21
+ declare function resolveAnchoredImagePosition(imgRun: ImageRun, fragmentY: number, geometry: PageGeometry): AnchoredImagePosition;
22
+ //#endregion
23
+ export { AnchoredImagePosition, PageGeometry, resolveAnchoredImagePosition };
@@ -0,0 +1,95 @@
1
+ import { emuToPixels } from "./renderUtils.js";
2
+ //#region src/layout-painter/anchoredImagePosition.ts
3
+ function resolveHorizontalBand(relativeTo, geometry) {
4
+ switch (relativeTo) {
5
+ case "page": return {
6
+ baseX: -geometry.marginLeft,
7
+ bandWidth: geometry.pageWidth
8
+ };
9
+ case "leftMargin":
10
+ case "insideMargin": return {
11
+ baseX: -geometry.marginLeft,
12
+ bandWidth: geometry.marginLeft
13
+ };
14
+ case "rightMargin":
15
+ case "outsideMargin": return {
16
+ baseX: geometry.contentWidth,
17
+ bandWidth: geometry.marginRight
18
+ };
19
+ case "character": return {
20
+ baseX: 0,
21
+ bandWidth: 0
22
+ };
23
+ default: return {
24
+ baseX: 0,
25
+ bandWidth: geometry.contentWidth
26
+ };
27
+ }
28
+ }
29
+ function resolveVerticalBand(relativeTo, fragmentY, geometry) {
30
+ switch (relativeTo) {
31
+ case "page": return {
32
+ baseY: -geometry.marginTop,
33
+ bandHeight: geometry.pageHeight
34
+ };
35
+ case "topMargin": return {
36
+ baseY: -geometry.marginTop,
37
+ bandHeight: geometry.marginTop
38
+ };
39
+ case "bottomMargin": return {
40
+ baseY: geometry.contentHeight,
41
+ bandHeight: geometry.marginBottom
42
+ };
43
+ case "paragraph":
44
+ case "line": return {
45
+ baseY: fragmentY,
46
+ bandHeight: 0
47
+ };
48
+ default: return {
49
+ baseY: 0,
50
+ bandHeight: geometry.contentHeight
51
+ };
52
+ }
53
+ }
54
+ /** Resolve an anchored image into page-content-relative coordinates. */
55
+ function resolveAnchoredImagePosition(imgRun, fragmentY, geometry) {
56
+ const position = imgRun.position;
57
+ const contentWidth = geometry.contentWidth;
58
+ let side = "left";
59
+ let x = 0;
60
+ if (position?.horizontal) {
61
+ const h = position.horizontal;
62
+ const { baseX, bandWidth } = resolveHorizontalBand(h.relativeTo, geometry);
63
+ const horizontalHasBand = h.relativeTo !== "character";
64
+ if (h.align === "right" || h.align === "outside") {
65
+ side = "right";
66
+ x = horizontalHasBand ? baseX + bandWidth - imgRun.width : 0;
67
+ } else if (h.align === "left" || h.align === "inside") x = baseX;
68
+ else if (h.align === "center") x = horizontalHasBand ? baseX + (bandWidth - imgRun.width) / 2 : 0;
69
+ else if (h.posOffset !== void 0) {
70
+ x = baseX + emuToPixels(h.posOffset);
71
+ side = x > contentWidth / 2 ? "right" : "left";
72
+ } else x = baseX;
73
+ } else if (imgRun.cssFloat === "right") {
74
+ side = "right";
75
+ x = contentWidth - imgRun.width;
76
+ }
77
+ let y;
78
+ if (position?.vertical) {
79
+ const v = position.vertical;
80
+ const { baseY, bandHeight } = resolveVerticalBand(v.relativeTo, fragmentY, geometry);
81
+ const verticalHasBand = v.relativeTo !== "paragraph" && v.relativeTo !== "line";
82
+ if (v.align === "top" || v.align === "inside") y = baseY;
83
+ else if (v.align === "center") y = verticalHasBand ? baseY + (bandHeight - imgRun.height) / 2 : fragmentY;
84
+ else if (v.align === "bottom" || v.align === "outside") y = verticalHasBand ? baseY + bandHeight - imgRun.height : fragmentY;
85
+ else if (v.posOffset !== void 0) y = baseY + emuToPixels(v.posOffset);
86
+ else y = verticalHasBand ? baseY : fragmentY;
87
+ } else y = fragmentY;
88
+ return {
89
+ x,
90
+ y,
91
+ side
92
+ };
93
+ }
94
+ //#endregion
95
+ export { resolveAnchoredImagePosition };
@@ -0,0 +1,23 @@
1
+ import { BorderStyle, ParagraphBorders } from "../layout-engine/types.js";
2
+
3
+ //#region src/layout-painter/borderStroke.d.ts
4
+ type CssBorderStroke = {
5
+ color: string;
6
+ style: string;
7
+ width: number;
8
+ };
9
+ /**
10
+ * Preserve the optical weight of authored subpixel borders. CSS quantizes a
11
+ * fractional border width to one device pixel, so its color carries the
12
+ * fractional coverage while layout keeps the authored width.
13
+ */
14
+ declare const resolveCssBorderStroke: (border: Pick<BorderStyle, "color" | "style" | "width">) => CssBorderStroke;
15
+ declare const borderStrokeToCss: (border: Pick<BorderStyle, "color" | "style" | "width">) => string;
16
+ type ParagraphBorderHorizontalOutsets = {
17
+ left: number;
18
+ right: number;
19
+ };
20
+ /** Resolve the outer horizontal edges of a paragraph border box. */
21
+ declare const resolveParagraphBorderHorizontalOutsets: (borders: ParagraphBorders, hasHorizontalRule: boolean) => ParagraphBorderHorizontalOutsets;
22
+ //#endregion
23
+ export { borderStrokeToCss, resolveCssBorderStroke, resolveParagraphBorderHorizontalOutsets };
@@ -0,0 +1,39 @@
1
+ import { pointsToPixels } from "../utils/units.js";
2
+ //#region src/layout-painter/borderStroke.ts
3
+ const DEFAULT_BORDER_COLOR = "#000000";
4
+ const DEFAULT_BORDER_STYLE = "solid";
5
+ const CSS_HAIRLINE_WIDTH = 1;
6
+ const PARAGRAPH_RULE_ENDPOINT_OUTSET = pointsToPixels(1.5);
7
+ /**
8
+ * Preserve the optical weight of authored subpixel borders. CSS quantizes a
9
+ * fractional border width to one device pixel, so its color carries the
10
+ * fractional coverage while layout keeps the authored width.
11
+ */
12
+ const resolveCssBorderStroke = (border) => {
13
+ const authoredWidth = border.width ?? CSS_HAIRLINE_WIDTH;
14
+ if (authoredWidth <= 0 || authoredWidth >= CSS_HAIRLINE_WIDTH) return {
15
+ color: border.color ?? DEFAULT_BORDER_COLOR,
16
+ style: border.style ?? DEFAULT_BORDER_STYLE,
17
+ width: Math.max(CSS_HAIRLINE_WIDTH, authoredWidth)
18
+ };
19
+ const coveragePercent = Number((authoredWidth * 100).toFixed(4));
20
+ return {
21
+ color: `color-mix(in srgb, ${border.color ?? DEFAULT_BORDER_COLOR} ${coveragePercent}%, transparent)`,
22
+ style: border.style ?? DEFAULT_BORDER_STYLE,
23
+ width: CSS_HAIRLINE_WIDTH
24
+ };
25
+ };
26
+ const borderStrokeToCss = (border) => {
27
+ const stroke = resolveCssBorderStroke(border);
28
+ return `${stroke.width}px ${stroke.style} ${stroke.color}`;
29
+ };
30
+ /** Resolve the outer horizontal edges of a paragraph border box. */
31
+ const resolveParagraphBorderHorizontalOutsets = (borders, hasHorizontalRule) => {
32
+ const ruleOutset = hasHorizontalRule ? PARAGRAPH_RULE_ENDPOINT_OUTSET : 0;
33
+ return {
34
+ left: borders.left ? (borders.left.space ?? 0) + (borders.left.width ?? 0) : ruleOutset,
35
+ right: borders.right ? (borders.right.space ?? 0) + (borders.right.width ?? 0) : ruleOutset
36
+ };
37
+ };
38
+ //#endregion
39
+ export { borderStrokeToCss, resolveCssBorderStroke, resolveParagraphBorderHorizontalOutsets };
@@ -10,7 +10,7 @@ declare const IMAGE_CLASS_NAMES: {
10
10
  imageAnchored: string;
11
11
  };
12
12
  /**
13
- * Structural shape required to apply Word's per-image visual attributes:
13
+ * Structural shape required to apply OOXML per-image visual attributes:
14
14
  * `wp:srcRect` crop fractions and `a:alphaModFix` opacity. `ImageRun` and
15
15
  * `ImageBlock` both satisfy this, so callers don't need an adapter.
16
16
  */
@@ -32,10 +32,11 @@ type ImageVisualAttrs = {
32
32
  * `0`) — that bug otherwise hides every plain image behind `opacity: 0`.
33
33
  */
34
34
  declare function hasImageVisualAttrs(v: ImageVisualAttrs): boolean;
35
+ declare function hasImageCrop(v: ImageVisualAttrs): boolean;
35
36
  /**
36
- * Apply Word's `<a:srcRect>` crop to an `<img>` whose parent already has
37
+ * Apply an OOXML `<a:srcRect>` crop to an `<img>` whose parent already has
37
38
  * `overflow: hidden` and is sized to the *visible* (cropped) dimensions
38
- * (this matches `wp:extent` semantics: in Word the extent is the cropped
39
+ * (this matches `wp:extent` semantics: the extent is the cropped
39
40
  * frame, with `<a:stretch><a:fillRect/>` stretching the cropped source to
40
41
  * fill it).
41
42
  *
@@ -85,4 +86,4 @@ type RenderImageFragmentOptions = {
85
86
  */
86
87
  declare function renderImageFragment(fragment: ImageFragment, block: ImageBlock, _measure: ImageMeasure, _context: RenderContext, options?: RenderImageFragmentOptions): HTMLElement;
87
88
  //#endregion
88
- export { IMAGE_CLASS_NAMES, ImageVisualAttrs, RenderImageFragmentOptions, applyImageVisualAttrs, hasImageVisualAttrs, renderImageFragment, wrapImageWithCrop };
89
+ export { IMAGE_CLASS_NAMES, ImageVisualAttrs, RenderImageFragmentOptions, applyImageVisualAttrs, hasImageCrop, hasImageVisualAttrs, renderImageFragment, wrapImageWithCrop };
@@ -18,12 +18,15 @@ const IMAGE_CLASS_NAMES = {
18
18
  */
19
19
  function hasImageVisualAttrs(v) {
20
20
  if (v.opacity != null && v.opacity < 1) return true;
21
+ return hasImageCrop(v);
22
+ }
23
+ function hasImageCrop(v) {
21
24
  return Boolean(v.cropTop || v.cropRight || v.cropBottom || v.cropLeft);
22
25
  }
23
26
  /**
24
- * Apply Word's `<a:srcRect>` crop to an `<img>` whose parent already has
27
+ * Apply an OOXML `<a:srcRect>` crop to an `<img>` whose parent already has
25
28
  * `overflow: hidden` and is sized to the *visible* (cropped) dimensions
26
- * (this matches `wp:extent` semantics: in Word the extent is the cropped
29
+ * (this matches `wp:extent` semantics: the extent is the cropped
27
30
  * frame, with `<a:stretch><a:fillRect/>` stretching the cropped source to
28
31
  * fill it).
29
32
  *
@@ -54,8 +57,19 @@ function applyImageVisualAttrs(img, v) {
54
57
  const fh = 1 / remainingH;
55
58
  img.style.width = `${fw * 100}%`;
56
59
  img.style.height = `${fh * 100}%`;
60
+ img.style.maxWidth = "none";
61
+ img.style.maxHeight = "none";
57
62
  img.style.marginLeft = `${-left * fw * 100}%`;
58
- img.style.marginTop = `${-top * fh * 100}%`;
63
+ const existingTransform = img.style.transform;
64
+ if (existingTransform) {
65
+ const visibleCenterX = (left + 1 - right) * 50;
66
+ const visibleCenterY = (top + 1 - bottom) * 50;
67
+ img.style.transformOrigin = `${visibleCenterX}% ${visibleCenterY}%`;
68
+ }
69
+ if (top !== 0) {
70
+ const cropTransform = `translateY(${-top * 100}%)`;
71
+ img.style.transform = existingTransform ? `${existingTransform} ${cropTransform}` : cropTransform;
72
+ }
59
73
  img.style.objectFit = "fill";
60
74
  }
61
75
  /**
@@ -127,4 +141,4 @@ function renderImageFragment(fragment, block, _measure, _context, options = {})
127
141
  return containerEl;
128
142
  }
129
143
  //#endregion
130
- export { IMAGE_CLASS_NAMES, applyImageVisualAttrs, hasImageVisualAttrs, renderImageFragment, wrapImageWithCrop };
144
+ export { IMAGE_CLASS_NAMES, applyImageVisualAttrs, hasImageCrop, hasImageVisualAttrs, renderImageFragment, wrapImageWithCrop };
@@ -1,6 +1,7 @@
1
1
  import { document_d_exports } from "../types/document.js";
2
2
  import { FootnoteContent, HeaderFooterContent, ImageRun, Page, isFloatingImageRun } from "../layout-engine/types.js";
3
3
  import { RenderContext, emuToPixels } from "./renderUtils.js";
4
+ import { AnchoredImagePosition, PageGeometry } from "./anchoredImagePosition.js";
4
5
  import { BlockLookup } from "./index.js";
5
6
 
6
7
  //#region src/layout-painter/renderPage.d.ts
@@ -157,32 +158,6 @@ declare function resolveHeaderFooterFloatLeft(width: number, h: {
157
158
  align?: string;
158
159
  alignment?: string;
159
160
  } | undefined, layout: HeaderFooterLayoutInfo): string;
160
- /**
161
- * Page geometry needed to translate OOXML `relativeFrom` anchors into
162
- * painter coordinates. All values are in CSS pixels. Mirrors the upstream
163
- * (eigenpal docx-editor) `PageGeometry` shape introduced in #424 so the
164
- * anchor math stays identical across the two codebases.
165
- */
166
- type PageGeometry = {
167
- pageWidth: number;
168
- pageHeight: number;
169
- marginLeft: number;
170
- marginTop: number;
171
- marginRight: number;
172
- marginBottom: number;
173
- contentWidth: number;
174
- contentHeight: number;
175
- };
176
- /**
177
- * Resolved on-page coordinates for an anchored floating image.
178
- * `x` / `y` are in content-area-relative pixels (content origin = (0, 0)).
179
- * `side` feeds the text-wrap exclusion zone helper.
180
- */
181
- type AnchoredImagePosition = {
182
- x: number;
183
- y: number;
184
- side: "left" | "right";
185
- };
186
161
  /**
187
162
  * Resolve the on-page coordinates of an anchored floating image.
188
163
  *
@@ -290,4 +265,4 @@ declare function findPageShellForPmPos(container: HTMLElement, pmPos: number): {
290
265
  isExact: boolean;
291
266
  } | null;
292
267
  //#endregion
293
- export { AnchoredImagePosition, FootnoteRenderItem, type HeaderFooterContent, HeaderFooterLayoutInfo, PAGE_CLASS_NAMES, PAINTER_PAINTED_EVENT, PageFloatingImage, PageGeometry, PainterPaintedDetail, type RenderContext, RenderPageOptions, applySectionHeaderFooterOptions, calculateFootnoteAreaRenderHeight, computePageFingerprint, emuToPixels, findPageShellForPmPos, floatingTableReservesBand, getDefaultPageFontFamily, isFloatingImageRun, renderAllPagesNow, renderFloatingImagesLayer, renderFootnoteArea, renderPage, renderPages, resolveAnchoredImagePosition, resolveHeaderFooterFloatLeft };
268
+ export { type AnchoredImagePosition, FootnoteRenderItem, type HeaderFooterContent, HeaderFooterLayoutInfo, PAGE_CLASS_NAMES, PAINTER_PAINTED_EVENT, PageFloatingImage, type PageGeometry, PainterPaintedDetail, type RenderContext, RenderPageOptions, applySectionHeaderFooterOptions, calculateFootnoteAreaRenderHeight, computePageFingerprint, emuToPixels, findPageShellForPmPos, floatingTableReservesBand, getDefaultPageFontFamily, isFloatingImageRun, renderAllPagesNow, renderFloatingImagesLayer, renderFootnoteArea, renderPage, renderPages, resolveAnchoredImagePosition, resolveHeaderFooterFloatLeft };