@stll/folio-core 0.25.2 → 0.25.3

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.
@@ -1,6 +1,7 @@
1
1
  import { convertBulletToUnicode } from "../../docx/bulletMarkers.js";
2
2
  import { resolveDocumentGridLinePitch } from "../../docx/documentGrid.js";
3
3
  import { formatOoxmlCounter } from "../../docx/ooxmlCounterFormatter.js";
4
+ import { setHyperlinkInstanceIndex } from "../../layout-engine/measure/hyperlinkInstance.js";
4
5
  import { setParagraphFrame } from "../../layout-engine/paragraphFrame.js";
5
6
  import { setTextBoxGroupId } from "../../layout-engine/textBoxGroup.js";
6
7
  import { DEFAULT_TEXTBOX_MARGINS } from "../../layout-engine/types.js";
@@ -267,6 +268,7 @@ function extractRunFormatting(marks, theme) {
267
268
  const attrs = expectHyperlinkMarkAttrs(mark);
268
269
  const link = { href: attrs.href };
269
270
  if (attrs.tooltip !== void 0) link.tooltip = attrs.tooltip;
271
+ if (attrs._docxHyperlinkIndex !== void 0) setHyperlinkInstanceIndex(link, attrs._docxHyperlinkIndex);
270
272
  formatting.hyperlink = link;
271
273
  break;
272
274
  }
@@ -468,10 +470,7 @@ const TOC_STYLE_ID = /^TOC\d*$/iu;
468
470
  */
469
471
  function stripTocHyperlinkStyle(formatting) {
470
472
  if (!formatting.hyperlink) return;
471
- formatting.hyperlink = {
472
- ...formatting.hyperlink,
473
- noDefaultStyle: true
474
- };
473
+ formatting.hyperlink.noDefaultStyle = true;
475
474
  delete formatting.color;
476
475
  delete formatting.underline;
477
476
  }
@@ -1,6 +1,7 @@
1
1
  import { measuredLineAdvance, measuredLineContentOffset } from "../../layout-engine/lineFlow.js";
2
2
  import { buildRunFontStyle, findCharacterAtX } from "../../layout-engine/measure/measureHelpers.js";
3
3
  import { measureRun } from "../../layout-engine/measure/measureProvider.js";
4
+ import { resolvePhysicalParagraphInlineLayout } from "../../utils/paragraphInlineLayout.js";
4
5
  import { inlineImageBoundingBox } from "../../utils/rotationBoundingBox.js";
5
6
  //#region src/layout-bridge/engine/clickToPosition.ts
