@stll/folio-core 0.25.1 → 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.
Files changed (31) hide show
  1. package/dist/ai-edits/headless.d.ts +6 -0
  2. package/dist/ai-edits/headless.js +60 -7
  3. package/dist/ai-edits/read.d.ts +26 -1
  4. package/dist/ai-edits/read.js +26 -1
  5. package/dist/docx/rezip.d.ts +2 -0
  6. package/dist/docx/rezip.js +61 -58
  7. package/dist/docx/selectiveXmlPatch.d.ts +24 -1
  8. package/dist/docx/selectiveXmlPatch.js +186 -1
  9. package/dist/docx/xmlParser.d.ts +7 -1
  10. package/dist/docx/xmlParser.js +11 -4
  11. package/dist/layout-bridge/convert/toFlowBlocks.js +3 -4
  12. package/dist/layout-bridge/engine/clickToPosition.js +13 -28
  13. package/dist/layout-bridge/engine/hitTest.d.ts +2 -0
  14. package/dist/layout-bridge/engine/hitTest.js +28 -13
  15. package/dist/layout-bridge/engine/selectionRects.js +26 -15
  16. package/dist/layout-engine/index.js +6 -9
  17. package/dist/layout-engine/measure/hyperlinkInstance.d.ts +6 -0
  18. package/dist/layout-engine/measure/hyperlinkInstance.js +8 -0
  19. package/dist/layout-engine/measure/tableCellGrid.d.ts +15 -1
  20. package/dist/layout-engine/measure/tableCellGrid.js +29 -6
  21. package/dist/layout-engine/measure/tableInlinePlacement.d.ts +12 -0
  22. package/dist/layout-engine/measure/tableInlinePlacement.js +22 -0
  23. package/dist/layout-engine/types.d.ts +1 -1
  24. package/dist/layout-painter/renderParagraph.d.ts +3 -0
  25. package/dist/layout-painter/renderParagraph.js +13 -19
  26. package/dist/layout-painter/renderTable.js +36 -74
  27. package/dist/prosemirror/conversion/toProseDoc.js +42 -44
  28. package/dist/prosemirror/extensions/marks/CommentExtension.js +1 -0
  29. package/dist/utils/paragraphInlineLayout.d.ts +14 -0
  30. package/dist/utils/paragraphInlineLayout.js +25 -0
  31. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { WORDPROCESSINGML_NAMESPACE_URIS, findAttributeByNamespaceUri, getChildElements, getLocalName, getNamespacePrefix, getNamespaceUri, parseXmlDocument } from "./xmlParser.js";
1
2
  //#region src/docx/selectiveXmlPatch.ts
