@stll/folio-core 0.23.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/controller/fontReadiness.js +12 -7
  2. package/dist/controller/layoutPipeline.js +3 -4
  3. package/dist/docx/fieldParser.d.ts +1 -1
  4. package/dist/docx/fieldParser.js +2 -9
  5. package/dist/docx/numberingParser.d.ts +2 -1
  6. package/dist/docx/numberingParser.js +24 -80
  7. package/dist/docx/paragraphParser.js +3 -4
  8. package/dist/docx/paragraphTextBoxEnrichment.js +1 -0
  9. package/dist/docx/sectionParser.js +2 -2
  10. package/dist/docx/serializer/runSerializer.js +1 -0
  11. package/dist/docx/textBoxParser.js +4 -0
  12. package/dist/fields/fieldContext.d.ts +2 -1
  13. package/dist/layout-bridge/convert/headerFooterLayout.d.ts +9 -1
  14. package/dist/layout-bridge/convert/headerFooterLayout.js +27 -2
  15. package/dist/layout-bridge/convert/toFlowBlocks.js +12 -16
  16. package/dist/layout-bridge/engine/selectionRects.js +3 -5
  17. package/dist/layout-engine/index.d.ts +2 -2
  18. package/dist/layout-engine/index.js +3 -5
  19. package/dist/layout-engine/measure/cache.js +4 -0
  20. package/dist/layout-engine/measure/listMarkerWidth.d.ts +3 -1
  21. package/dist/layout-engine/measure/listMarkerWidth.js +25 -21
  22. package/dist/layout-engine/measure/measureBlocks.js +27 -11
  23. package/dist/layout-engine/measure/measureParagraph.js +7 -1
  24. package/dist/layout-engine/measure/tableCellFloating.js +2 -3
  25. package/dist/layout-engine/renderedBreakReconciliation.js +1 -1
  26. package/dist/layout-engine/tableRowBreak.js +2 -2
  27. package/dist/layout-engine/types.d.ts +16 -11
  28. package/dist/layout-engine/types.js +15 -1
  29. package/dist/layout-painter/renderParagraph.d.ts +9 -1
  30. package/dist/layout-painter/renderParagraph.js +65 -52
  31. package/dist/layout-painter/renderTable.js +2 -5
  32. package/dist/layout-painter/renderTextBox.js +1 -1
  33. package/dist/layout-painter/renderUtils.d.ts +2 -1
  34. package/dist/prosemirror/attrs/index.js +4 -6
  35. package/dist/prosemirror/conversion/fromProseDoc.js +2 -3
  36. package/dist/prosemirror/conversion/toProseDoc.js +3 -3
  37. package/dist/prosemirror/extensions/core/ParagraphExtension.js +1 -3
  38. package/dist/prosemirror/extensions/features/ListExtension.js +1 -3
  39. package/dist/prosemirror/extensions/nodes/TextBoxExtension.d.ts +2 -0
  40. package/dist/prosemirror/extensions/nodes/TextBoxExtension.js +7 -0
  41. package/dist/prosemirror/schema/nodes.d.ts +5 -7
  42. package/dist/prosemirror/styles/resolvedStyleAttrs.js +1 -3
  43. package/dist/utils/paragraphBaseDirection.d.ts +6 -0
  44. package/dist/utils/paragraphBaseDirection.js +12 -0
  45. package/package.json +2 -2
@@ -2,15 +2,15 @@ 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 { getListMarkerInlineWidth, getListMarkerVisualOffset } from "../layout-engine/measure/listMarkerWidth.js";
5
+ import { getListMarkerInlineWidth, getListMarkerVisualOffset, resolveListMarkerFont } from "../layout-engine/measure/listMarkerWidth.js";
6
6
  import { DOCX_SCRIPT_FONT_SCALE } from "../layout-engine/measure/measureHelpers.js";
7
7
  import { FONT_KERNING_MODE, countCompressibleSpaces, getFontKerningMode, getRunFontKerningMode, toPaintedText } from "../layout-engine/measure/textMeasurementPolicy.js";
8
8
  import { isFloatingImageRun } from "../layout-engine/types.js";
9
9
  import { calculateTabWidth } from "../prosemirror/utils/tabCalculator.js";
10
10
  import { AUTHOR_COLORS, getAuthorColorIdx } from "../utils/authorColors.js";
11
- import { detectBaseDirection } from "../utils/baseDirection.js";
12
11
  import { resolveFontFamily } from "../utils/fontResolver.js";
13
12
  import "../utils/fontWeights.js";
13
+ import { isRtlParagraph } from "../utils/paragraphBaseDirection.js";
14
14
  import { inlineImageBoundingBox, parseRotationDegrees, rotatedBoundingBox } from "../utils/rotationBoundingBox.js";
15
15
  import { applySanitizedImageSrc } from "../utils/sanitizeImageSrc.js";
16
16
  import { SCRIPT_CLASS, hasCjk, hasComplexScript, segmentByScript } from "../utils/scriptSegments.js";
@@ -39,12 +39,42 @@ const PARAGRAPH_CLASS_NAMES = {
39
39
  image: "layout-run-image",
40
40
  lineBreak: "layout-run-linebreak"
41
41
  };
42
+ const LEFT_TO_RIGHT_DIRECTION = "ltr";
43
+ const RIGHT_TO_LEFT_DIRECTION = "rtl";
44
+ const DISPLAYED_URL_PATTERN = /^(?:https?:\/\/|www\.)\S+$/iu;
42
45
  /**
43
46
  * Check if run is a text run
44
47
  */
45
48
  function isTextRun(run) {
46
49
  return run.kind === "text";
47
50
  }