6
7
  /**
@@ -120,24 +121,12 @@ function findLineAtY(measure, localY, fromLine, toLine) {
120
121
  if (toLine > fromLine) return Math.min(toLine - 1, measure.lines.length - 1);
121
122
  return null;
122
123
  }
123
- /**
124
- * Find the character position at an X coordinate within a line.
125
- *
126
- * Uses canvas text measurement for pixel-perfect accuracy.
127
- *
128
- * @param block - The paragraph block.
129
- * @param line - The measured line.
130
- * @param x - X coordinate relative to the line's start position.
131
- * @param availableWidth - Available width for alignment calculations.
132
- * @returns Character offset and PM position.
133
- */
134
- function findCharacterInLine(block, line, x, availableWidth) {
124
+ function findCharacterInLine({ block, line, x, availableWidth, alignment }) {
135
125
  const { pmStart, pmEnd } = computeLinePmRange(block, line);
136
126
  if (pmStart === void 0 || pmEnd === void 0) return {
137
127
  charOffset: 0,
138
128
  pmPosition: block.pmStart ?? 0
139
129
  };
140
- const alignment = block.attrs?.alignment ?? "left";
141
130
  let alignmentOffset = 0;
142
131
  if (alignment === "center") alignmentOffset = Math.max(0, (availableWidth - line.width) / 2);
143
132
  else if (alignment === "right") alignmentOffset = Math.max(0, availableWidth - line.width);
@@ -236,11 +225,15 @@ function clickToPositionInParagraph(fragmentHit) {
236
225
  if (lineIndex === null) return null;
237
226
  const line = paragraphMeasure.lines[lineIndex];
238
227
  if (!line) return null;
239
- const indent = paragraphBlock.attrs?.indent;
240
- const indentLeft = indent?.left ?? 0;
241
- const indentRight = indent?.right ?? 0;
228
+ const { alignment, indentLeft, indentRight } = resolvePhysicalParagraphInlineLayout(paragraphBlock);
242
229
  const availableWidth = Math.max(0, fragment.width - indentLeft - indentRight);
243
- const { charOffset, pmPosition } = findCharacterInLine(paragraphBlock, line, localX - indentLeft, availableWidth);
230
+ const { charOffset, pmPosition } = findCharacterInLine({
231
+ block: paragraphBlock,
232
+ line,
233
+ x: localX - indentLeft,
234
+ availableWidth,
235
+ alignment
236
+ });
244
237
  return {
245
238
  pmPosition,
246
239
  charOffset,
@@ -254,7 +247,7 @@ function clickToPositionInParagraph(fragmentHit) {
254
247
  * @returns PM position, or null if mapping fails.
255
248
  */
256
249
  function clickToPositionInTableCell(tableCellHit) {
257
- const { cellBlock, cellMeasure, cellLocalX, cellLocalY } = tableCellHit;
250
+ const { cellBlock, cellMeasure, cellLocalX, cellLocalY, cellContentWidth } = tableCellHit;
258
251
  if (!cellBlock || !cellMeasure) return null;
259
252
  return clickToPositionInParagraph({
260
253
  fragment: {
@@ -262,7 +255,7 @@ function clickToPositionInTableCell(tableCellHit) {
262
255
  blockId: cellBlock.id,
263
256
  x: 0,
264
257
  y: 0,
265
- width: getMaxLineWidth(cellMeasure.lines, 100),
258
+ width: cellContentWidth,
266
259
  fromLine: 0,
267
260
  toLine: cellMeasure.lines.length,
268
261
  height: cellMeasure.totalHeight
@@ -371,21 +364,13 @@ function positionToX(block, measure, pmPosition, _fragmentWidth) {
371
364
  }
372
365
  return null;
373
366
  }
374
- const getMaxLineWidth = (lines, fallback) => {
375
- let maxWidth = fallback;
376
- for (const line of lines) maxWidth = Math.max(maxWidth, line.width);
377
- return maxWidth;
378
- };
379
367
  /**
380
368
  * Get the bounding rect for a PM position (for caret rendering).
381
369
  */
382
370
  function getPositionRect(block, measure, pmPosition, fragmentX, fragmentY, fragmentWidth, fromLine) {
383
371
  const result = positionToX(block, measure, pmPosition, fragmentWidth);
384
372
  if (!result) return null;
385
- const alignment = block.attrs?.alignment ?? "left";
386
- const indent = block.attrs?.indent;
387
- const indentLeft = indent?.left ?? 0;
388
- const indentRight = indent?.right ?? 0;
373
+ const { alignment, indentLeft, indentRight } = resolvePhysicalParagraphInlineLayout(block);
389
374
  const availableWidth = Math.max(0, fragmentWidth - indentLeft - indentRight);
390
375
  const line = measure.lines[result.lineIndex];
391
376
  if (!line) return null;
@@ -57,6 +57,8 @@ type TableCellHit = {
57
57
  cellMeasure?: ParagraphMeasure;
58
58
  /** X position relative to cell content area. */
59
59
  cellLocalX: number;
60
+ /** Width of the cell content area after physical padding. */
61
+ cellContentWidth: number;
60
62
  /** Y position relative to cell content area. */
61
63
  cellLocalY: number;
62
64
  };
@@ -1,6 +1,8 @@
1
1
  import { getHeaderRowsHeight } from "../../layout-engine/index.js";
2
2
  import { measuredLineRangeHeight } from "../../layout-engine/lineFlow.js";
3
- import { getTableRowLeadingWidth } from "../../layout-engine/types.js";
3
+ import { getTableCellContentWidth } from "../../layout-engine/measure/tableCellFloating.js";
4
+ import { buildTableCellGrid, buildTableCellPlacements } from "../../layout-engine/measure/tableCellGrid.js";
5
+ import { resolveTableCellPadding } from "../../layout-engine/types.js";
4
6
  //#region src/layout-bridge/engine/hitTest.ts
5
7
  /**
6
8
  * Hit Testing Utilities
@@ -214,21 +216,33 @@ function hitTestTableCell(pageHit, blocks, measures, pagePoint) {
214
216
  const rowMeasure = tableMeasure.rows[rowIndex];
215
217
  const row = tableBlock.rows[rowIndex];
216
218
  if (!rowMeasure || !row) continue;
217
- let colX = getTableRowLeadingWidth(row, tableMeasure.columnWidths);
218
219
  let colIndex = -1;
220
+ let cellLeft = 0;
219
221
  if (rowMeasure.cells.length === 0 || row.cells.length === 0) continue;
220
- for (let c = 0; c < rowMeasure.cells.length; c++) {
221
- const cellMeasure = rowMeasure.cells[c];
222
- if (localX >= colX && localX < colX + cellMeasure.width) {
222
+ const cellPlacements = buildTableCellPlacements({
223
+ grid: buildTableCellGrid(tableBlock.rows, tableMeasure.columnWidths.length),
224
+ columnWidths: tableMeasure.columnWidths,
225
+ bidi: tableBlock.bidi === true
226
+ });
227
+ let nearestDistance = Infinity;
228
+ for (let c = 0; c < row.cells.length; c++) {
229
+ const cell = row.cells[c];
230
+ if (!cell) continue;
231
+ const placement = cellPlacements.get(cell);
232
+ if (!placement) continue;
233
+ if (localX >= placement.left && localX < placement.left + placement.width) {
223
234
  colIndex = c;
235
+ cellLeft = placement.left;
224
236
  break;
225
237
  }
226
- colX += cellMeasure.width;
227
- }
228
- if (colIndex === -1) {
229
- colIndex = rowMeasure.cells.length - 1;
230
- if (colIndex < 0) continue;
238
+ const distance = Math.min(Math.abs(localX - placement.left), Math.abs(localX - placement.left - placement.width));
239
+ if (distance < nearestDistance) {
240
+ nearestDistance = distance;
241
+ colIndex = c;
242
+ cellLeft = placement.left;
243
+ }
231
244
  }
245
+ if (colIndex === -1) continue;
232
246
  const cellMeasure = rowMeasure.cells[colIndex];
233
247
  const cell = row.cells[colIndex];
234
248
  if (!cellMeasure || !cell) continue;
@@ -239,8 +253,6 @@ function hitTestTableCell(pageHit, blocks, measures, pagePoint) {
239
253
  rowTop = headerHeight;
240
254
  for (let r = tableFragment.fromRow; r < rowIndex; r++) rowTop += tableMeasure.rows[r]?.height ?? 0;
241
255
  }
242
- let colLeft = 0;
243
- for (let c = 0; c < colIndex; c++) colLeft += rowMeasure.cells[c]?.width ?? 0;
244
256
  let cellBlock;
245
257
  let cellBlockMeasure;
246
258
  if (cell.blocks.length > 0) {
@@ -251,7 +263,9 @@ function hitTestTableCell(pageHit, blocks, measures, pagePoint) {
251
263
  cellBlockMeasure = firstMeasure;
252
264
  }
253
265
  }
254
- const cellLocalX = localX - colLeft;
266
+ const { left: padLeft } = resolveTableCellPadding(cell);
267
+ const cellLocalX = localX - cellLeft - padLeft;
268
+ const cellContentWidth = getTableCellContentWidth(cell, cellMeasure);
255
269
  const clipOffset = isClickOnHeader ? 0 : tableFragment.topClip ?? 0;
256
270
  const cellLocalY = localY - rowTop + clipOffset;
257
271
  return {
@@ -264,6 +278,7 @@ function hitTestTableCell(pageHit, blocks, measures, pagePoint) {
264
278
  ...cellBlock !== void 0 ? { cellBlock } : {},
265
279
  ...cellBlockMeasure !== void 0 ? { cellMeasure: cellBlockMeasure } : {},
266
280
  cellLocalX: Math.max(0, cellLocalX),
281
+ cellContentWidth,
267
282
  cellLocalY: Math.max(0, cellLocalY)
268
283
  };
269
284
  }
@@ -4,7 +4,9 @@ import { buildRunFontStyle } from "../../layout-engine/measure/measureHelpers.js
4
4
  import { measureParagraph } from "../../layout-engine/measure/measureParagraph.js";
5
5
  import { measureRun } from "../../layout-engine/measure/measureProvider.js";
6
6
  import { buildTableCellFloatingZones, getTableCellContentWidth, getTableCellFloatingImages } from "../../layout-engine/measure/tableCellFloating.js";
7
- import { getTableRowLeadingWidth, resolveTableCellPadding } from "../../layout-engine/types.js";
7
+ import { buildTableCellGrid, buildTableCellPlacements } from "../../layout-engine/measure/tableCellGrid.js";
8
+ import { resolveTableCellPadding } from "../../layout-engine/types.js";
9
+ import { resolvePhysicalParagraphInlineLayout } from "../../utils/paragraphInlineLayout.js";
8
10
  import { inlineImageBoundingBox } from "../../utils/rotationBoundingBox.js";
9
11
  import { getPageTop } from "./hitTest.js";
10
12
  //#region src/layout-bridge/engine/selectionRects.ts
@@ -246,6 +248,7 @@ function selectionToRects(layout, blocks, measures, from, to) {
246
248
  const selFrom = Math.min(from, to);
247
249
  const selTo = Math.max(from, to);
248
250
  const rects = [];
251
+ const tableCellPlacements = /* @__PURE__ */ new WeakMap();
249
252
  for (let pageIndex = 0; pageIndex < layout.pages.length; pageIndex++) {
250
253
  const page = layout.pages[pageIndex];
251
254
  const pageTopY = getPageTop(layout, pageIndex);
@@ -260,6 +263,8 @@ function selectionToRects(layout, blocks, measures, from, to) {
260
263
  const paragraphBlock = block;
261
264
  const paragraphMeasure = measure;
262
265
  const paragraphFragment = fragment;
266
+ const { alignment, indentLeft, indentRight } = resolvePhysicalParagraphInlineLayout(paragraphBlock);
267
+ const availableWidth = Math.max(0, fragment.width - indentLeft - indentRight);
263
268
  const intersectingLines = findLinesInRange(paragraphBlock, paragraphMeasure, selFrom, selTo);
264
269
  for (const { line, index } of intersectingLines) {
265
270
  if (index < paragraphFragment.fromLine || index >= paragraphFragment.toLine) continue;
@@ -272,13 +277,8 @@ function selectionToRects(layout, blocks, measures, from, to) {
272
277
  if (!isEmptyLine && sliceFrom >= sliceTo) continue;
273
278
  const charOffsetFrom = pmPosToCharOffset(paragraphBlock, line, sliceFrom);
274
279
  const charOffsetTo = pmPosToCharOffset(paragraphBlock, line, sliceTo);
275
- const indent = paragraphBlock.attrs?.indent;
276
- const indentLeft = indent?.left ?? 0;
277
- const indentRight = indent?.right ?? 0;
278
- const availableWidth = Math.max(0, fragment.width - indentLeft - indentRight);
279
280
  const startX = charOffsetToX(paragraphBlock, line, charOffsetFrom, availableWidth);
280
281
  const endX = charOffsetToX(paragraphBlock, line, charOffsetTo, availableWidth);
281
- const alignment = paragraphBlock.attrs?.alignment ?? "left";
282
282
  let alignmentOffset = 0;
283
283
  if (alignment === "center") alignmentOffset = Math.max(0, (availableWidth - line.width) / 2);
284
284
  else if (alignment === "right") alignmentOffset = Math.max(0, availableWidth - line.width);
@@ -305,6 +305,15 @@ function selectionToRects(layout, blocks, measures, from, to) {
305
305
  const tableBlock = block;
306
306
  const tableMeasure = measure;
307
307
  const tableFragment = fragment;
308
+ let cellPlacements = tableCellPlacements.get(tableBlock);
309
+ if (!cellPlacements) {
310
+ cellPlacements = buildTableCellPlacements({
311
+ grid: buildTableCellGrid(tableBlock.rows, tableMeasure.columnWidths.length),
312
+ columnWidths: tableMeasure.columnWidths,
313
+ bidi: tableBlock.bidi === true
314
+ });
315
+ tableCellPlacements.set(tableBlock, cellPlacements);
316
+ }
308
317
  const hdrCount = tableFragment.headerRowCount ?? 0;
309
318
  let rowY = hdrCount > 0 && tableFragment.continuesFromPrev ? getHeaderRowsHeight(tableMeasure, hdrCount) : 0;
310
319
  for (let rowIndex = tableFragment.fromRow; rowIndex < tableFragment.toRow && rowIndex < tableBlock.rows.length; rowIndex++) {
@@ -313,11 +322,12 @@ function selectionToRects(layout, blocks, measures, from, to) {
313
322
  if (!row || !rowMeasure) continue;
314
323
  const clipTop = tableFragment.topClip ?? 0;
315
324
  const clipBottom = tableFragment.bottomClip ?? rowMeasure.height;
316
- let cellX = getTableRowLeadingWidth(row, tableMeasure.columnWidths);
317
325
  for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex++) {
318
326
  const cell = row.cells[cellIndex];
319
327
  const cellMeasure = rowMeasure.cells[cellIndex];
320
328
  if (!cell || !cellMeasure) continue;
329
+ const placement = cellPlacements.get(cell);
330
+ if (!placement) continue;
321
331
  const contentWidth = getTableCellContentWidth(cell, cellMeasure);
322
332
  const floatingZones = buildTableCellFloatingZones(getTableCellFloatingImages(cell, cellMeasure, contentWidth), contentWidth);
323
333
  const contentOffsetX = getCellContentOffsetX(cell);
@@ -340,6 +350,8 @@ function selectionToRects(layout, blocks, measures, from, to) {
340
350
  floatingZones,
341
351
  paragraphYOffset: blockY
342
352
  });
353
+ const { alignment, indentLeft, indentRight } = resolvePhysicalParagraphInlineLayout(paragraphBlock);
354
+ const availableWidth = Math.max(0, contentWidth - indentLeft - indentRight);
343
355
  const intersectingLines = findLinesInRange(paragraphBlock, paragraphMeasure, selFrom, selTo);
344
356
  for (const { line, index } of intersectingLines) {
345
357
  const range = computeLinePmRange(paragraphBlock, line);
@@ -351,13 +363,16 @@ function selectionToRects(layout, blocks, measures, from, to) {
351
363
  if (!isEmptyLine && sliceFrom >= sliceTo) continue;
352
364
  const charOffsetFrom = pmPosToCharOffset(paragraphBlock, line, sliceFrom);
353
365
  const charOffsetTo = pmPosToCharOffset(paragraphBlock, line, sliceTo);
354
- const startX = charOffsetToX(paragraphBlock, line, charOffsetFrom, contentWidth);
355
- const endX = charOffsetToX(paragraphBlock, line, charOffsetTo, contentWidth);
366
+ const startX = charOffsetToX(paragraphBlock, line, charOffsetFrom, availableWidth);
367
+ const endX = charOffsetToX(paragraphBlock, line, charOffsetTo, availableWidth);
368
+ let alignmentOffset = 0;
369
+ if (alignment === "center") alignmentOffset = Math.max(0, (availableWidth - line.width) / 2);
370
+ else if (alignment === "right") alignmentOffset = Math.max(0, availableWidth - line.width);
356
371
  const lineY = measuredLineContentOffset(paragraphMeasure.lines, 0, index);
357
372
  const clippedLineY = contentOffsetY + blockY + lineY;
358
373
  if (clippedLineY + line.lineHeight <= clipTop || clippedLineY >= clipBottom) continue;
359
374
  rects.push({
360
- x: tableFragment.x + cellX + contentOffsetX + Math.min(startX, endX),
375
+ x: tableFragment.x + placement.left + contentOffsetX + indentLeft + alignmentOffset + Math.min(startX, endX),
361
376
  y: tableFragment.y + rowY + clippedLineY - clipTop + pageTopY,
362
377
  width: isEmptyLine ? EMPTY_PARAGRAPH_SLIVER_WIDTH : Math.max(1, Math.abs(endX - startX)),
363
378
  height: line.lineHeight,
@@ -366,7 +381,6 @@ function selectionToRects(layout, blocks, measures, from, to) {
366
381
  }
367
382
  blockY += paragraphMeasure.totalHeight;
368
383
  }
369
- cellX += cellMeasure.width;
370
384
  }
371
385
  rowY += rowMeasure.height;
372
386
  }
@@ -419,12 +433,9 @@ function getCaretPosition(layout, blocks, measures, pmPosition) {
419
433
  if (range.pmStart === void 0 || range.pmEnd === void 0) continue;
420
434
  if (pmPosition >= range.pmStart && pmPosition <= range.pmEnd) {
421
435
  const charOffset = pmPosToCharOffset(paragraphBlock, line, pmPosition);
422
- const indent = paragraphBlock.attrs?.indent;
423
- const indentLeft = indent?.left ?? 0;
424
- const indentRight = indent?.right ?? 0;
436
+ const { alignment, indentLeft, indentRight } = resolvePhysicalParagraphInlineLayout(paragraphBlock);
425
437
  const availableWidth = Math.max(0, fragment.width - indentLeft - indentRight);
426
438
  const x = charOffsetToX(paragraphBlock, line, charOffset, availableWidth);
427
- const alignment = paragraphBlock.attrs?.alignment ?? "left";
428
439
  let alignmentOffset = 0;
429
440
  if (alignment === "center") alignmentOffset = Math.max(0, (availableWidth - line.width) / 2);
430
441
  else if (alignment === "right") alignmentOffset = Math.max(0, availableWidth - line.width);
@@ -3,6 +3,7 @@ import { resolveSectionHeaderFooterRefs } from "./headerFooterRefs.js";
3
3
  import { calculateChainHeight, computeKeepNextChains, getMidChainIndices, hasKeepLines, hasPageBreakBefore } from "./keep-together.js";
4
4
  import { measuredLineAdvance } from "./lineFlow.js";
5
5
  import { resolveFloatingTableX } from "./measure/floatingTablePosition.js";
6
+ import { resolveTableInlinePlacement } from "./measure/tableInlinePlacement.js";
6
7
  import { createPaginator } from "./paginator.js";
7
8
  import { getParagraphFragmentPmRange } from "./paragraphFragmentRange.js";
8
9
  import { collapseParagraphSpacing, getParagraphSpacingAfter, getParagraphSpacingBefore, isEmptyParagraph, paragraphsShareStyle, resolveEffectiveParagraphSpacingTree } from "./paragraphSpacing.js";
@@ -521,15 +522,11 @@ function layoutTable(block, measure, paginator, footnoteHeightById) {
521
522
  const breakInfo = buildTableRowBreakInfo(block, measure);
522
523
  const verticallyMergedRows = getVerticallyMergedRows(block);
523
524
  const computeTableX = (columnIndex) => {
524
- let x = paginator.getColumnX(columnIndex);
525
- if (block.justification === "center") x += (paginator.columnWidth - measure.totalWidth) / 2;
526
- else if (block.justification === "right") x = x + paginator.columnWidth - measure.totalWidth;
527
- else if (block.indent !== void 0) x += block.indent;
528
- else {
529
- const leadingCellMargin = block.rows.at(0)?.cells.at(0)?.padding?.left ?? 0;
530
- x -= leadingCellMargin;
531
- }
532
- return x;
525
+ const x = paginator.getColumnX(columnIndex);
526
+ const placement = resolveTableInlinePlacement(block);
527
+ if (placement.alignment === "center") return x + (paginator.columnWidth - measure.totalWidth) / 2;
528
+ if (placement.alignment === "right") return x + paginator.columnWidth - measure.totalWidth - placement.offset;
529
+ return x + placement.offset;
533
530
  };
534
531
  const getCurrentRowCapacity = (state = paginator.getCurrentState()) => state.rawContentBottom - state.topMargin;
535
532
  const hasAdjacentPriorTableRows = (rowIndex, state = paginator.getCurrentState()) => {
@@ -0,0 +1,6 @@
1
+ import { HyperlinkInfo } from "../types.js";
2
+ //#region src/layout-engine/measure/hyperlinkInstance.d.ts
3
+ declare const setHyperlinkInstanceIndex: (hyperlink: HyperlinkInfo, instanceIndex: number) => void;
4
+ declare const getHyperlinkInstanceIndex: (hyperlink: HyperlinkInfo) => number | undefined;
5
+ //#endregion
6
+ export { getHyperlinkInstanceIndex, setHyperlinkInstanceIndex };
@@ -0,0 +1,8 @@
1
+ //#region src/layout-engine/measure/hyperlinkInstance.ts
2
+ const hyperlinkInstanceIndexes = /* @__PURE__ */ new WeakMap();
3
+ const setHyperlinkInstanceIndex = (hyperlink, instanceIndex) => {
4
+ hyperlinkInstanceIndexes.set(hyperlink, instanceIndex);
5
+ };
6
+ const getHyperlinkInstanceIndex = (hyperlink) => hyperlinkInstanceIndexes.get(hyperlink);
7
+ //#endregion
8
+ export { getHyperlinkInstanceIndex, setHyperlinkInstanceIndex };
@@ -5,10 +5,24 @@ type TableCellGrid = {
5
5
  sourceCellsByRow: ReadonlyMap<number, ReadonlyMap<number, TableCell>>;
6
6
  sourceColumnsByCell: ReadonlyMap<TableCell, number>;
7
7
  };
8
+ type TableCellPlacement = {
9
+ sourceColumn: number;
10
+ columnSpan: number;
11
+ left: number;
12
+ width: number;
13
+ };
14
+ type TableCellPlacements = ReadonlyMap<TableCell, TableCellPlacement>;
15
+ type BuildTableCellPlacementsOptions = {
16
+ grid: TableCellGrid;
17
+ columnWidths: readonly number[];
18
+ bidi: boolean;
19
+ };
8
20
  declare const buildTableCellGrid: (rows: readonly TableRow[], columnCount: number) => TableCellGrid;
9
21
  declare const getFirstAvailableColumn: (grid: TableCellGrid, rowIndex: number, startingColumn: number) => number;
10
22
  declare const getSourceCellAt: (grid: TableCellGrid, rowIndex: number, columnIndex: number) => TableCell | undefined;
11
23
  declare const getSourceCellColumn: (grid: TableCellGrid, cell: TableCell) => number | undefined;
24
+ /** Resolve all source cells to canonical logical columns and physical boxes. */
25
+ declare const buildTableCellPlacements: ({ grid, columnWidths, bidi }: BuildTableCellPlacementsOptions) => TableCellPlacements;
12
26
  declare const getTableCellVerticalBorderHeight: (grid: TableCellGrid, cell: TableCell | undefined, rowIndex: number) => number;
13
27
  //#endregion
14
- export { TableCellGrid, buildTableCellGrid, getFirstAvailableColumn, getSourceCellAt, getSourceCellColumn, getTableCellVerticalBorderHeight };
28
+ export { TableCellGrid, TableCellPlacement, TableCellPlacements, buildTableCellGrid, buildTableCellPlacements, getFirstAvailableColumn, getSourceCellAt, getSourceCellColumn, getTableCellVerticalBorderHeight };
@@ -6,10 +6,11 @@ const buildTableCellGrid = (rows, columnCount) => {
6
6
  for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
7
7
  const row = rows[rowIndex];
8
8
  if (!row) continue;
9
- let columnIndex = firstAvailableColumn(occupiedColumnsByRow.get(rowIndex), row.gridBefore ?? 0);
9
+ const gridBefore = Math.max(0, Math.min(columnCount, Math.trunc(row.gridBefore ?? 0)));
10
+ let columnIndex = firstAvailableColumn(occupiedColumnsByRow.get(rowIndex), gridBefore);
10
11
  for (const cell of row.cells) {
11
- const columnSpan = cell.colSpan ?? 1;
12
- const rowSpan = cell.rowSpan ?? 1;
12
+ const columnSpan = Math.max(1, Math.trunc(cell.colSpan ?? 1));
13
+ const rowSpan = Math.max(1, Math.trunc(cell.rowSpan ?? 1));
13
14
  sourceColumnsByCell.set(cell, columnIndex);
14
15
  const rowEnd = Math.min(rows.length, rowIndex + rowSpan);
15
16
  const columnEnd = Math.min(columnCount, columnIndex + columnSpan);
@@ -17,9 +18,9 @@ const buildTableCellGrid = (rows, columnCount) => {
17
18
  const cellsByColumn = getOrCreateCellRow(sourceCellsByRow, gridRowIndex);
18
19
  for (let gridColumnIndex = columnIndex; gridColumnIndex < columnEnd; gridColumnIndex++) cellsByColumn.set(gridColumnIndex, cell);
19
20
  }
20
- if (rowSpan > 1) for (let spannedRowIndex = rowIndex + 1; spannedRowIndex < rowIndex + rowSpan; spannedRowIndex++) {
21
+ if (rowSpan > 1) for (let spannedRowIndex = rowIndex + 1; spannedRowIndex < rowEnd; spannedRowIndex++) {
21
22
  const occupied = getOrCreateOccupiedRow(occupiedColumnsByRow, spannedRowIndex);
22
- for (let columnOffset = 0; columnOffset < columnSpan; columnOffset++) occupied.add(columnIndex + columnOffset);
23
+ for (let gridColumnIndex = columnIndex; gridColumnIndex < columnEnd; gridColumnIndex++) occupied.add(gridColumnIndex);
23
24
  }
24
25
  columnIndex = firstAvailableColumn(occupiedColumnsByRow.get(rowIndex), columnIndex + columnSpan);
25
26
  }
@@ -33,6 +34,28 @@ const buildTableCellGrid = (rows, columnCount) => {
33
34
  const getFirstAvailableColumn = (grid, rowIndex, startingColumn) => firstAvailableColumn(grid.occupiedColumnsByRow.get(rowIndex), startingColumn);
34
35
  const getSourceCellAt = (grid, rowIndex, columnIndex) => grid.sourceCellsByRow.get(rowIndex)?.get(columnIndex);
35
36
  const getSourceCellColumn = (grid, cell) => grid.sourceColumnsByCell.get(cell);
37
+ /** Resolve all source cells to canonical logical columns and physical boxes. */
38
+ const buildTableCellPlacements = ({ grid, columnWidths, bidi }) => {
39
+ const columnOffsets = [0];
40
+ for (const columnWidth of columnWidths) columnOffsets.push((columnOffsets.at(-1) ?? 0) + columnWidth);
41
+ const tableWidth = columnOffsets.at(-1) ?? 0;
42
+ const placements = /* @__PURE__ */ new Map();
43
+ for (const [cell, sourceColumn] of grid.sourceColumnsByCell) {
44
+ if (sourceColumn < 0 || sourceColumn >= columnWidths.length) continue;
45
+ const declaredColumnSpan = Math.max(1, Math.trunc(cell.colSpan ?? 1));
46
+ const columnSpan = Math.min(declaredColumnSpan, columnWidths.length - sourceColumn);
47
+ const logicalLeft = columnOffsets.at(sourceColumn) ?? 0;
48
+ const logicalRight = columnOffsets.at(sourceColumn + columnSpan) ?? logicalLeft;
49
+ const width = logicalRight - logicalLeft;
50
+ placements.set(cell, {
51
+ sourceColumn,
52
+ columnSpan,
53
+ left: bidi ? tableWidth - logicalRight : logicalLeft,
54
+ width
55
+ });
56
+ }
57
+ return placements;
58
+ };
36
59
  const getTableCellVerticalBorderHeight = (grid, cell, rowIndex) => {
37
60
  const sourceColumn = cell ? getSourceCellColumn(grid, cell) : void 0;
38
61
  const aboveBottom = (sourceColumn === void 0 ? void 0 : getSourceCellAt(grid, rowIndex - 1, sourceColumn))?.borders?.bottom;
@@ -59,4 +82,4 @@ const getOrCreateOccupiedRow = (occupiedColumnsByRow, rowIndex) => {
59
82
  return occupiedColumns;
60
83
  };
61
84
  //#endregion
62
- export { buildTableCellGrid, getFirstAvailableColumn, getSourceCellAt, getSourceCellColumn, getTableCellVerticalBorderHeight };
85
+ export { buildTableCellGrid, buildTableCellPlacements, getFirstAvailableColumn, getSourceCellAt, getSourceCellColumn, getTableCellVerticalBorderHeight };
@@ -0,0 +1,12 @@
1
+ import { TableBlock } from "../types.js";
2
+ //#region src/layout-engine/measure/tableInlinePlacement.d.ts
3
+ type TableInlinePlacement = {
4
+ alignment: "center";
5
+ } | {
6
+ alignment: "left" | "right";
7
+ offset: number;
8
+ };
9
+ /** Resolve an inline table's horizontal anchor without losing RTL leading-edge semantics. */
10
+ declare const resolveTableInlinePlacement: (table: Pick<TableBlock, "bidi" | "indent" | "justification" | "rows">) => TableInlinePlacement;
11
+ //#endregion
12
+ export { resolveTableInlinePlacement };
@@ -0,0 +1,22 @@
1
+ import { resolveTableCellPadding } from "../types.js";
2
+ //#region src/layout-engine/measure/tableInlinePlacement.ts
3
+ /** Resolve an inline table's horizontal anchor without losing RTL leading-edge semantics. */
4
+ const resolveTableInlinePlacement = (table) => {
5
+ if (table.justification === "center") return { alignment: "center" };
6
+ if (table.justification === "right") return {
7
+ alignment: "right",
8
+ offset: 0
9
+ };
10
+ const firstCell = table.rows.at(0)?.cells.at(0);
11
+ const firstCellPadding = firstCell ? resolveTableCellPadding(firstCell) : void 0;
12
+ if (table.justification === "left" || table.bidi !== true) return {
13
+ alignment: "left",
14
+ offset: table.indent ?? -(firstCellPadding?.left ?? 0)
15
+ };
16
+ return {
17
+ alignment: "right",
18
+ offset: table.indent ?? -(firstCellPadding?.right ?? 0)
19
+ };
20
+ };
21
+ //#endregion
22
+ export { resolveTableInlinePlacement };
@@ -632,7 +632,7 @@ type TableBlock = {
632
632
  layout?: "fixed" | "autofit";
633
633
  /** Table horizontal alignment */
634
634
  justification?: "left" | "center" | "right";
635
- /** Table indent from left margin (in pixels, from w:tblInd) */
635
+ /** Table indent from the leading margin (in pixels, from w:tblInd). */
636
636
  indent?: number;
637
637
  /** Right-to-left column order (w:bidiVisual): logical column 0 paints on the right. */
638
638
  bidi?: boolean;
@@ -1,5 +1,6 @@
1
1
  import { HyperlinkInfo, MeasuredLine, ParagraphBlock, ParagraphBorders, ParagraphFragment, ParagraphMeasure, Run, TabStop } from "../layout-engine/types.js";
2
2
  import { RenderContext } from "./renderUtils.js";
3
+ import { PhysicalParagraphInlineLayout } from "../utils/paragraphInlineLayout.js";
3
4
  //#region src/layout-painter/renderParagraph.d.ts
4
5
  /**
5
6
  * CSS class names for paragraph rendering
@@ -70,6 +71,8 @@ type RenderLineOptions = {
70
71
  firstLineIndentPx?: number;
71
72
  /** Paragraph base direction for logical first-line indentation. */
72
73
  isRtl?: boolean;
74
+ /** Explicit alignment after resolving authored bidi physical sides. */
75
+ explicitAlignment?: PhysicalParagraphInlineLayout["explicitAlignment"];
73
76
  /** Full paragraph content-box width before physical indents. */
74
77
  contentWidthPx?: number;
75
78
  /** Line-specific floating image margins (calculated per-line based on Y overlap) */
@@ -2,6 +2,7 @@ import { ommlToMathml } from "../docx/mathToMathml.js";
2
2
  import { parseXmlDocument } from "../docx/xmlParser.js";
3
3
  import { evaluateFieldInstruction } from "../fields/evaluateField.js";
4
4
  import { hasComplexScriptFormatting, resolveComplexScriptFormatting } from "../layout-engine/measure/complexScriptFormatting.js";
5
+ import { getHyperlinkInstanceIndex } from "../layout-engine/measure/hyperlinkInstance.js";
5
6
  import { getListMarkerInlineWidth, getListMarkerVisualOffset, resolveListMarkerFont } from "../layout-engine/measure/listMarkerWidth.js";
6
7
  import { DOCX_SCRIPT_FONT_SCALE } from "../layout-engine/measure/measureHelpers.js";
7
8
  import { FONT_KERNING_MODE, countCompressibleSpaces, getFontKerningMode, getRunFontKerningMode, toPaintedText } from "../layout-engine/measure/textMeasurementPolicy.js";
@@ -10,7 +11,7 @@ import { calculateTabWidth } from "../prosemirror/utils/tabCalculator.js";
10
11
  import { AUTHOR_COLORS, getAuthorColorIdx } from "../utils/authorColors.js";
11
12
  import { resolveFontFamily } from "../utils/fontResolver.js";
12
13
  import "../utils/fontWeights.js";
13
- import { isRtlParagraph } from "../utils/paragraphBaseDirection.js";
14
+ import { resolvePhysicalParagraphInlineLayout } from "../utils/paragraphInlineLayout.js";
14
15
  import { inlineImageBoundingBox, parseRotationDegrees, rotatedBoundingBox } from "../utils/rotationBoundingBox.js";
15
16
  import { applySanitizedImageSrc } from "../utils/sanitizeImageSrc.js";
16
17
  import { SCRIPT_CLASS, hasCjk, hasComplexScript, segmentByScript } from "../utils/scriptSegments.js";
@@ -49,6 +50,9 @@ function isTextRun(run) {
49
50
  return run.kind === "text";
50
51
  }
51
52
  function hyperlinksMatch(left, right) {
53
+ const leftInstanceIndex = getHyperlinkInstanceIndex(left);
54
+ const rightInstanceIndex = getHyperlinkInstanceIndex(right);
55
+ if (leftInstanceIndex !== void 0 || rightInstanceIndex !== void 0) return leftInstanceIndex === rightInstanceIndex;
52
56
  return left.href === right.href && left.tooltip === right.tooltip && left.noDefaultStyle === right.noDefaultStyle;
53
57
  }
54
58
  function collectLeftToRightDisplayedUrlHyperlinks(runs) {
@@ -1097,7 +1101,7 @@ function renderLine(block, line, alignment, doc, options) {
1097
1101
  return runEl;
1098
1102
  };
1099
1103
  const onlyRun = runsForLine.length === 1 ? runsForLine[0] : void 0;
1100
- if (onlyRun && isMathRun(onlyRun) && onlyRun.display === "block" && block.attrs?.alignment !== "right") lineEl.style.textAlign = "center";
1104
+ if (onlyRun && isMathRun(onlyRun) && onlyRun.display === "block" && (options?.explicitAlignment ?? resolvePhysicalParagraphInlineLayout(block).explicitAlignment) !== "right") lineEl.style.textAlign = "center";
1101
1105
  if (runsForLine.length === 1 && isImageRun(runsForLine[0])) {
1102
1106
  lineEl.style.display = "flex";
1103
1107
  lineEl.style.alignItems = "center";
@@ -1342,26 +1346,15 @@ function renderParagraphFragment(fragment, block, measure, context, options = {}
1342
1346
  if (fragment.continuesFromPrev) fragmentEl.dataset["continuesFromPrev"] = "true";
1343
1347
  if (fragment.continuesOnNext) fragmentEl.dataset["continuesOnNext"] = "true";
1344
1348
  const lines = measure.lines.slice(fragment.fromLine, fragment.toLine);
1345
- const alignment = block.attrs?.alignment;
1349
+ const inlineLayout = resolvePhysicalParagraphInlineLayout(block);
1350
+ const { alignment, indentLeft, indentRight, isRtl } = inlineLayout;
1346
1351
  if (block.attrs?.styleId) fragmentEl.dataset["styleId"] = block.attrs.styleId;
1347
- const isRtl = isRtlParagraph(block);
1348
1352
  if (isRtl) fragmentEl.dir = "rtl";
1349
- if (alignment) if (alignment === "center") fragmentEl.style.textAlign = "center";
1350
- else if (alignment === "right") fragmentEl.style.textAlign = "right";
1351
- else if (alignment === "left") fragmentEl.style.textAlign = "left";
1352
- else fragmentEl.style.textAlign = isRtl ? "right" : "left";
1353
- else if (isRtl) fragmentEl.style.textAlign = "right";
1354
- const effectiveAlignment = alignment ?? (isRtl ? "right" : void 0);
1353
+ let fragmentAlignment = alignment;
1354
+ if (alignment === "justify") fragmentAlignment = isRtl ? "right" : "left";
1355
+ fragmentEl.style.textAlign = fragmentAlignment;
1356
+ const effectiveAlignment = alignment;
1355
1357
  const indent = block.attrs?.indent;
1356
- let indentLeft = 0;
1357
- let indentRight = 0;
1358
- if (indent) if (isRtl) {
1359
- if (indent.left !== void 0) indentRight = indent.left;
1360
- if (indent.right !== void 0) indentLeft = indent.right;
1361
- } else {
1362
- if (indent.left !== void 0) indentLeft = indent.left;
1363
- if (indent.right !== void 0) indentRight = indent.right;
1364
- }
1365
1358
  const borders = block.attrs?.borders;
1366
1359
  if (borders) {
1367
1360
  const borderStyleToCss = (style) => {
@@ -1448,6 +1441,7 @@ function renderParagraphFragment(fragment, block, measure, context, options = {}
1448
1441
  tabLeftIndentPx: indent?.left ?? 0,
1449
1442
  firstLineIndentPx: isFirstLine ? firstLineIndentPx : 0,
1450
1443
  isRtl,
1444
+ ...inlineLayout.explicitAlignment === void 0 ? {} : { explicitAlignment: inlineLayout.explicitAlignment },
1451
1445
  contentWidthPx: fragment.width,
1452
1446
  context,
1453
1447
  floatingMargins: {
@@ -1,7 +1,9 @@
1
1
  import { measureParagraph } from "../layout-engine/measure/measureParagraph.js";
2
2
  import { buildTableCellFloatingZones, getTableCellContentWidth, getTableCellFloatingImages } from "../layout-engine/measure/tableCellFloating.js";
3
3
  import { createTableCellFlowState, finishTableCellFlow, placeTableCellBlock } from "../layout-engine/measure/tableCellFlow.js";
4
- import { getTableRowLeadingWidth, isFloatingImageRun, isFloatingTextBoxBlock, resolveTableCellPadding, tableColumnsArePinned } from "../layout-engine/types.js";
4
+ import { buildTableCellGrid, buildTableCellPlacements, getSourceCellAt } from "../layout-engine/measure/tableCellGrid.js";
5
+ import { resolveTableInlinePlacement } from "../layout-engine/measure/tableInlinePlacement.js";
6
+ import { isFloatingImageRun, isFloatingTextBoxBlock, resolveTableCellPadding, tableColumnsArePinned } from "../layout-engine/types.js";
5
7
  import { applySanitizedImageSrc } from "../utils/sanitizeImageSrc.js";
6
8
  import { emuToPixels } from "../utils/units.js";
7
9
  import { resolveAnchoredImagePosition } from "./anchoredImagePosition.js";
@@ -228,11 +230,14 @@ function renderNestedTable(block, measure, context, doc) {
228
230
  tableEl.style.position = "relative";
229
231
  tableEl.style.width = `${measure.totalWidth}px`;
230
232
  tableEl.style.display = "block";
231
- if (block.justification === "center") {
233
+ const placement = resolveTableInlinePlacement(block);
234
+ if (placement.alignment === "center") {
232
235
  tableEl.style.marginLeft = "auto";
233
236
  tableEl.style.marginRight = "auto";
234
- } else if (block.justification === "right") tableEl.style.marginLeft = "auto";
235
- else if (block.indent) tableEl.style.marginLeft = `${block.indent}px`;
237
+ } else if (placement.alignment === "right") {
238
+ tableEl.style.marginLeft = "auto";
239
+ tableEl.style.marginRight = `${placement.offset}px`;
240
+ } else tableEl.style.marginLeft = `${placement.offset}px`;
236
241
  tableEl.dataset["blockId"] = String(block.id);
237
242
  if (block.pmStart !== void 0) tableEl.dataset["pmStart"] = String(block.pmStart);
238
243
  if (block.pmEnd !== void 0) tableEl.dataset["pmEnd"] = String(block.pmEnd);
@@ -243,9 +248,13 @@ function renderNestedTable(block, measure, context, doc) {
243
248
  yPos += i_item.height;
244
249
  }
245
250
  rowYPositions.push(yPos);
246
- const spanningCells = /* @__PURE__ */ new Map();
247
251
  const columnsPinned = tableColumnsArePinned(block);
248
- const cellGrid = buildTableCellGrid(block, measure.columnWidths.length);
252
+ const cellGrid = buildTableCellGrid(block.rows, measure.columnWidths.length);
253
+ const cellPlacements = buildTableCellPlacements({
254
+ grid: cellGrid,
255
+ columnWidths: measure.columnWidths,
256
+ bidi: block.bidi === true
257
+ });
249
258
  let y = 0;
250
259
  for (let rowIndex = 0; rowIndex < block.rows.length; rowIndex++) {
251
260
  const row = block.rows[rowIndex];
@@ -264,11 +273,11 @@ function renderNestedTable(block, measure, context, doc) {
264
273
  totalRows: block.rows.length,
265
274
  context,
266
275
  doc,
267
- spanningCells,
268
276
  rowYPositions,
269
277
  bidi: block.bidi === true,
270
278
  columnsPinned,
271
- cellGrid
279
+ cellGrid,
280
+ cellPlacements
272
281
  });
273
282
  tableEl.append(rowEl);
274
283
  y += rowMeasure.height;
@@ -320,13 +329,13 @@ function renderCellDiagonalBorder({ border, direction, cellWidth, cellHeight, do
320
329
  }
321
330
  return line;
322
331
  }
323
- function renderTableCell({ cell, cellMeasure, x, rowHeight, borderFlags, columnsPinned, context, doc, contentClip, pageContentPosition }) {
332
+ function renderTableCell({ cell, cellMeasure, x, width, rowHeight, borderFlags, columnsPinned, context, doc, contentClip, pageContentPosition }) {
324
333
  const cellEl = doc.createElement("div");
325
334
  cellEl.className = TABLE_CLASS_NAMES.cell;
326
335
  cellEl.style.position = "absolute";
327
336
  cellEl.style.left = `${x}px`;
328
337
  cellEl.style.top = "0";
329
- cellEl.style.width = `${cellMeasure.width}px`;
338
+ cellEl.style.width = `${width}px`;
330
339
  cellEl.style.height = `${rowHeight}px`;
331
340
  cellEl.style.overflow = "hidden";
332
341
  cellEl.style.boxSizing = "border-box";
@@ -396,7 +405,7 @@ function renderTableCell({ cell, cellMeasure, x, rowHeight, borderFlags, columns
396
405
  const topLeftToBottomRight = renderCellDiagonalBorder({
397
406
  border: cell.borders?.topLeftToBottomRight,
398
407
  direction: "top-left-to-bottom-right",
399
- cellWidth: cellMeasure.width,
408
+ cellWidth: width,
400
409
  cellHeight: rowHeight,
401
410
  doc
402
411
  });
@@ -404,7 +413,7 @@ function renderTableCell({ cell, cellMeasure, x, rowHeight, borderFlags, columns
404
413
  const topRightToBottomLeft = renderCellDiagonalBorder({
405
414
  border: cell.borders?.topRightToBottomLeft,
406
415
  direction: "top-right-to-bottom-left",
407
- cellWidth: cellMeasure.width,
416
+ cellWidth: width,
408
417
  cellHeight: rowHeight,
409
418
  doc
410
419
  });
@@ -423,52 +432,23 @@ function renderTableCell({ cell, cellMeasure, x, rowHeight, borderFlags, columns
423
432
  }
424
433
  return cellEl;
425
434
  }
426
- const tableCellGridKey = (rowIndex, columnIndex) => `${rowIndex}:${columnIndex}`;
427
- function buildTableCellGrid(block, columnCount) {
428
- const grid = /* @__PURE__ */ new Map();
429
- for (let rowIndex = 0; rowIndex < block.rows.length; rowIndex++) {
430
- const row = block.rows[rowIndex];
431
- if (!row) continue;
432
- let columnIndex = 0;
433
- for (const cell of row.cells) {
434
- while (grid.has(tableCellGridKey(rowIndex, columnIndex))) columnIndex += 1;
435
- const colSpan = cell.colSpan ?? 1;
436
- const rowSpan = cell.rowSpan ?? 1;
437
- const rowEnd = Math.min(block.rows.length, rowIndex + rowSpan);
438
- const columnEnd = Math.min(columnCount, columnIndex + colSpan);
439
- for (let gridRow = rowIndex; gridRow < rowEnd; gridRow++) for (let gridColumn = columnIndex; gridColumn < columnEnd; gridColumn++) grid.set(tableCellGridKey(gridRow, gridColumn), cell);
440
- columnIndex += colSpan;
441
- }
442
- }
443
- return grid;
444
- }
445
435
  const hasVisibleBorder = (border) => border !== void 0 && border.style !== "none" && border.style !== "nil";
446
- function renderTableRow({ row, rowMeasure, rowIndex, y, columnWidths, totalRows, context, doc, spanningCells, rowYPositions, isFirstRowInFragment, bidi = false, columnsPinned = false, cellGrid, contentClip, pageContentPosition }) {
436
+ function renderTableRow({ row, rowMeasure, rowIndex, y, columnWidths, totalRows, context, doc, rowYPositions, isFirstRowInFragment, bidi = false, columnsPinned = false, cellGrid, cellPlacements, contentClip, pageContentPosition }) {
447
437
  const rowEl = doc.createElement("div");
448
438
  rowEl.className = TABLE_CLASS_NAMES.row;
449
- const tableWidth = bidi ? columnWidths.reduce((sum, columnWidth) => sum + columnWidth, 0) : 0;
450
439
  rowEl.style.position = "absolute";
451
440
  rowEl.style.left = "0";
452
441
  rowEl.style.top = `${y}px`;
453
442
  rowEl.style.width = "100%";
454
443
  rowEl.style.height = `${rowMeasure.height}px`;
455
444
  rowEl.dataset["rowIndex"] = String(rowIndex);
456
- const occupiedColumns = /* @__PURE__ */ new Set();
457
- if (spanningCells) {
458
- for (const [, spanCell] of spanningCells) if (spanCell.startRow < rowIndex && spanCell.startRow + spanCell.rowSpan > rowIndex) for (let c = 0; c < spanCell.colSpan; c++) occupiedColumns.add(spanCell.columnIndex + c);
459
- }
460
- const gridBefore = row.gridBefore ?? 0;
461
- let x = getTableRowLeadingWidth(row, columnWidths);
462
- let columnIndex = gridBefore;
463
- while (occupiedColumns.has(columnIndex)) {
464
- x += columnWidths[columnIndex] ?? 0;
465
- columnIndex++;
466
- }
467
445
  for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex++) {
468
446
  const cell = row.cells[cellIndex];
469
447
  const cellMeasure = rowMeasure.cells[cellIndex];
470
448
  if (!cell || !cellMeasure) continue;
471
- const colSpan = cell.colSpan ?? 1;
449
+ const placement = cellPlacements.get(cell);
450
+ if (!placement) continue;
451
+ const { sourceColumn: columnIndex, columnSpan: colSpan, left: cellLeft, width } = placement;
472
452
  const rowSpan = cell.rowSpan ?? 1;
473
453
  let cellHeight = rowMeasure.height;
474
454
  if (rowSpan > 1 && rowYPositions) {
@@ -476,24 +456,21 @@ function renderTableRow({ row, rowMeasure, rowIndex, y, columnWidths, totalRows,
476
456
  for (let r = rowIndex; r < rowIndex + rowSpan && r < rowYPositions.length - 1; r++) cellHeight += (rowYPositions[r + 1] ?? 0) - (rowYPositions[r] ?? 0);
477
457
  if (cellHeight === 0) cellHeight = rowMeasure.height * rowSpan;
478
458
  }
479
- let cellWidth = 0;
480
- for (let c = 0; c < colSpan && columnIndex + c < columnWidths.length; c++) cellWidth += columnWidths[columnIndex + c] ?? 0;
481
- const cellLeft = bidi ? tableWidth - x - cellWidth : x;
482
459
  const isFirstRow = rowIndex === 0 || isFirstRowInFragment === true;
483
460
  const isLastRow = rowIndex + rowSpan >= totalRows;
484
461
  const atLogicalStart = columnIndex === 0;
485
462
  const atLogicalEnd = columnIndex + colSpan >= columnWidths.length;
486
463
  const isFirstCol = bidi ? atLogicalEnd : atLogicalStart;
487
464
  const isLastCol = bidi ? atLogicalStart : atLogicalEnd;
488
- const aboveCell = cellGrid?.get(tableCellGridKey(rowIndex - 1, columnIndex));
489
- const leftNeighborColumn = bidi ? columnIndex + colSpan : columnIndex - 1;
490
- const leftCell = cellGrid?.get(tableCellGridKey(rowIndex, leftNeighborColumn));
465
+ const aboveCell = getSourceCellAt(cellGrid, rowIndex - 1, columnIndex);
466
+ const leftCell = getSourceCellAt(cellGrid, rowIndex, bidi ? columnIndex + colSpan : columnIndex - 1);
491
467
  const drawTop = isFirstRow || !hasVisibleBorder(aboveCell?.borders?.bottom);
492
468
  const drawLeft = isFirstCol || !hasVisibleBorder(leftCell?.borders?.right);
493
469
  const cellEl = renderTableCell({
494
470
  cell,
495
471
  cellMeasure,
496
472
  x: cellLeft,
473
+ width,
497
474
  rowHeight: cellHeight,
498
475
  borderFlags: {
499
476
  drawTop,
@@ -515,25 +492,6 @@ function renderTableRow({ row, rowMeasure, rowIndex, y, columnWidths, totalRows,
515
492
  cellEl.dataset["columnIndex"] = String(columnIndex);
516
493
  if (rowSpan > 1) cellEl.dataset["rowSpan"] = String(rowSpan);
517
494
  rowEl.append(cellEl);
518
- if (rowSpan > 1 && spanningCells) {
519
- const key = `${rowIndex}-${columnIndex}`;
520
- spanningCells.set(key, {
521
- cell,
522
- cellMeasure,
523
- columnIndex,
524
- startRow: rowIndex,
525
- rowSpan,
526
- colSpan,
527
- x: cellLeft,
528
- totalHeight: cellHeight
529
- });
530
- }
531
- x += cellWidth;
532
- columnIndex += colSpan;
533
- while (occupiedColumns.has(columnIndex)) {
534
- x += columnWidths[columnIndex] ?? 0;
535
- columnIndex++;
536
- }
537
495
  }
538
496
  return rowEl;
539
497
  }
@@ -592,9 +550,13 @@ function renderTableFragment(fragment, block, measure, context, options = {}) {
592
550
  yPos += i_item.height;
593
551
  }
594
552
  rowYPositions.push(yPos);
595
- const spanningCells = /* @__PURE__ */ new Map();
596
553
  const columnsPinned = tableColumnsArePinned(block);
597
- const cellGrid = buildTableCellGrid(block, measure.columnWidths.length);
554
+ const cellGrid = buildTableCellGrid(block.rows, measure.columnWidths.length);
555
+ const cellPlacements = buildTableCellPlacements({
556
+ grid: cellGrid,
557
+ columnWidths: measure.columnWidths,
558
+ bidi: block.bidi === true
559
+ });
598
560
  const headerRowCount = fragment.headerRowCount ?? 0;
599
561
  let y = 0;
600
562
  if (headerRowCount > 0 && fragment.continuesFromPrev) for (let hdrIdx = 0; hdrIdx < headerRowCount; hdrIdx++) {
@@ -614,12 +576,12 @@ function renderTableFragment(fragment, block, measure, context, options = {}) {
614
576
  totalRows: block.rows.length,
615
577
  context,
616
578
  doc,
617
- spanningCells,
618
579
  rowYPositions,
619
580
  isFirstRowInFragment: hdrIdx === 0,
620
581
  bidi: block.bidi === true,
621
582
  columnsPinned,
622
583
  cellGrid,
584
+ cellPlacements,
623
585
  ...tablePageContentPosition ? { pageContentPosition: {
624
586
  ...tablePageContentPosition,
625
587
  y: tablePageContentPosition.y + y
@@ -667,12 +629,12 @@ function renderTableFragment(fragment, block, measure, context, options = {}) {
667
629
  totalRows: block.rows.length,
668
630
  context,
669
631
  doc,
670
- spanningCells,
671
632
  rowYPositions,
672
633
  isFirstRowInFragment,
673
634
  bidi: block.bidi === true,
674
635
  columnsPinned,
675
636
  cellGrid,
637
+ cellPlacements,
676
638
  ...contentClip ? { contentClip } : {},
677
639
  ...tablePageContentPosition ? { pageContentPosition: {
678
640
  ...tablePageContentPosition,
@@ -30,6 +30,11 @@ const createTextBoxGroupIdFactory = () => {
30
30
  let index = 0;
31
31
  return () => `${salt}:${index++}`;
32
32
  };
33
+ /** Keep imported hyperlink identity unique across every nested conversion scope. */
34
+ const createHyperlinkInstanceIndexAllocator = () => {
35
+ let index = 0;
36
+ return () => index++;
37
+ };
33
38
  /**
34
39
  * Convert a Document to a ProseMirror document
35
40
  *
@@ -42,6 +47,11 @@ function toProseDoc(document, options) {
42
47
  const styleResolver = createStyleEngine(options?.styles ?? document.package.styles);
43
48
  const theme = options?.theme ?? document.package.theme ?? null;
44
49
  const nextTextBoxGroupId = createTextBoxGroupIdFactory();
50
+ const conversionContext = {
51
+ theme,
52
+ nextTextBoxGroupId,
53
+ nextHyperlinkInstanceIndex: createHyperlinkInstanceIndexAllocator()
54
+ };
45
55
  const convertBodyBlocks = (blocks) => {
46
56
  const out = [];
47
57
  for (const block of blocks) if (block.type === "paragraph") {
@@ -49,10 +59,7 @@ function toProseDoc(document, options) {
49
59
  if (pbPos === "before") out.push(schema.node("pageBreak"));
50
60
  const converted = convertParagraphWithTextBoxes(block, styleResolver, {
51
61
  textBoxGroupId: nextTextBoxGroupId(),
52
- context: {
53
- theme,
54
- nextTextBoxGroupId
55
- }
62
+ context: conversionContext
56
63
  });
57
64
  const firstConverted = converted.at(0);
58
65
  if (pbPos === "before" && converted.length === 1 && firstConverted?.type.name === "paragraph" && firstConverted.content.size === 0 && document.package.settings?.splitPageBreakAndParagraphMark !== true) {
@@ -72,10 +79,7 @@ function toProseDoc(document, options) {
72
79
  }
73
80
  out.push(...converted);
74
81
  if (pbPos === "after") out.push(schema.node("pageBreak"));
75
- } else if (block.type === "table") out.push(convertTable(block, styleResolver, {
76
- theme,
77
- nextTextBoxGroupId
78
- }));
82
+ } else if (block.type === "table") out.push(convertTable(block, styleResolver, conversionContext));
79
83
  else out.push(convertBlockSdt(block, convertBodyBlocks));
80
84
  return out;
81
85
  };
@@ -122,14 +126,13 @@ function convertBlockSdt(blockSdt, convertBlocks) {
122
126
  * Resolves style-based text formatting and passes it to runs so that
123
127
  * paragraph styles (like Heading1) apply their font size, color, etc.
124
128
  */
125
- function convertParagraph(paragraph, styleResolver, activeCommentIds, extraRunFormatting, tableParagraphOverlay, textBoxAnchors) {
129
+ function convertParagraph(paragraph, styleResolver, nextHyperlinkInstanceIndex, activeCommentIds, extraRunFormatting, tableParagraphOverlay, textBoxAnchors) {
126
130
  const attrs = paragraphFormattingToAttrs(paragraph, styleResolver, tableParagraphOverlay);
127
131
  const isTocParagraph = isTocStyleId(paragraph.formatting?.styleId);
128
132
  const inlineNodes = [];
129
133
  let inlineOffset = 0;
130
134
  let bookmarksArr;
131
135
  let emptyHyperlinks;
132
- let hyperlinkIndex = 0;
133
136
  const commentIds = activeCommentIds ?? /* @__PURE__ */ new Set();
134
137
  const emitInlineNodes = (nodes) => {
135
138
  if (nodes.length === 0) return;
@@ -160,7 +163,7 @@ function convertParagraph(paragraph, styleResolver, activeCommentIds, extraRunFo
160
163
  return suppressParagraphMarkFormatting(baseRunFormatting, inheritableParagraphRunFormatting, formatting, paragraphMarkPrecedesStyle);
161
164
  };
162
165
  const emitTrackedChange = (change, markType, moveKind) => {
163
- emitInlineNodes(convertTrackedChange(change, markType, getInheritedRunFormatting, styleResolver, moveKind, textBoxAnchors));
166
+ emitInlineNodes(convertTrackedChange(change, markType, nextHyperlinkInstanceIndex, getInheritedRunFormatting, styleResolver, moveKind, textBoxAnchors));
164
167
  };
165
168
  for (const content of paragraph.content) {
166
169
  if (content.type === "commentRangeStart") commentIds.add(content.id);
@@ -168,12 +171,10 @@ function convertParagraph(paragraph, styleResolver, activeCommentIds, extraRunFo
168
171
  else if (content.type === "commentReference") anchorPointComment(inlineNodes, content.id);
169
172
  else if (content.type === "run") emitInlineNodes(convertRun(content, getInheritedRunFormatting(content.formatting), styleResolver, textBoxAnchors));
170
173
  else if (content.type === "hyperlink") {
171
- const currentHyperlinkIndex = hyperlinkIndex;
172
- hyperlinkIndex += 1;
173
174
  const linkNodes = convertHyperlink(content, {
174
175
  getInheritedRunFormatting,
175
176
  styleResolver,
176
- hyperlinkIndex: currentHyperlinkIndex,
177
+ hyperlinkIndex: nextHyperlinkInstanceIndex(),
177
178
  textBoxAnchors
178
179
  });
179
180
  if (linkNodes.length === 0) {
@@ -189,7 +190,7 @@ function convertParagraph(paragraph, styleResolver, activeCommentIds, extraRunFo
189
190
  }
190
191
  emitInlineNodes(linkNodes);
191
192
  } else if (content.type === "simpleField" || content.type === "complexField") emitInlineNode(convertField(content, getInheritedRunFormatting, styleResolver));
192
- else if (content.type === "inlineSdt") emitInlineNode(convertInlineSdt(content, getInheritedRunFormatting, styleResolver, textBoxAnchors));
193
+ else if (content.type === "inlineSdt") emitInlineNode(convertInlineSdt(content, nextHyperlinkInstanceIndex, getInheritedRunFormatting, styleResolver, textBoxAnchors));
193
194
  else if (content.type === "insertion" || content.type === "moveTo") emitTrackedChange(content, "insertion", content.type === "moveTo" ? "moveTo" : null);
194
195
  else if (content.type === "deletion" || content.type === "moveFrom") emitTrackedChange(content, "deletion", content.type === "moveFrom" ? "moveFrom" : null);
195
196
  else if (content.type === "mathEquation") emitInlineNode(convertMathEquation(content));
@@ -243,13 +244,11 @@ function anchorPointComment(nodes, commentId) {
243
244
  * Convert tracked change (insertion or deletion) content to PM nodes with
244
245
  * an insertion/deletion mark applied.
245
246
  */
246
- function convertTrackedChange(change, markType, getInheritedRunFormatting, styleResolver, moveKind = null, textBoxAnchors) {
247
+ function convertTrackedChange(change, markType, nextHyperlinkInstanceIndex, getInheritedRunFormatting, styleResolver, moveKind = null, textBoxAnchors) {
247
248
  const nodes = [];
248
- let hyperlinkIndex = 0;
249
249
  for (const item of change.content) if (item.type === "run") nodes.push(...convertRun(item, getInheritedRunFormatting(item.formatting), styleResolver, textBoxAnchors));
250
250
  else {
251
- const currentHyperlinkIndex = hyperlinkIndex;
252
- hyperlinkIndex += 1;
251
+ const currentHyperlinkIndex = nextHyperlinkInstanceIndex();
253
252
  nodes.push(...convertHyperlink(item, {
254
253
  getInheritedRunFormatting,
255
254
  styleResolver,
@@ -921,7 +920,8 @@ function standaloneTableCellToProseMirror(cell, nodeType) {
921
920
  styleResolver: null,
922
921
  context: {
923
922
  theme: null,
924
- nextTextBoxGroupId: createTextBoxGroupIdFactory()
923
+ nextTextBoxGroupId: createTextBoxGroupIdFactory(),
924
+ nextHyperlinkInstanceIndex: createHyperlinkInstanceIndexAllocator()
925
925
  },
926
926
  isHeader: nodeType === "tableHeader",
927
927
  gridWidthPercent: void 0,
@@ -973,20 +973,17 @@ function convertMathEquation(math) {
973
973
  /**
974
974
  * Convert an InlineSdt to a ProseMirror sdt node with inline content.
975
975
  */
976
- function convertInlineSdt(sdt, getInheritedRunFormatting, styleResolver, textBoxAnchors) {
976
+ function convertInlineSdt(sdt, nextHyperlinkInstanceIndex, getInheritedRunFormatting, styleResolver, textBoxAnchors) {
977
977
  const props = sdt.properties;
978
978
  const inlineNodes = [];
979
- let hyperlinkIndex = 0;
980
979
  for (const content of sdt.content) if (content.type === "run") {
981
980
  const runNodes = convertRun(content, getInheritedRunFormatting(content.formatting), styleResolver, textBoxAnchors);
982
981
  inlineNodes.push(...runNodes);
983
982
  } else if (content.type === "hyperlink") {
984
- const currentHyperlinkIndex = hyperlinkIndex;
985
- hyperlinkIndex += 1;
986
983
  const linkNodes = convertHyperlink(content, {
987
984
  getInheritedRunFormatting,
988
985
  styleResolver,
989
- hyperlinkIndex: currentHyperlinkIndex,
986
+ hyperlinkIndex: nextHyperlinkInstanceIndex(),
990
987
  textBoxAnchors
991
988
  });
992
989
  inlineNodes.push(...linkNodes);
@@ -994,12 +991,12 @@ function convertInlineSdt(sdt, getInheritedRunFormatting, styleResolver, textBox
994
991
  const fieldNode = convertField(content, getInheritedRunFormatting, styleResolver);
995
992
  if (fieldNode) inlineNodes.push(fieldNode);
996
993
  } else if (content.type === "inlineSdt") {
997
- const nestedSdt = convertInlineSdt(content, getInheritedRunFormatting, styleResolver, textBoxAnchors);
994
+ const nestedSdt = convertInlineSdt(content, nextHyperlinkInstanceIndex, getInheritedRunFormatting, styleResolver, textBoxAnchors);
998
995
  if (nestedSdt) inlineNodes.push(nestedSdt);
999
- } else if (content.type === "insertion") inlineNodes.push(...convertTrackedChange(content, "insertion", getInheritedRunFormatting, styleResolver, null, textBoxAnchors));
1000
- else if (content.type === "deletion") inlineNodes.push(...convertTrackedChange(content, "deletion", getInheritedRunFormatting, styleResolver, null, textBoxAnchors));
1001
- else if (content.type === "moveTo") inlineNodes.push(...convertTrackedChange(content, "insertion", getInheritedRunFormatting, styleResolver, "moveTo", textBoxAnchors));
1002
- else if (content.type === "moveFrom") inlineNodes.push(...convertTrackedChange(content, "deletion", getInheritedRunFormatting, styleResolver, "moveFrom", textBoxAnchors));
996
+ } else if (content.type === "insertion") inlineNodes.push(...convertTrackedChange(content, "insertion", nextHyperlinkInstanceIndex, getInheritedRunFormatting, styleResolver, null, textBoxAnchors));
997
+ else if (content.type === "deletion") inlineNodes.push(...convertTrackedChange(content, "deletion", nextHyperlinkInstanceIndex, getInheritedRunFormatting, styleResolver, null, textBoxAnchors));
998
+ else if (content.type === "moveTo") inlineNodes.push(...convertTrackedChange(content, "insertion", nextHyperlinkInstanceIndex, getInheritedRunFormatting, styleResolver, "moveTo", textBoxAnchors));
999
+ else if (content.type === "moveFrom") inlineNodes.push(...convertTrackedChange(content, "deletion", nextHyperlinkInstanceIndex, getInheritedRunFormatting, styleResolver, "moveFrom", textBoxAnchors));
1003
1000
  else {
1004
1001
  const mathNode = convertMathEquation(content);
1005
1002
  if (mathNode) inlineNodes.push(mathNode);
@@ -1380,7 +1377,7 @@ function convertShape(shape) {
1380
1377
  }
1381
1378
  function convertParagraphWithTextBoxes(block, styleResolver, { textBoxGroupId, context, extraRunFormatting, tableParagraphOverlay }) {
1382
1379
  const { textBoxes, textBoxAnchors } = extractTextBoxesFromParagraph(block, textBoxGroupId);
1383
- const pmParagraph = convertParagraph(block, styleResolver, void 0, extraRunFormatting, tableParagraphOverlay, textBoxAnchors);
1380
+ const pmParagraph = convertParagraph(block, styleResolver, context.nextHyperlinkInstanceIndex, void 0, extraRunFormatting, tableParagraphOverlay, textBoxAnchors);
1384
1381
  const nodes = [];
1385
1382
  const isEmptyAfterExtraction = textBoxes.length > 0 && !hasContentBesidesTextBoxAnchors(pmParagraph);
1386
1383
  const keepWrapperParagraph = isEmptyAfterExtraction && hasParagraphBoundaryPayload(block, pmParagraph);
@@ -1639,19 +1636,18 @@ function headerFooterToProseDoc(content, options) {
1639
1636
  const styleResolver = options?.styles ? createStyleEngine(options.styles) : null;
1640
1637
  const theme = options?.theme ?? null;
1641
1638
  const nextTextBoxGroupId = createTextBoxGroupIdFactory();
1639
+ const conversionContext = {
1640
+ theme,
1641
+ nextTextBoxGroupId,
1642
+ nextHyperlinkInstanceIndex: createHyperlinkInstanceIndexAllocator()
1643
+ };
1642
1644
  const convertBlocks = (blocks) => {
1643
1645
  const out = [];
1644
1646
  for (const block of blocks) if (block.type === "paragraph") out.push(...convertParagraphWithTextBoxes(block, styleResolver, {
1645
1647
  textBoxGroupId: nextTextBoxGroupId(),
1646
- context: {
1647
- theme,
1648
- nextTextBoxGroupId
1649
- }
1650
- }));
1651
- else if (block.type === "table") out.push(convertTable(block, styleResolver, {
1652
- theme,
1653
- nextTextBoxGroupId
1648
+ context: conversionContext
1654
1649
  }));
1650
+ else if (block.type === "table") out.push(convertTable(block, styleResolver, conversionContext));
1655
1651
  else out.push(convertBlockSdt(block, convertBlocks));
1656
1652
  return out;
1657
1653
  };
@@ -0,0 +1,14 @@
1
+ import { ParagraphAttrs, ParagraphBlock } from "../layout-engine/types.js";
2
+ //#region src/utils/paragraphInlineLayout.d.ts
3
+ type ParagraphAlignment = NonNullable<ParagraphAttrs["alignment"]>;
4
+ type PhysicalParagraphInlineLayout = {
5
+ alignment: ParagraphAlignment;
6
+ explicitAlignment?: ParagraphAlignment;
7
+ indentLeft: number;
8
+ indentRight: number;
9
+ isRtl: boolean;
10
+ };
11
+ /** Resolve logical OOXML paragraph sides into physical inline geometry. */
12
+ declare const resolvePhysicalParagraphInlineLayout: (block: ParagraphBlock) => PhysicalParagraphInlineLayout;
13
+ //#endregion
14
+ export { PhysicalParagraphInlineLayout, resolvePhysicalParagraphInlineLayout };
@@ -0,0 +1,25 @@
1
+ import { isRtlParagraph } from "./paragraphBaseDirection.js";
2
+ //#region src/utils/paragraphInlineLayout.ts
3
+ const mirrorHorizontalAlignment = (alignment) => {
4
+ if (alignment === "left") return "right";
5
+ if (alignment === "right") return "left";
6
+ return alignment;
7
+ };
8
+ /** Resolve logical OOXML paragraph sides into physical inline geometry. */
9
+ const resolvePhysicalParagraphInlineLayout = (block) => {
10
+ const attrs = block.attrs;
11
+ const isRtl = isRtlParagraph(block);
12
+ const mirrorsHorizontalSides = attrs?.bidi === true;
13
+ const explicitAlignment = mirrorsHorizontalSides ? mirrorHorizontalAlignment(attrs?.alignment) : attrs?.alignment;
14
+ const indentLeft = attrs?.indent?.left ?? 0;
15
+ const indentRight = attrs?.indent?.right ?? 0;
16
+ return {
17
+ alignment: explicitAlignment ?? (isRtl ? "right" : "left"),
18
+ ...explicitAlignment === void 0 ? {} : { explicitAlignment },
19
+ indentLeft: mirrorsHorizontalSides ? indentRight : indentLeft,
20
+ indentRight: mirrorsHorizontalSides ? indentLeft : indentRight,
21
+ isRtl
22
+ };
23
+ };
24
+ //#endregion
25
+ export { resolvePhysicalParagraphInlineLayout };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/folio-core",
3
- "version": "0.25.2",
3
+ "version": "0.25.3",
4
4
  "description": "Headless, framework-neutral core of folio: the OOXML (.docx) parser, document model, ProseMirror integration, and page-layout engine. No React.",
5
5
  "keywords": [
6
6
  "document-model",