2
3
  /**
3
4
  * Selective XML Patch Module
@@ -317,6 +318,190 @@ function extractElementByIdAttr(xml, openLiteral, closeTag, idAttr, id) {
317
318
  const offsets = findElementByIdAttr(xml, openLiteral, closeTag, idAttr, id);
318
319
  return offsets ? xml.slice(offsets.start, offsets.end) : null;
319
320
  }
321
+ const qualifiedName = (prefix, localName) => prefix.length === 0 ? localName : `${prefix}:${localName}`;
322
+ const collectNoteElementSyntax = (xml, elementName) => {
323
+ const byId = /* @__PURE__ */ new Map();
324
+ const root = parseXmlDocument(xml);
325
+ for (const element of getChildElements(root)) {
326
+ if (getLocalName(element.name) !== elementName || !WORDPROCESSINGML_NAMESPACE_URIS.has(getNamespaceUri(element) ?? "")) continue;
327
+ const idAttribute = findAttributeByNamespaceUri(element, WORDPROCESSINGML_NAMESPACE_URIS, "id");
328
+ if (!element.name || !idAttribute) continue;
329
+ const syntax = {
330
+ elementName: element.name,
331
+ idAttributeName: idAttribute.name,
332
+ elementPrefix: getNamespacePrefix(element.name) ?? "",
333
+ attributePrefix: getNamespacePrefix(idAttribute.name) ?? ""
334
+ };
335
+ const entries = byId.get(idAttribute.value);
336
+ if (entries) entries.push(syntax);
337
+ else byId.set(idAttribute.value, [syntax]);
338
+ }
339
+ return byId;
340
+ };
341
+ const syntaxLiteral = (syntax) => ({
342
+ openLiteral: `<${syntax.elementName}`,
343
+ closeTag: `</${syntax.elementName}>`,
344
+ idAttr: syntax.idAttributeName
345
+ });
346
+ const extractNoteElement = (xml, syntax, id) => {
347
+ const { openLiteral, closeTag, idAttr } = syntaxLiteral(syntax);
348
+ return extractElementByIdAttr(xml, openLiteral, closeTag, idAttr, id);
349
+ };
350
+ const findNoteElement = (xml, syntax, id) => {
351
+ const { openLiteral, closeTag, idAttr } = syntaxLiteral(syntax);
352
+ return findElementByIdAttr(xml, openLiteral, closeTag, idAttr, id);
353
+ };
354
+ const rewriteWordprocessingPrefixes = (xml, { source, target, sourceXmlnsDeclarations }) => {
355
+ let rewritten = "";
356
+ let quote = null;
357
+ let insideTag = false;
358
+ for (let index = 0; index < xml.length; index++) {
359
+ const character = xml[index];
360
+ if (!insideTag) {
361
+ rewritten += character;
362
+ insideTag = character === "<";
363
+ continue;
364
+ }
365
+ if (quote) {
366
+ rewritten += character;
367
+ if (character === quote) quote = null;
368
+ continue;
369
+ }
370
+ if (character === "\"" || character === "'") {
371
+ quote = character;
372
+ rewritten += character;
373
+ continue;
374
+ }
375
+ if (character === ">") {
376
+ insideTag = false;
377
+ rewritten += character;
378
+ continue;
379
+ }
380
+ const previous = xml[index - 1];
381
+ const isElementName = previous === "<" || previous === "/" && xml[index - 2] === "<";
382
+ const sourcePrefix = isElementName ? source.elementPrefix : source.attributePrefix;
383
+ if (sourcePrefix.length > 0 && xml.startsWith(`${sourcePrefix}:`, index)) {
384
+ const targetPrefix = isElementName ? target.elementPrefix : target.attributePrefix;
385
+ rewritten += targetPrefix.length === 0 ? "" : `${targetPrefix}:`;
386
+ index += sourcePrefix.length;
387
+ continue;
388
+ }
389
+ rewritten += character;
390
+ }
391
+ return withXmlnsDeclarations(rewritten, sourceXmlnsDeclarations);
392
+ };
393
+ const paragraphRanges = (xml, wordPrefix) => {
394
+ const ranges = [];
395
+ const paragraphName = qualifiedName(wordPrefix, "p");
396
+ const openLiteral = `<${paragraphName}`;
397
+ const closeTag = `</${paragraphName}>`;
398
+ let pos = 0;
399
+ while (pos < xml.length) {
400
+ const start = xml.indexOf(openLiteral, pos);
401
+ if (start === -1) break;
402
+ if (!isXmlNameBoundary(xml[start + openLiteral.length])) {
403
+ pos = start + 1;
404
+ continue;
405
+ }
406
+ const range = scanElementRange(xml, start, openLiteral, closeTag);
407
+ if (!range) break;
408
+ ranges.push(range);
409
+ pos = range.end;
410
+ }
411
+ return ranges;
412
+ };
413
+ const collectChangedNoteParaIds = (baselineXml, currentXml) => {
414
+ const changed = /* @__PURE__ */ new Set();
415
+ const baselineIds = collectParaIds(baselineXml);
416
+ const baselineOffsets = buildParagraphOffsetIndex(baselineXml);
417
+ const currentOffsets = buildParagraphOffsetIndex(currentXml);
418
+ for (const [id, count] of collectParaIds(currentXml)) {
419
+ if (count !== 1 || baselineIds.get(id) !== 1) continue;
420
+ const before = baselineOffsets.get(id);
421
+ const after = currentOffsets.get(id);
422
+ if (before && after && baselineXml.slice(before.start, before.end) !== currentXml.slice(after.start, after.end)) changed.add(id);
423
+ }
424
+ return changed;
425
+ };
426
+ const replaceRanges = (xml, replacements) => {
427
+ let result = xml;
428
+ for (const { start, end, newXml } of [...replacements].toSorted((a, b) => b.start - a.start)) result = result.slice(0, start) + newXml + result.slice(end);
429
+ return result;
430
+ };
431
+ /**
432
+ * Patch an existing note part from its model serialization.
433
+ *
434
+ * Dirty paragraph ids locate their owning note in the model serialization;
435
+ * `(note w:id, paragraph ordinal)` then locates the corresponding source XML
436
+ * even when the producer omitted paragraph ids. Equal-shape edits replace only
437
+ * dirty paragraphs. A tracked paragraph-break resolution can change that
438
+ * shape, so it replaces the one affected note. Separator notes, unrelated
439
+ * notes, and unaffected equal-shape paragraphs remain byte-exact.
440
+ * `replacementXml` also supplies synthesized automatic note-reference marks,
441
+ * which the parsed model intentionally omits.
442
+ */
443
+ function buildPatchedNotePartXml({ originalXml, baselineXml, serializedXml, replacementXml, elementName, changedParaIds }) {
444
+ const currentElements = collectNoteElementSyntax(serializedXml, elementName);
445
+ const originalElements = collectNoteElementSyntax(originalXml, elementName);
446
+ const replacementElements = collectNoteElementSyntax(replacementXml, elementName);
447
+ const replacementXmlnsDeclarations = collectXmlnsFromOpeningTag(replacementXml);
448
+ const ordinalReplacements = [];
449
+ const serializedParaIds = collectParaIds(serializedXml);
450
+ const effectiveChangedParaIds = changedParaIds ?? collectChangedNoteParaIds(baselineXml, serializedXml);
451
+ const unroutedChangedParaIds = new Set([...effectiveChangedParaIds].filter((paraId) => serializedParaIds.has(paraId)));
452
+ for (const [id, currentSyntaxEntries] of currentElements) {
453
+ const originalSyntaxEntries = originalElements.get(id);
454
+ const replacementSyntaxEntries = replacementElements.get(id);
455
+ if (currentSyntaxEntries.length !== 1 || originalSyntaxEntries?.length !== 1 || replacementSyntaxEntries?.length !== 1) return null;
456
+ const currentSyntax = currentSyntaxEntries[0];
457
+ const originalSyntax = originalSyntaxEntries[0];
458
+ const replacementSyntax = replacementSyntaxEntries[0];
459
+ if (!currentSyntax || !originalSyntax || !replacementSyntax) return null;
460
+ const currentNote = extractNoteElement(serializedXml, currentSyntax, id);
461
+ const originalOffsets = findNoteElement(originalXml, originalSyntax, id);
462
+ const replacementNote = extractNoteElement(replacementXml, replacementSyntax, id);
463
+ if (!currentNote || !originalOffsets || !replacementNote) return null;
464
+ const originalNote = originalXml.slice(originalOffsets.start, originalOffsets.end);
465
+ const currentParagraphs = paragraphRanges(currentNote, currentSyntax.elementPrefix);
466
+ const originalParagraphs = paragraphRanges(originalNote, originalSyntax.elementPrefix);
467
+ const replacementParagraphs = paragraphRanges(replacementNote, replacementSyntax.elementPrefix);
468
+ const noteChangedParaIds = [...collectParaIds(currentNote).keys()].filter((paraId) => unroutedChangedParaIds.has(paraId));
469
+ if (noteChangedParaIds.length === 0) continue;
470
+ if (currentParagraphs.length !== originalParagraphs.length || currentParagraphs.length !== replacementParagraphs.length) {
471
+ for (const paraId of noteChangedParaIds) unroutedChangedParaIds.delete(paraId);
472
+ ordinalReplacements.push({
473
+ start: originalOffsets.start,
474
+ end: originalOffsets.end,
475
+ newXml: rewriteWordprocessingPrefixes(replacementNote, {
476
+ source: replacementSyntax,
477
+ target: originalSyntax,
478
+ sourceXmlnsDeclarations: replacementXmlnsDeclarations
479
+ })
480
+ });
481
+ continue;
482
+ }
483
+ for (let index = 0; index < currentParagraphs.length; index++) {
484
+ const currentRange = currentParagraphs[index];
485
+ const originalRange = originalParagraphs[index];
486
+ const replacementRange = replacementParagraphs[index];
487
+ if (!currentRange || !originalRange || !replacementRange) return null;
488
+ const routedIds = [...collectParaIds(currentNote.slice(currentRange.start, currentRange.end)).keys()].filter((paraId) => unroutedChangedParaIds.has(paraId));
489
+ if (routedIds.length === 0) continue;
490
+ for (const paraId of routedIds) unroutedChangedParaIds.delete(paraId);
491
+ ordinalReplacements.push({
492
+ start: originalOffsets.start + originalRange.start,
493
+ end: originalOffsets.start + originalRange.end,
494
+ newXml: rewriteWordprocessingPrefixes(replacementNote.slice(replacementRange.start, replacementRange.end), {
495
+ source: replacementSyntax,
496
+ target: originalSyntax,
497
+ sourceXmlnsDeclarations: replacementXmlnsDeclarations
498
+ })
499
+ });
500
+ }
501
+ }
502
+ if (unroutedChangedParaIds.size > 0) return null;
503
+ return replaceRanges(originalXml, ordinalReplacements);
504
+ }
320
505
  /**
321
506
  * The full range of the first `<openLiteral …>…</closeTag>` element, or null.
322
507
  * Used to locate an unkeyed sub-element (a level's `mc:AlternateContent`).
@@ -627,4 +812,4 @@ function escapeRegExp(str) {
627
812
  return str.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
628
813
  }
629
814
  //#endregion
630
- export { appendNumberingDefs, buildParagraphOffsetIndex, buildPatchedDocumentXml, buildPatchedNoteXml, buildPatchedNumberingXml, collectAddedNumberingDefs, collectChangedNumberingDefs, collectParaIds, countParagraphElements, extractParagraphXml, findParagraphOffsets, isXmlNameBoundary, validatePatchSafety };
815
+ export { appendNumberingDefs, buildParagraphOffsetIndex, buildPatchedDocumentXml, buildPatchedNotePartXml, buildPatchedNoteXml, buildPatchedNumberingXml, collectAddedNumberingDefs, collectChangedNoteParaIds, collectChangedNumberingDefs, collectParaIds, countParagraphElements, extractParagraphXml, findParagraphOffsets, isXmlNameBoundary, validatePatchSafety };
@@ -104,6 +104,12 @@ declare const WORDPROCESSINGML_NAMESPACE_URIS: ReadonlySet<string>;
104
104
  * from a foreign namespace is not accepted.
105
105
  */