51
+ function hyperlinksMatch(left, right) {
52
+ return left.href === right.href && left.tooltip === right.tooltip && left.noDefaultStyle === right.noDefaultStyle;
53
+ }
54
+ function collectLeftToRightDisplayedUrlHyperlinks(runs) {
55
+ const result = /* @__PURE__ */ new Set();
56
+ let index = 0;
57
+ while (index < runs.length) {
58
+ const first = runs[index];
59
+ if (!first || !isTextRun(first) || !first.hyperlink) {
60
+ index += 1;
61
+ continue;
62
+ }
63
+ const hyperlinks = [];
64
+ let displayedText = "";
65
+ let groupEnd = index;
66
+ while (groupEnd < runs.length) {
67
+ const run = runs[groupEnd];
68
+ if (!run || !isTextRun(run) || !run.hyperlink || !hyperlinksMatch(first.hyperlink, run.hyperlink)) break;
69
+ displayedText += toPaintedText(run.text);
70
+ hyperlinks.push(run.hyperlink);
71
+ groupEnd += 1;
72
+ }
73
+ if (DISPLAYED_URL_PATTERN.test(displayedText.trim())) for (const hyperlink of hyperlinks) result.add(hyperlink);
74
+ index = groupEnd;
75
+ }
76
+ return result;
77
+ }
48
78
  /**
49
79
  * Check if run is a tab run
50
80
  */
@@ -144,8 +174,8 @@ function applyRunStyles(element, run) {
144
174
  element.style.webkitTextStroke = "1px currentColor";
145
175
  element.style.webkitTextFillColor = "transparent";
146
176
  }
