@stll/folio-core 0.25.2 → 0.25.4

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.
@@ -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: {
@@ -1460,9 +1454,10 @@ function renderParagraphFragment(fragment, block, measure, context, options = {}
1460
1454
  });
1461
1455
  const isFlexLine = lineEl.dataset["flexLine"] === "true";
1462
1456
  const lineMarginLeft = Math.min(indentLeft, 0) + lineLeftOffset;
1457
+ const lineMarginRight = Math.min(indentRight, 0) + lineRightOffset;
1463
1458
  if (lineMarginLeft !== 0 || lineRightOffset > 0 || indentLeft < 0 || indentRight < 0) {
1464
1459
  lineEl.style.marginLeft = `${lineMarginLeft}px`;
1465
- if (lineRightOffset > 0) lineEl.style.marginRight = `${lineRightOffset}px`;
1460
+ if (lineMarginRight !== 0) lineEl.style.marginRight = `${lineMarginRight}px`;
1466
1461
  const constrainedWidth = lineAvailableWidth - lineLeftOffset - lineRightOffset;
1467
1462
  if (constrainedWidth > 0) lineEl.style.width = `${constrainedWidth}px`;
1468
1463
  }
@@ -1482,27 +1477,33 @@ function renderParagraphFragment(fragment, block, measure, context, options = {}
1482
1477
  if (isFirstLine && block.attrs?.listMarker && !block.attrs.listMarkerHidden) {
1483
1478
  const hanging = indent?.hanging ?? 0;
1484
1479
  const firstLine = indent?.firstLine ?? 0;
1485
- const markerStart = hanging > 0 ? indentLeft - hanging : indentLeft + firstLine;
1486
- lineEl.style.paddingLeft = `${Math.max(0, markerStart)}px`;
1480
+ const markerPhysicalStart = isRtl ? "right" : "left";
1481
+ const markerIndent = markerPhysicalStart === "right" ? indentRight : indentLeft;
1482
+ const markerStart = hanging > 0 ? markerIndent - hanging : markerIndent + firstLine;
1483
+ const logicalMarkerVisualOffset = getListMarkerVisualOffset(block);
1484
+ if (markerPhysicalStart === "right") lineEl.style.paddingRight = `${Math.max(0, markerStart)}px`;
1485
+ else lineEl.style.paddingLeft = `${Math.max(0, markerStart)}px`;
1487
1486
  lineEl.style.textIndent = "0";
1488
- const marker = renderListMarker(block.attrs.listMarker, getListMarkerInlineWidth(block), getListMarkerVisualOffset(block), doc, resolveListMarkerFont(block), block.attrs.listMarkerRevision, block.attrs.listMarkerSecondSlotOffsetTwips);
1489
- const markerMarginLeft = markerStart - Math.min(indentLeft, 0);
1490
- if (markerMarginLeft < 0) marker.style.marginLeft = `${markerMarginLeft}px`;
1487
+ const marker = renderListMarker({
1488
+ marker: block.attrs.listMarker,
1489
+ inlineWidth: getListMarkerInlineWidth(block),
1490
+ visualOffset: markerPhysicalStart === "right" ? -logicalMarkerVisualOffset : logicalMarkerVisualOffset,
1491
+ doc,
1492
+ formatting: resolveListMarkerFont(block),
1493
+ ...block.attrs.listMarkerRevision === void 0 ? {} : { revision: block.attrs.listMarkerRevision },
1494
+ ...block.attrs.listMarkerSecondSlotOffsetTwips === void 0 ? {} : { secondSlotOffsetTwips: block.attrs.listMarkerSecondSlotOffsetTwips },
1495
+ physicalStart: markerPhysicalStart
1496
+ });
1497
+ const markerMarginLeft = markerStart - (markerPhysicalStart === "right" ? Math.min(indentRight, 0) : Math.min(indentLeft, 0));
1498
+ if (markerMarginLeft < 0) if (markerPhysicalStart === "right") marker.style.marginRight = `${markerMarginLeft}px`;
1499
+ else marker.style.marginLeft = `${markerMarginLeft}px`;
1491
1500
  lineEl.prepend(marker);
1492
1501
  }
1493
1502
  fragmentEl.append(lineEl);
1494
1503
  }
1495
1504
  return fragmentEl;
1496
1505
  }
1497
- /**
1498
- * Render a list marker element as an inline-block at the start of the first
1499
- * body line. `inlineWidth` (from `getListMarkerInlineWidth`) sizes the marker
1500
- * so the body text aligns at the next tab stop per ECMA-376 §17.9.25 —
1501
- * this honours `w:suff` (`tab` / `space` / `nothing`) and the document's
1502
- * tab grid. Long markers like "1.1.1." therefore grow to the next stop
1503
- * instead of butting against the body text.
1504
- */
1505
- function renderListMarker(marker, inlineWidth, visualOffset, doc, formatting, revision, secondSlotOffsetTwips) {
1506
+ function renderListMarker({ marker, inlineWidth, visualOffset, doc, formatting, revision, secondSlotOffsetTwips, physicalStart }) {
1506
1507
  const span = doc.createElement("span");
1507
1508
  span.className = "layout-list-marker";
1508
1509
  span.style.display = "inline-block";
@@ -1511,8 +1512,8 @@ function renderListMarker(marker, inlineWidth, visualOffset, doc, formatting, re
1511
1512
  if (formatting.bold !== void 0) span.style.fontWeight = formatting.bold ? "700" : "normal";
1512
1513
  if (formatting.italic !== void 0) span.style.fontStyle = formatting.italic ? "italic" : "normal";
1513
1514
  if (formatting.rtl !== void 0) span.dir = formatting.rtl ? "rtl" : "ltr";
1514
- span.style.textAlign = "left";
1515
- span.style.textAlignLast = "left";
1515
+ span.style.textAlign = physicalStart;
1516
+ span.style.textAlignLast = physicalStart;
1516
1517
  span.style.boxSizing = "border-box";
1517
1518
  span.style.width = `${inlineWidth}px`;
1518
1519
  if (visualOffset !== 0) span.style.transform = `translateX(${visualOffset}px)`;
@@ -1546,7 +1547,8 @@ function renderListMarker(marker, inlineWidth, visualOffset, doc, formatting, re
1546
1547
  secondSlot.style.display = "inline-block";
1547
1548
  span.style.position = "relative";
1548
1549
  secondSlot.style.position = "absolute";
1549
- secondSlot.style.left = `${secondSlotOffsetTwips / 15}px`;
1550
+ if (physicalStart === "right") secondSlot.style.right = `${secondSlotOffsetTwips / 15}px`;
1551
+ else secondSlot.style.left = `${secondSlotOffsetTwips / 15}px`;
1550
1552
  secondSlot.style.top = "0";
1551
1553
  span.append(firstSlot, secondSlot);
1552
1554
  return span;
@@ -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.4",
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",