106
106
  declare function findChildByNamespaceUri(parent: XmlElement | null | undefined, namespaceUris: ReadonlySet<string>, localName: string): XmlElement | null;
107
+ type XmlAttributeMatch = {
108
+ name: string;
109
+ value: string;
110
+ };
111
+ /** Find an attribute whose prefix resolves to one of the accepted namespace URIs. */
112
+ declare function findAttributeByNamespaceUri(element: XmlElement | null | undefined, namespaceUris: ReadonlySet<string>, localName: string): XmlAttributeMatch | null;
107
113
  /** Get an attribute whose prefix resolves to one of the accepted namespace URIs. */
108
114
  declare function getAttributeByNamespaceUri(element: XmlElement | null | undefined, namespaceUris: ReadonlySet<string>, localName: string): string | null;
109
115
  /**
@@ -338,4 +344,4 @@ declare function mergeXmlnsDeclarations(inherited: Record<string, string>, eleme
338
344
  */
339
345
  declare function cloneWithXmlnsDeclarations(element: XmlElement, xmlnsDecls: Record<string, string>): XmlElement;
340
346
  //#endregion
341
- export { NAMESPACES, WORDPROCESSINGML_NAMESPACE_URIS, XmlElement, XmlNamespaceScope, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findByFullName, findChild, findChildByLocalName, findChildByNamespaceUri, findChildren, findChildrenByLocalName, findDeep, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributeByNamespaceUri, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getNamespaceUri, getTextContent, hasChild, hasFlag, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumberingLevelAttribute, parseNumericAttribute, parseOnOffValue, parseTableMeasurementValue, parseXml, parseXmlDocument };
347
+ export { NAMESPACES, WORDPROCESSINGML_NAMESPACE_URIS, XmlAttributeMatch, XmlElement, XmlNamespaceScope, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findAttributeByNamespaceUri, findByFullName, findChild, findChildByLocalName, findChildByNamespaceUri, findChildren, findChildrenByLocalName, findDeep, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributeByNamespaceUri, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getNamespaceUri, getTextContent, hasChild, hasFlag, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumberingLevelAttribute, parseNumericAttribute, parseOnOffValue, parseTableMeasurementValue, parseXml, parseXmlDocument };
@@ -208,16 +208,23 @@ function findChildByNamespaceUri(parent, namespaceUris, localName) {
208
208
  for (const child of parent.elements) if (child.type === "element" && hasLocalName(child.name, localName) && namespaceUris.has(child.namespaceUri ?? "")) return child;
209
209
  return null;
210
210
  }