147
- if (run.rtl === true) element.dir = "rtl";
148
- else if (run.rtl === false) element.dir = "ltr";
177
+ if (run.rtl === true) element.dir = RIGHT_TO_LEFT_DIRECTION;
178
+ else if (run.rtl === false) element.dir = LEFT_TO_RIGHT_DIRECTION;
149
179
  if (run.textEffect) {
150
180
  element.classList.add("docx-text-effect", `docx-text-effect-${run.textEffect}`);
151
181
  element.dataset["effect"] = run.textEffect;
@@ -264,7 +294,7 @@ function applyPmPositions(element, pmStart, pmEnd) {
264
294
  /**
265
295
  * Render a text run
266
296
  */
267
- function renderTextRun(run, doc) {
297
+ function renderTextRun(run, doc, hyperlinkDirection) {
268
298
  const span = doc.createElement("span");
269
299
  span.className = `${PARAGRAPH_CLASS_NAMES.run} ${PARAGRAPH_CLASS_NAMES.text}`;
270
300
  if (run.footnoteRefId !== void 0) {
@@ -284,6 +314,7 @@ function renderTextRun(run, doc) {
284
314
  if (run.hyperlink) {
285
315
  const anchor = doc.createElement("a");
286
316
  anchor.href = run.hyperlink.href;
317
+ if (hyperlinkDirection || DISPLAYED_URL_PATTERN.test(paintedText.trim())) anchor.dir = LEFT_TO_RIGHT_DIRECTION;
287
318
  if (!run.hyperlink.href.startsWith("#")) {
288
319
  anchor.target = "_blank";
289
320
  anchor.rel = "noopener noreferrer";
@@ -1053,7 +1084,7 @@ function renderLine(block, line, alignment, doc, options) {
1053
1084
  return withCursiveJoiners(runEl, run, cursiveJoinerPlan, applyJoinerRunStyles, doc);
1054
1085
  };
1055
1086
  const renderLineTextRun = (run) => {
1056
- const runEl = renderTextRun(run, doc);
1087
+ const runEl = renderTextRun(run, doc, run.hyperlink && options?.leftToRightDisplayedUrlHyperlinks?.has(run.hyperlink) ? LEFT_TO_RIGHT_DIRECTION : void 0);
1057
1088
  if (collapsedLeadingSpaceRuns.has(run)) runEl.dataset["collapsedLeadingSpaces"] = "true";
1058
1089
  if (collapsedTrailingSpaceRuns.has(run)) runEl.dataset["collapsedTrailingSpaces"] = "true";
1059
1090
  if (isCollapsedLineEdgeSpaceRun(run)) {
@@ -1125,7 +1156,8 @@ function renderLine(block, line, alignment, doc, options) {
1125
1156
  const measureText = collapsedSpaceMeasureText ?? (hasTabRuns || hasScaledTextRun ? createTextMeasurer(doc) : void 0);
1126
1157
  if (hasTabRuns) {
1127
1158
  const explicitStops = options?.tabStops?.map(convertTabStopToCalc);
1128
- const leftIndentTwips = options?.leftIndentPx ? Math.round(options.leftIndentPx * 15) : 0;
1159
+ const tabLeftIndentPx = options?.tabLeftIndentPx ?? options?.leftIndentPx ?? 0;
1160
+ const leftIndentTwips = Math.round(tabLeftIndentPx * 15);
1129
1161
  tabContext = {
1130
1162
  ...explicitStops !== void 0 ? { explicitStops } : {},
1131
1163
  ...block.attrs?.defaultTabStopTwips !== void 0 ? { defaultTabInterval: block.attrs.defaultTabStopTwips } : {},
@@ -1134,11 +1166,13 @@ function renderLine(block, line, alignment, doc, options) {
1134
1166
  }
1135
1167
  let currentX;
1136
1168
  const leftIndentPx = options?.leftIndentPx ?? 0;
1169
+ const tabLeftIndentPx = options?.tabLeftIndentPx ?? leftIndentPx;
1170
+ const floatingLeftOffsetPx = options?.floatingMargins?.leftMargin ?? 0;
1137
1171
  if (options?.isFirstLine) {
1138
1172
  const firstLineIndentPx = options.firstLineIndentPx ?? 0;
1139
1173
  const markerInlineWidth = getListMarkerInlineWidth(block);
1140
- currentX = leftIndentPx + firstLineIndentPx + markerInlineWidth;
1141
- } else currentX = leftIndentPx;
1174
+ currentX = tabLeftIndentPx + firstLineIndentPx + markerInlineWidth + floatingLeftOffsetPx;
1175
+ } else currentX = tabLeftIndentPx + floatingLeftOffsetPx;
1142
1176
  for (let i = 0; i < runsForLine.length; i++) {
1143
1177
  const run = runsForLine[i];
1144
1178
  if (isTabRun(run) && tabContext && measureText) {
@@ -1160,7 +1194,7 @@ function renderLine(block, line, alignment, doc, options) {
1160
1194
  break;
1161
1195
  }
1162
1196
  }
1163
- if (lineRightEdgeX !== void 0 && tabResult.alignment === "end" && !hasFollowingTab && currentX + tabResult.width + followingWidthForCheck >= lineRightEdgeX - RIGHT_EDGE_EPSILON_PX) {
1197
+ if (lineRightEdgeX !== void 0 && options?.isRtl !== true && tabResult.alignment === "end" && !hasFollowingTab && currentX + tabResult.width + followingWidthForCheck >= lineRightEdgeX - RIGHT_EDGE_EPSILON_PX) {
1164
1198
  lineEl.style.display = "flex";
1165
1199
  lineEl.style.alignItems = "baseline";
1166
1200
  lineEl.style.whiteSpace = "pre";
@@ -1173,7 +1207,8 @@ function renderLine(block, line, alignment, doc, options) {
1173
1207
  tabEl.style.minWidth = "0";
1174
1208
  tabEl.style.width = "auto";
1175
1209
  lineEl.append(tabEl);
1176
- if (options?.isFirstLine && options.firstLineIndentPx !== void 0 && options.firstLineIndentPx !== 0 && lineEl.firstElementChild instanceof HTMLElement) lineEl.firstElementChild.style.marginLeft = `${options.firstLineIndentPx}px`;
1210
+ const firstFlexChild = lineEl.firstElementChild ?? void 0;
1211
+ if (options?.isFirstLine && options.firstLineIndentPx !== void 0 && options.firstLineIndentPx !== 0 && isStyleableHTMLElement(firstFlexChild)) firstFlexChild.style.marginLeft = `${options.firstLineIndentPx}px`;
1177
1212
  for (let j = i + 1; j < runsForLine.length; j++) {
1178
1213
  const next = runsForLine[j];
1179
1214
  if (isTabRun(next) || isLineBreakRun(next)) break;
@@ -1191,7 +1226,10 @@ function renderLine(block, line, alignment, doc, options) {
1191
1226
  break;
1192
1227
  }
1193
1228
  let tabWidth = tabResult.width;
1194
- if (!(tabResult.alignment === "start" && leftIndentPx > 0 && Math.abs(currentX + tabWidth - leftIndentPx) <= RIGHT_EDGE_EPSILON_PX) && lineRightEdgeX !== void 0 && canClampTabToRightEdge(tabResult.alignment, i > 0, runsForLine.slice(0, i).some(isTabRun), options?.isLastLine === true) && currentX + tabWidth + followingWidthForCheck > lineRightEdgeX) tabWidth = Math.max(1, lineRightEdgeX - currentX - followingWidthForCheck);
1229
+ const activeContentRightEdge = options?.contentWidthPx === void 0 ? void 0 : options.contentWidthPx - (options.floatingMargins?.rightMargin ?? 0);
1230
+ const preservesLogicalRtlEndStop = options?.isRtl === true && tabResult.alignment === "end" && activeContentRightEdge !== void 0 && currentX + tabWidth + followingWidthForCheck <= activeContentRightEdge + RIGHT_EDGE_EPSILON_PX;
1231
+ const landsOnLeftIndent = tabResult.alignment === "start" && tabLeftIndentPx > 0 && Math.abs(currentX + tabWidth - tabLeftIndentPx) <= RIGHT_EDGE_EPSILON_PX;
1232
+ if (!preservesLogicalRtlEndStop && !landsOnLeftIndent && lineRightEdgeX !== void 0 && canClampTabToRightEdge(tabResult.alignment, i > 0, runsForLine.slice(0, i).some(isTabRun), options?.isLastLine === true) && currentX + tabWidth + followingWidthForCheck > lineRightEdgeX) tabWidth = Math.max(1, lineRightEdgeX - currentX - followingWidthForCheck);
1195
1233
  const tabEl = renderTabRun(run, doc, tabWidth, tabResult.leader);
1196
1234
  lineEl.append(tabEl);
1197
1235
  currentX += tabWidth;
@@ -1281,22 +1319,6 @@ function bordersFormGroup(a, b) {
1281
1319
  return bordersEqual(a.top, b.top) && bordersEqual(a.bottom, b.bottom) && bordersEqual(a.left, b.left) && bordersEqual(a.right, b.right) && bordersEqual(a.between, b.between);
1282
1320
  }
1283
1321
  /**
1284
- * Decide whether a paragraph without an explicit `w:bidi` flag should still be
1285
- * laid out right-to-left. Only paragraphs that carry at least one `w:rtl` run
1286
- * are candidates; among those the base direction follows the first strong
1287
- * directional character (the `dir="auto"` rule), so Hebrew/Arabic-led lines
1288
- * order RTL while an English- (or CJK-/Devanagari-/…) led line stays LTR.
1289
- * eigenpal #723 (#719).
1290
- */
1291
- function paragraphBaseIsRtl(block) {
1292
- const runs = block.runs.filter((r) => isTextRun(r) || isFieldRun(r));
1293
- if (!runs.some((r) => r.rtl)) return false;
1294
- return detectBaseDirection(runs.map((r) => {
1295
- if (isTextRun(r)) return r.text;
1296
- return isFieldRun(r) ? r.fallback ?? "" : "";
1297
- }).join("")) !== "ltr";
1298
- }
1299
- /**
1300
1322
  * Render a paragraph fragment
1301
1323
  *
1302
1324
  * @param fragment - The fragment to render
@@ -1322,7 +1344,7 @@ function renderParagraphFragment(fragment, block, measure, context, options = {}
1322
1344
  const lines = measure.lines.slice(fragment.fromLine, fragment.toLine);
1323
1345
  const alignment = block.attrs?.alignment;
1324
1346
  if (block.attrs?.styleId) fragmentEl.dataset["styleId"] = block.attrs.styleId;
1325
- const isRtl = block.attrs?.bidi ?? paragraphBaseIsRtl(block);
1347
+ const isRtl = isRtlParagraph(block);
1326
1348
  if (isRtl) fragmentEl.dir = "rtl";
1327
1349
  if (alignment) if (alignment === "center") fragmentEl.style.textAlign = "center";
1328
1350
  else if (alignment === "right") fragmentEl.style.textAlign = "right";
@@ -1407,6 +1429,7 @@ function renderParagraphFragment(fragment, block, measure, context, options = {}
1407
1429
  else if (indent?.firstLine && indent.firstLine > 0) firstLineIndentPx = indent.firstLine;
1408
1430
  let _cumulativeLineY = 0;
1409
1431
  const renderedInlineImageKeys = options.renderedInlineImageKeys ?? /* @__PURE__ */ new Set();
1432
+ const leftToRightDisplayedUrlHyperlinks = isRtl ? collectLeftToRightDisplayedUrlHyperlinks(block.runs) : void 0;
1410
1433
  for (let i = 0; i < lines.length; i++) {
1411
1434
  const line = lines[i];
1412
1435
  const lineIndex = fragment.fromLine + i;
@@ -1422,13 +1445,17 @@ function renderParagraphFragment(fragment, block, measure, context, options = {}
1422
1445
  paragraphEndsWithLineBreak,
1423
1446
  ...block.attrs?.tabs !== void 0 ? { tabStops: block.attrs.tabs } : {},
1424
1447
  leftIndentPx: indentLeft,
1448
+ tabLeftIndentPx: indent?.left ?? 0,
1425
1449
  firstLineIndentPx: isFirstLine ? firstLineIndentPx : 0,
1450
+ isRtl,
1451
+ contentWidthPx: fragment.width,
1426
1452
  context,
1427
1453
  floatingMargins: {
1428
1454
  leftMargin: lineLeftOffset,
1429
1455
  rightMargin: lineRightOffset
1430
1456
  },
1431
1457
  renderedInlineImageKeys,
1458
+ ...leftToRightDisplayedUrlHyperlinks === void 0 ? {} : { leftToRightDisplayedUrlHyperlinks },
1432
1459
  lineRightEdgePx: fragment.width - indentRight - lineRightOffset
1433
1460
  });
1434
1461
  const isFlexLine = lineEl.dataset["flexLine"] === "true";
@@ -1447,13 +1474,8 @@ function renderParagraphFragment(fragment, block, measure, context, options = {}
1447
1474
  const hasHanging = indent?.hanging && indent.hanging > 0;
1448
1475
  const hasFirstLine = indent?.firstLine && indent.firstLine > 0;
1449
1476
  if (isFirstLine) {
1450
- if (indentLeft !== 0 && hasHanging) {
1451
- lineEl.style.paddingLeft = `${Math.max(indentLeft, 0)}px`;
1452
- if (!isFlexLine) lineEl.style.textIndent = `-${indent.hanging ?? 0}px`;
1453
- } else if (indentLeft !== 0 && hasFirstLine) {
1454
- lineEl.style.paddingLeft = `${Math.max(indentLeft, 0)}px`;
1455
- if (!isFlexLine) lineEl.style.textIndent = `${indent.firstLine ?? 0}px`;
1456
- } else if (indentLeft > 0) lineEl.style.paddingLeft = `${indentLeft}px`;
1477
+ if (indentLeft > 0) lineEl.style.paddingLeft = `${indentLeft}px`;
1478
+ if (hasHanging && !isFlexLine) lineEl.style.textIndent = `-${indent.hanging ?? 0}px`;
1457
1479
  else if (hasFirstLine && !isFlexLine) lineEl.style.textIndent = `${indent.firstLine ?? 0}px`;
1458
1480
  } else if (indentLeft > 0) lineEl.style.paddingLeft = `${indentLeft}px`;
1459
1481
  if (indentRight > 0) lineEl.style.paddingRight = `${indentRight}px`;
@@ -1463,18 +1485,7 @@ function renderParagraphFragment(fragment, block, measure, context, options = {}
1463
1485
  const markerStart = hanging > 0 ? indentLeft - hanging : indentLeft + firstLine;
1464
1486
  lineEl.style.paddingLeft = `${Math.max(0, markerStart)}px`;
1465
1487
  lineEl.style.textIndent = "0";
1466
- let firstTextRun;
1467
- if (!block.attrs.listMarkerFontFamily || !block.attrs.listMarkerFontSize || block.attrs.listMarkerBold === void 0) for (let ri = line.fromRun; ri <= line.toRun; ri++) {
1468
- const r = block.runs[ri];
1469
- if (r && r.kind === "text") {
1470
- firstTextRun = r;
1471
- break;
1472
- }
1473
- }
1474
- const markerFontFamily = block.attrs.listMarkerFontFamily ?? firstTextRun?.fontFamily ?? block.attrs.defaultFontFamily;
1475
- const markerFontSize = block.attrs.listMarkerFontSize ?? firstTextRun?.fontSize ?? block.attrs.defaultFontSize;
1476
- const markerBold = block.attrs.listMarkerBold ?? firstTextRun?.bold;
1477
- const marker = renderListMarker(block.attrs.listMarker, getListMarkerInlineWidth(block), getListMarkerVisualOffset(block), doc, markerFontFamily, markerFontSize, markerBold, block.attrs.listMarkerRevision, block.attrs.listMarkerSecondSlotOffsetTwips);
1488
+ const marker = renderListMarker(block.attrs.listMarker, getListMarkerInlineWidth(block), getListMarkerVisualOffset(block), doc, resolveListMarkerFont(block), block.attrs.listMarkerRevision, block.attrs.listMarkerSecondSlotOffsetTwips);
1478
1489
  const markerMarginLeft = markerStart - Math.min(indentLeft, 0);
1479
1490
  if (markerMarginLeft < 0) marker.style.marginLeft = `${markerMarginLeft}px`;
1480
1491
  lineEl.prepend(marker);
@@ -1491,13 +1502,15 @@ function renderParagraphFragment(fragment, block, measure, context, options = {}
1491
1502
  * tab grid. Long markers like "1.1.1." therefore grow to the next stop
1492
1503
  * instead of butting against the body text.
1493
1504
  */
1494
- function renderListMarker(marker, inlineWidth, visualOffset, doc, fontFamily, fontSize, bold, revision, secondSlotOffsetTwips) {
1505
+ function renderListMarker(marker, inlineWidth, visualOffset, doc, formatting, revision, secondSlotOffsetTwips) {
1495
1506
  const span = doc.createElement("span");
1496
1507
  span.className = "layout-list-marker";
1497
1508
  span.style.display = "inline-block";
1498
- if (fontFamily) span.style.fontFamily = resolveFontFamily(fontFamily).cssFallback;
1499
- if (fontSize) span.style.fontSize = `${fontSize * 96 / 72}px`;
1500
- if (bold !== void 0) span.style.fontWeight = bold ? "700" : "normal";
1509
+ span.style.fontFamily = resolveFontFamily(formatting.fontFamily).cssFallback;
1510
+ if (formatting.fontSize) span.style.fontSize = `${formatting.fontSize * 96 / 72}px`;
1511
+ if (formatting.bold !== void 0) span.style.fontWeight = formatting.bold ? "700" : "normal";
1512
+ if (formatting.italic !== void 0) span.style.fontStyle = formatting.italic ? "italic" : "normal";
1513
+ if (formatting.rtl !== void 0) span.dir = formatting.rtl ? "rtl" : "ltr";
1501
1514
  span.style.textAlign = "left";
1502
1515
  span.style.textAlignLast = "left";
1503
1516
  span.style.boxSizing = "border-box";
@@ -1,7 +1,7 @@
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, tableColumnsArePinned } from "../layout-engine/types.js";
4
+ import { getTableRowLeadingWidth, isFloatingImageRun, isFloatingTextBoxBlock, resolveTableCellPadding, tableColumnsArePinned } from "../layout-engine/types.js";
5
5
  import { applySanitizedImageSrc } from "../utils/sanitizeImageSrc.js";
6
6
  import { emuToPixels } from "../utils/units.js";
7
7
  import { resolveAnchoredImagePosition } from "./anchoredImagePosition.js";
@@ -330,10 +330,7 @@ function renderTableCell({ cell, cellMeasure, x, rowHeight, borderFlags, columns
330
330
  cellEl.style.height = `${rowHeight}px`;
331
331
  cellEl.style.overflow = "hidden";
332
332
  cellEl.style.boxSizing = "border-box";
333
- const padTop = cell.padding?.top ?? 1;
334
- const padRight = cell.padding?.right ?? 7;
335
- const padBottom = cell.padding?.bottom ?? 1;
336
- const padLeft = cell.padding?.left ?? 7;
333
+ const { top: padTop, right: padRight, bottom: padBottom, left: padLeft } = resolveTableCellPadding(cell);
337
334
  cellEl.style.padding = `${padTop}px ${padRight}px ${padBottom}px ${padLeft}px`;
338
335
  if (cell.borders) {
339
336
  if (borderFlags.drawTop) applyBorder(cellEl, "top", cell.borders.top);
@@ -26,7 +26,7 @@ function renderTextBoxFragment(fragment, block, measure, context, options = {})
26
26
  containerEl.style.position = "absolute";
27
27
  containerEl.style.width = `${fragment.width}px`;
28
28
  containerEl.style.height = `${fragment.height}px`;
29
- containerEl.style.overflow = "hidden";
29
+ containerEl.style.overflow = block.textWrap === "none" ? "visible" : "hidden";
30
30
  containerEl.style.boxSizing = "border-box";
31
31
  if (block.fillColor) containerEl.style.backgroundColor = block.fillColor;
32
32
  if (block.outlineWidth && block.outlineWidth > 0) {
@@ -1,3 +1,4 @@
1
+ import { document_d_exports } from "../types/document.js";
1
2
  import { ImageRun, isFloatingImageRun, isTextWrappingFloatingImageRun } from "../layout-engine/types.js";
2
3
  //#region src/layout-painter/renderUtils.d.ts
3
4
  /**
@@ -17,7 +18,7 @@ type RenderContext = {
17
18
  /** Current page number (1-indexed) */
18
19
  pageNumber: number;
19
20
  /** OOXML section-level format for the current logical page number. */
20
- pageNumberFormat?: string;
21
+ pageNumberFormat?: document_d_exports.NumberFormat;
21
22
  /** Total number of pages */
22
23
  totalPages: number;
23
24
  /** Which section is being rendered */
@@ -25,6 +25,7 @@ const TEXT_BOX_AUTO_FIT_VALUES = [
25
25
  "normal",
26
26
  "shape"
27
27
  ];
28
+ const TEXT_BOX_TEXT_WRAP_VALUES = ["square", "none"];
28
29
  const FIELD_KINDS = ["simple", "complex"];
29
30
  const MATH_DISPLAYS = ["inline", "block"];
30
31
  const SHAPE_FILL_TYPES = [
@@ -162,9 +163,7 @@ const readParagraphAttrs = (node) => {
162
163
  optionalBoolean(attrs, "listIsLegal", "paragraph.attrs.listIsLegal", issues);
163
164
  optionalString(attrs, "listMarker", "paragraph.attrs.listMarker", issues);
164
165
  optionalBoolean(attrs, "listMarkerHidden", "paragraph.attrs.listMarkerHidden", issues);
165
- optionalString(attrs, "listMarkerFontFamily", "paragraph.attrs.listMarkerFontFamily", issues);
166
- optionalNumber(attrs, "listMarkerFontSize", "paragraph.attrs.listMarkerFontSize", issues);
167
- optionalBoolean(attrs, "listMarkerBold", "paragraph.attrs.listMarkerBold", issues);
166
+ optionalTextFormatting(attrs, "listMarkerFormatting", "paragraph.attrs.listMarkerFormatting", issues);
168
167
  optionalOneOf(attrs, "listMarkerAlignment", "paragraph.attrs.listMarkerAlignment", issues, [
169
168
  "left",
170
169
  "center",
@@ -465,6 +464,7 @@ const readTextBoxAttrs = (node) => {
465
464
  optionalNumber(attrs, "width", "textBox.attrs.width", issues);
466
465
  optionalNumber(attrs, "height", "textBox.attrs.height", issues);
467
466
  optionalOneOf(attrs, "autoFit", "textBox.attrs.autoFit", issues, TEXT_BOX_AUTO_FIT_VALUES);
467
+ optionalOneOf(attrs, "textWrap", "textBox.attrs.textWrap", issues, TEXT_BOX_TEXT_WRAP_VALUES);
468
468
  optionalString(attrs, "textBoxId", "textBox.attrs.textBoxId", issues);
469
469
  optionalString(attrs, "fillColor", "textBox.attrs.fillColor", issues);
470
470
  optionalNumber(attrs, "outlineWidth", "textBox.attrs.outlineWidth", issues);
@@ -1397,12 +1397,10 @@ const validateParagraphFormatting = (value, path, issues) => {
1397
1397
  "listIsBullet",
1398
1398
  "listIsLegal",
1399
1399
  "listMarkerHidden",
1400
- "listMarkerBold",
1401
1400
  "listMarkerAllCaps"
1402
1401
  ]) optionalBoolean(value, key, `${path}.${key}`, issues);
1403
1402
  for (const key of PARAGRAPH_FORMATTING_NUMBER_KEYS) optionalNumber(value, key, `${path}.${key}`, issues);
1404
1403
  for (const key of [
1405
- "listMarkerFontSize",
1406
1404
  "listImplicitChildLevelAdvances",
1407
1405
  "listMarkerSecondSlotOffsetTwips",
1408
1406
  "listAbstractNumId",
@@ -1412,7 +1410,7 @@ const validateParagraphFormatting = (value, path, issues) => {
1412
1410
  optionalString(value, "styleId", `${path}.styleId`, issues);
1413
1411
  optionalOneOf(value, "listNumFmt", `${path}.listNumFmt`, issues, NUMBER_FORMAT_VALUES);
1414
1412
  optionalString(value, "listMarker", `${path}.listMarker`, issues);
1415
- optionalString(value, "listMarkerFontFamily", `${path}.listMarkerFontFamily`, issues);
1413
+ optionalTextFormatting(value, "listMarkerFormatting", `${path}.listMarkerFormatting`, issues);
1416
1414
  optionalOneOf(value, "listMarkerAlignment", `${path}.listMarkerAlignment`, issues, [
1417
1415
  "left",
1418
1416
  "center",
@@ -421,9 +421,7 @@ function listRenderingFromAttrs(attrs) {
421
421
  ...attrs.listIsLegal != null && { isLegal: attrs.listIsLegal },
422
422
  ...attrs.listNumFmt != null && { numFmt: attrs.listNumFmt },
423
423
  ...attrs.listMarkerHidden != null && { markerHidden: attrs.listMarkerHidden },
424
- ...attrs.listMarkerFontFamily != null && { markerFontFamily: attrs.listMarkerFontFamily },
425
- ...attrs.listMarkerFontSize != null && { markerFontSize: attrs.listMarkerFontSize },
426
- ...attrs.listMarkerBold != null && { markerBold: attrs.listMarkerBold },
424
+ ...attrs.listMarkerFormatting != null && { markerFormatting: attrs.listMarkerFormatting },
427
425
  ...attrs.listMarkerAlignment != null && { markerAlignment: attrs.listMarkerAlignment },
428
426
  ...attrs.listMarkerSuffix != null && { markerSuffix: attrs.listMarkerSuffix },
429
427
  ...attrs.listMarkerAllCaps != null && { markerAllCaps: attrs.listMarkerAllCaps },
@@ -1880,6 +1878,7 @@ function convertPMTextBox(node) {
1880
1878
  content: []
1881
1879
  }],
1882
1880
  ...attrs.autoFit !== void 0 ? { autoFit: attrs.autoFit } : {},
1881
+ ...attrs.textWrap !== void 0 ? { textWrap: attrs.textWrap } : {},
1883
1882
  margins: (() => {
1884
1883
  const m = {};
1885
1884
  if (typeof attrs.marginTop === "number") m.top = pixelsToEmu(attrs.marginTop);
@@ -290,9 +290,7 @@ function paragraphFormattingToAttrs(paragraph, styleResolver, tableParagraphOver
290
290
  if (paragraph.listRendering?.isLegal) attrs.listIsLegal = paragraph.listRendering.isLegal;
291
291
  if (paragraph.listRendering?.marker) attrs.listMarker = paragraph.listRendering.marker;
292
292
  if (paragraph.listRendering?.markerHidden) attrs.listMarkerHidden = paragraph.listRendering.markerHidden;
293
- if (paragraph.listRendering?.markerFontFamily) attrs.listMarkerFontFamily = paragraph.listRendering.markerFontFamily;
294
- if (paragraph.listRendering?.markerFontSize) attrs.listMarkerFontSize = paragraph.listRendering.markerFontSize;
295
- if (paragraph.listRendering?.markerBold !== void 0) attrs.listMarkerBold = paragraph.listRendering.markerBold;
293
+ if (paragraph.listRendering?.markerFormatting) attrs.listMarkerFormatting = paragraph.listRendering.markerFormatting;
296
294
  if (paragraph.listRendering?.markerAlignment) attrs.listMarkerAlignment = paragraph.listRendering.markerAlignment;
297
295
  if (paragraph.listRendering?.markerSuffix) attrs.listMarkerSuffix = paragraph.listRendering.markerSuffix;
298
296
  if (paragraph.listRendering?.markerAllCaps) attrs.listMarkerAllCaps = paragraph.listRendering.markerAllCaps;
@@ -1525,6 +1523,7 @@ function textBoxFromShape(shape, textBody) {
1525
1523
  if (shape.outline) textBox.outline = shape.outline;
1526
1524
  if (textBody.margins) textBox.margins = textBody.margins;
1527
1525
  if (textBody.autoFit) textBox.autoFit = textBody.autoFit;
1526
+ if (textBody.textWrap) textBox.textWrap = textBody.textWrap;
1528
1527
  return textBox;
1529
1528
  }
1530
1529
  /**
@@ -1599,6 +1598,7 @@ function convertTextBox(textBox, styleResolver, options) {
1599
1598
  width: widthPx,
1600
1599
  height: heightPx,
1601
1600
  autoFit: textBox.autoFit,
1601
+ textWrap: textBox.textWrap,
1602
1602
  textBoxId: textBox.id,
1603
1603
  fillColor,
1604
1604
  outlineWidth,
@@ -204,9 +204,7 @@ const paragraphNodeSpec = {
204
204
  listIsLegal: { default: null },
205
205
  listMarker: { default: null },
206
206
  listMarkerHidden: { default: null },
207
- listMarkerFontFamily: { default: null },
208
- listMarkerFontSize: { default: null },
209
- listMarkerBold: { default: null },
207
+ listMarkerFormatting: { default: null },
210
208
  listMarkerAlignment: { default: null },
211
209
  listMarkerSuffix: { default: null },
212
210
  listMarkerAllCaps: { default: null },
@@ -32,9 +32,7 @@ const LIST_FORMATTING_ATTRS = [
32
32
  "listNumFmt",
33
33
  "listMarker",
34
34
  "listMarkerHidden",
35
- "listMarkerFontFamily",
36
- "listMarkerFontSize",
37
- "listMarkerBold",
35
+ "listMarkerFormatting",
38
36
  "listMarkerAlignment",
39
37
  "listMarkerSuffix",
40
38
  "listLevelNumFmts",
@@ -10,6 +10,8 @@ type TextBoxAttrs = {
10
10
  height?: number;
11
11
  /** Text fitting behavior */
12
12
  autoFit?: document_d_exports.ShapeTextBody["autoFit"];
13
+ /** Horizontal text wrapping inside the box */
14
+ textWrap?: document_d_exports.ShapeTextBody["textWrap"];
13
15
  /** Unique identifier */
14
16
  textBoxId?: string;
15
17
  /** Fill color as CSS color */
@@ -18,6 +18,9 @@ function parseTextBoxWrapType(raw) {
18
18
  function parseTextBoxAutoFit(raw) {
19
19
  if (raw === "none" || raw === "normal" || raw === "shape") return raw;
20
20
  }
21
+ function parseTextBoxTextWrap(raw) {
22
+ if (raw === "none" || raw === "square") return raw;
23
+ }
21
24
  const TextBoxExtension = createNodeExtension({
22
25
  name: "textBox",
23
26
  schemaNodeName: "textBox",
@@ -30,6 +33,7 @@ const TextBoxExtension = createNodeExtension({
30
33
  width: { default: 200 },
31
34
  height: { default: null },
32
35
  autoFit: { default: null },
36
+ textWrap: { default: null },
33
37
  textBoxId: { default: null },
34
38
  fillColor: { default: null },
35
39
  outlineWidth: { default: null },
@@ -63,10 +67,12 @@ const TextBoxExtension = createNodeExtension({
63
67
  const position = parseTextBoxPosition(d["position"]);
64
68
  const wrapType = parseTextBoxWrapType(d["wrapType"]);
65
69
  const autoFit = parseTextBoxAutoFit(d["autoFit"]);
70
+ const textWrap = parseTextBoxTextWrap(d["textWrap"]);
66
71
  return {
67
72
  ...d["width"] ? { width: Number(d["width"]) } : {},
68
73
  ...d["height"] ? { height: Number(d["height"]) } : {},
69
74
  ...autoFit ? { autoFit } : {},
75
+ ...textWrap ? { textWrap } : {},
70
76
  ...d["textboxId"] ? { textBoxId: d["textboxId"] } : {},
71
77
  ...d["fillColor"] ? { fillColor: d["fillColor"] } : {},
72
78
  ...d["outlineWidth"] ? { outlineWidth: Number(d["outlineWidth"]) } : {},
@@ -95,6 +101,7 @@ const TextBoxExtension = createNodeExtension({
95
101
  if (attrs.width) domAttrs["data-width"] = String(attrs.width);
96
102
  if (attrs.height) domAttrs["data-height"] = String(attrs.height);
97
103
  if (attrs.autoFit) domAttrs["data-auto-fit"] = attrs.autoFit;
104
+ if (attrs.textWrap) domAttrs["data-text-wrap"] = attrs.textWrap;
98
105
  if (attrs.textBoxId) domAttrs["data-textbox-id"] = attrs.textBoxId;
99
106
  if (attrs.fillColor) domAttrs["data-fill-color"] = attrs.fillColor;
100
107
  if (attrs.outlineWidth) domAttrs["data-outline-width"] = String(attrs.outlineWidth);
@@ -69,12 +69,8 @@ type ParagraphAttrs = {
69
69
  listMarker?: string;
70
70
  /** Whether the list marker is hidden (w:vanish on numbering level rPr) */
71
71
  listMarkerHidden?: boolean;
72
- /** Marker font family from numbering level rPr */
73
- listMarkerFontFamily?: string;
74
- /** Marker font size from numbering level rPr, in points */
75
- listMarkerFontSize?: number;
76
- /** Marker bold state from numbering level rPr */
77
- listMarkerBold?: boolean;
72
+ /** Canonical numbering-level marker typography, in OOXML units. */
73
+ listMarkerFormatting?: document_d_exports.ListMarkerFormatting;
78
74
  /** Horizontal alignment of the marker around the paragraph's list anchor. */
79
75
  listMarkerAlignment?: "left" | "center" | "right";
80
76
  /**
@@ -200,7 +196,7 @@ type ParagraphAttrs = {
200
196
  type ParagraphPropertyChangeAttrs = Omit<document_d_exports.ParagraphPropertyChange, "previousFormatting" | "currentFormatting"> & {
201
197
  previousFormatting?: Omit<document_d_exports.ParagraphFormatting, "numPr"> & {
202
198
  numPr?: document_d_exports.ParagraphFormatting["numPr"] | null;
203
- } & Partial<Pick<ParagraphAttrs, "listIsBullet" | "listIsLegal" | "listNumFmt" | "listMarker" | "listMarkerHidden" | "listMarkerFontFamily" | "listMarkerFontSize" | "listMarkerBold" | "listMarkerAlignment" | "listMarkerSuffix" | "listLevelNumFmts" | "listLevelStarts" | "listAbstractNumId" | "listStartOverride" | "lineSpacingExplicit" | "direction" | "_autospacingBase">>;
199
+ } & Partial<Pick<ParagraphAttrs, "listIsBullet" | "listIsLegal" | "listNumFmt" | "listMarker" | "listMarkerHidden" | "listMarkerFormatting" | "listMarkerAlignment" | "listMarkerSuffix" | "listLevelNumFmts" | "listLevelStarts" | "listAbstractNumId" | "listStartOverride" | "lineSpacingExplicit" | "direction" | "_autospacingBase">>;
204
200
  currentFormatting?: document_d_exports.ParagraphFormatting;
205
201
  };
206
202
  /**
@@ -461,6 +457,8 @@ type TextBoxAttrs = {
461
457
  height?: number;
462
458
  /** Text fitting behavior */
463
459
  autoFit?: document_d_exports.ShapeTextBody["autoFit"];
460
+ /** Horizontal text wrapping inside the box */
461
+ textWrap?: document_d_exports.ShapeTextBody["textWrap"];
464
462
  /** Unique identifier */
465
463
  textBoxId?: string;
466
464
  /** Fill color as CSS color */
@@ -86,9 +86,7 @@ function listAttrsFromResolvedStyle(resolved, numbering) {
86
86
  listIsLegal: rendering?.isLegal ?? null,
87
87
  listMarker: rendering?.marker ?? null,
88
88
  listMarkerHidden: rendering?.markerHidden ?? null,
89
- listMarkerFontFamily: rendering?.markerFontFamily ?? null,
90
- listMarkerFontSize: rendering?.markerFontSize ?? null,
91
- listMarkerBold: rendering?.markerBold ?? null,
89
+ listMarkerFormatting: rendering?.markerFormatting ?? null,
92
90
  listMarkerAlignment: rendering?.markerAlignment ?? null,
93
91
  listMarkerSuffix: rendering?.markerSuffix ?? null,
94
92
  listMarkerAllCaps: rendering?.markerAllCaps ?? null,
@@ -0,0 +1,6 @@
1
+ import { ParagraphBlock } from "../layout-engine/types.js";
2
+ //#region src/utils/paragraphBaseDirection.d.ts
3
+ /** Resolve the paragraph direction used by both measurement and painting. */
4
+ declare const isRtlParagraph: (block: ParagraphBlock) => boolean;
5
+ //#endregion
6
+ export { isRtlParagraph };
@@ -0,0 +1,12 @@
1
+ import { detectBaseDirection } from "./baseDirection.js";
2
+ //#region src/utils/paragraphBaseDirection.ts
3
+ const isDirectionalTextRun = (run) => run.kind === "text" || run.kind === "field";
4
+ /** Resolve the paragraph direction used by both measurement and painting. */
5
+ const isRtlParagraph = (block) => {
6
+ if (block.attrs?.bidi !== void 0) return block.attrs.bidi;
7
+ const runs = block.runs.filter(isDirectionalTextRun);
8
+ if (!runs.some(({ rtl }) => rtl === true)) return false;
9
+ return detectBaseDirection(runs.map((run) => run.kind === "text" ? run.text : run.fallback ?? "").join("")) !== "ltr";
10
+ };
11
+ //#endregion
12
+ export { isRtlParagraph };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/folio-core",
3
- "version": "0.23.1",
3
+ "version": "0.25.0",
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",
@@ -113,7 +113,7 @@
113
113
  "perf": "bun scripts/profile-editor.ts"
114
114
  },
115
115
  "dependencies": {
116
- "@stll/docx-core": "^0.15.2",
116
+ "@stll/docx-core": "^0.17.0",
117
117
  "@stll/docx-utils": "^0.1.0",
118
118
  "@stll/template-conditions": "^0.1.0",
119
119
  "better-result": "3.0.1",