211
- /** Get an attribute whose prefix resolves to one of the accepted namespace URIs. */
212
- function getAttributeByNamespaceUri(element, namespaceUris, localName) {
211
+ /** Find an attribute whose prefix resolves to one of the accepted namespace URIs. */
212
+ function findAttributeByNamespaceUri(element, namespaceUris, localName) {
213
213
  if (!element?.attributes) return null;
214
214
  for (const [name, value] of Object.entries(element.attributes)) {
215
215
  if (value === void 0 || getLocalName(name) !== localName) continue;
216
216
  const prefix = getNamespacePrefix(name);
217
- if (prefix !== null && namespaceUris.has(resolveNamespaceUri(element.namespaceScope, prefix) ?? "")) return String(value);
217
+ if (prefix !== null && namespaceUris.has(resolveNamespaceUri(element.namespaceScope, prefix) ?? "")) return {
218
+ name,
219
+ value: String(value)
220
+ };
218
221
  }
219
222
  return null;
220
223
  }
224
+ /** Get an attribute whose prefix resolves to one of the accepted namespace URIs. */
225
+ function getAttributeByNamespaceUri(element, namespaceUris, localName) {
226
+ return findAttributeByNamespaceUri(element, namespaceUris, localName)?.value ?? null;
227
+ }
221
228
  function hasLocalName(name, localName) {
222
229
  if (!name) return false;
223
230
  if (name === localName) return true;
@@ -712,4 +719,4 @@ function cloneWithXmlnsDeclarations(element, xmlnsDecls) {
712
719
  return element;
713
720
  }
714
721
  //#endregion
715
- export { NAMESPACES, WORDPROCESSINGML_NAMESPACE_URIS, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findByFullName, findChild, findChildByLocalName, findChildByNamespaceUri, findChildren, findChildrenByLocalName, findDeep, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributeByNamespaceUri, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getNamespaceUri, getTextContent, hasChild, hasFlag, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumberingLevelAttribute, parseNumericAttribute, parseOnOffValue, parseTableMeasurementValue, parseXml, parseXmlDocument };
722
+ export { NAMESPACES, WORDPROCESSINGML_NAMESPACE_URIS, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findAttributeByNamespaceUri, findByFullName, findChild, findChildByLocalName, findChildByNamespaceUri, findChildren, findChildrenByLocalName, findDeep, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributeByNamespaceUri, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getNamespaceUri, getTextContent, hasChild, hasFlag, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumberingLevelAttribute, parseNumericAttribute, parseOnOffValue, parseTableMeasurementValue, parseXml, parseXmlDocument };
@@ -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 };