@stll/folio-core 0.31.0 → 0.31.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,21 @@
1
1
  //#region src/ai-edits/word-diff.ts
2
- const tokenize = (s) => s.match(/\s+|\S+/gu) ?? [];
2
+ const WHITESPACE = /\s/u;
3
+ const tokenize = (s) => {
4
+ const tokens = [];
5
+ let tokenStart = 0;
6
+ let cursor = 0;
7
+ while (cursor < s.length) {
8
+ while (cursor < s.length && WHITESPACE.test(s.charAt(cursor))) cursor++;
9
+ if (cursor === s.length) break;
10
+ while (cursor < s.length && !WHITESPACE.test(s.charAt(cursor))) cursor++;
11
+ tokens.push(s.slice(tokenStart, cursor));
12
+ tokenStart = cursor;
13
+ }
14
+ const last = tokens.at(-1);
15
+ if (last === void 0) return s.length === 0 ? [] : [s];
16
+ if (tokenStart < s.length) tokens[tokens.length - 1] = last + s.slice(tokenStart);
17
+ return tokens;
18
+ };
3
19
  /**
4
20
  * Cell budget for the O(n*m) word-diff DP table below, mirroring
5
21
  * `MAX_LCS_CELLS` in `version-comparison.ts`. `before`/`after` come from
@@ -86,6 +86,7 @@ function createBilingualDocument(source, options) {
86
86
  cloner
87
87
  });
88
88
  const paraIds = createParaIdMinter(collectPackageParaIds(source.package));
89
+ const bookmarkIds = createBookmarkIdMinter(source.package);
89
90
  const rows = [];
90
91
  const content = [];
91
92
  let sectionRows = [];
@@ -97,7 +98,7 @@ function createBilingualDocument(source, options) {
97
98
  const copyParagraph = (paragraph) => {
98
99
  const targetParaId = paraIds.mint(paragraph.paraId);
99
100
  return {
100
- copy: cloneParagraphForTarget(paragraph, targetParaId, styleCloner, cloner),
101
+ copy: cloneParagraphForTarget(paragraph, targetParaId, styleCloner, cloner, bookmarkIds),
101
102
  ref: {
102
103
  sourceParaId: paragraph.paraId,
103
104
  targetParaId,
@@ -152,7 +153,8 @@ function createBilingualDocument(source, options) {
152
153
  editableParagraphIds: options.editableParagraphIds,
153
154
  paraIds,
154
155
  styleCloner,
155
- cloner
156
+ cloner,
157
+ bookmarkIds
156
158
  });
157
159
  rows.push({
158
160
  kind: "table",
@@ -362,7 +364,7 @@ const createStyleCloner = ({ styleById, suffix, cloner }) => {
362
364
  })
363
365
  };
364
366
  };
365
- const cloneParagraphForTarget = (paragraph, targetParaId, styleCloner, cloner) => {
367
+ const cloneParagraphForTarget = (paragraph, targetParaId, styleCloner, cloner, bookmarkIds) => {
366
368
  const { textId: _textId, sectionProperties: _sectionProperties, ...rest } = paragraph;
367
369
  const formatting = paragraph.formatting;
368
370
  const nextFormatting = formatting && {
@@ -379,12 +381,56 @@ const cloneParagraphForTarget = (paragraph, targetParaId, styleCloner, cloner) =
379
381
  };
380
382
  return {
381
383
  ...rest,
382
- content: structuredClone(paragraph.content),
384
+ content: remapClonedBookmarkIds(structuredClone(paragraph.content), bookmarkIds),
383
385
  paraId: targetParaId,
384
386
  ...nextFormatting && { formatting: nextFormatting },
385
387
  ...paragraph.listRendering && { listRendering: remapListRendering(paragraph.listRendering, cloner) }
386
388
  };
387
389
  };
390
+ const createBookmarkIdMinter = (source) => {
391
+ let nextId = 0;
392
+ const remapped = /* @__PURE__ */ new Map();
393
+ const visit = (value, seen) => {
394
+ if (typeof value !== "object" || value === null || seen.has(value)) return;
395
+ seen.add(value);
396
+ if (Array.isArray(value)) {
397
+ value.forEach((item) => visit(item, seen));
398
+ return;
399
+ }
400
+ const record = value;
401
+ if (record["type"] === "bookmarkStart" || record["type"] === "bookmarkEnd") {
402
+ const id = record["id"];
403
+ if (typeof id === "number") nextId = Math.max(nextId, id + 1);
404
+ }
405
+ Object.values(record).forEach((item) => visit(item, seen));
406
+ };
407
+ visit(source, /* @__PURE__ */ new Set());
408
+ return { mint: (sourceId) => {
409
+ const existing = remapped.get(sourceId);
410
+ if (existing !== void 0) return existing;
411
+ const id = nextId++;
412
+ remapped.set(sourceId, id);
413
+ return id;
414
+ } };
415
+ };
416
+ const remapClonedBookmarkIds = (value, bookmarkIds) => {
417
+ const visit = (item, seen) => {
418
+ if (typeof item !== "object" || item === null || seen.has(item)) return;
419
+ seen.add(item);
420
+ if (Array.isArray(item)) {
421
+ item.forEach((child) => visit(child, seen));
422
+ return;
423
+ }
424
+ const record = item;
425
+ if (record["type"] === "bookmarkStart" || record["type"] === "bookmarkEnd") {
426
+ const id = record["id"];
427
+ if (typeof id === "number") record["id"] = bookmarkIds.mint(id);
428
+ }
429
+ Object.values(record).forEach((child) => visit(child, seen));
430
+ };
431
+ visit(value, /* @__PURE__ */ new Set());
432
+ return value;
433
+ };
388
434
  const remapListRendering = (rendering, cloner) => {
389
435
  const clonedAbstract = rendering.abstractNumId === void 0 ? void 0 : cloner.clonedAbstractNumId(rendering.abstractNumId);
390
436
  return {
@@ -399,7 +445,7 @@ const collectTableParagraphs = (table) => {
399
445
  else out.push(...collectTableParagraphs(item));
400
446
  return out;
401
447
  };
402
- const cloneTableForTarget = ({ table, editableParagraphIds, paraIds, styleCloner, cloner }) => {
448
+ const cloneTableForTarget = ({ table, editableParagraphIds, paraIds, styleCloner, cloner, bookmarkIds }) => {
403
449
  const paragraphs = [];
404
450
  const cloneTable = (source) => ({
405
451
  ...structuredClone(source),
@@ -410,7 +456,7 @@ const cloneTableForTarget = ({ table, editableParagraphIds, paraIds, styleCloner
410
456
  content: cell.content.map((item) => {
411
457
  if (item.type === "table") return cloneTable(item);
412
458
  const targetParaId = paraIds.mint(item.paraId);
413
- const copy = cloneParagraphForTarget(item, targetParaId, styleCloner, cloner);
459
+ const copy = cloneParagraphForTarget(item, targetParaId, styleCloner, cloner, bookmarkIds);
414
460
  if (item.paraId !== void 0 && editableParagraphIds.has(item.paraId)) paragraphs.push({
415
461
  sourceParaId: item.paraId,
416
462
  targetParaId,
@@ -61,10 +61,11 @@ function frameWrapType(frame) {
61
61
  function toFrameTextBox(content, frame, nextBlockId) {
62
62
  const first = content.at(0);
63
63
  const last = content.at(-1);
64
+ const hasAuthoredWidth = frame.width !== void 0;
64
65
  const textBox = {
65
66
  kind: "textBox",
66
67
  id: nextBlockId(),
67
- width: frame.width ?? 200,
68
+ width: frame.width ?? 0,
68
69
  margins: {
69
70
  top: 0,
70
71
  right: 0,
@@ -82,6 +83,11 @@ function toFrameTextBox(content, frame, nextBlockId) {
82
83
  ...first?.pmStart !== void 0 ? { pmStart: first.pmStart } : {},
83
84
  ...last?.pmEnd !== void 0 ? { pmEnd: last.pmEnd } : {}
84
85
  };
86
+ if (!hasAuthoredWidth) {
87
+ textBox.widthMode = "intrinsic";
88
+ textBox.autoFit = "shape";
89
+ textBox.textWrap = "none";
90
+ }
85
91
  if (frame.height !== void 0) textBox.height = frame.height;
86
92
  const position = framePosition(frame);
87
93
  if (position !== void 0) textBox.position = position;
@@ -769,6 +769,8 @@ type TextBoxBlock = {
769
769
  id: BlockId;
770
770
  /** Width in pixels */
771
771
  width: number;
772
+ /** Whether width is authored or derived from the live rendered content. */
773
+ widthMode?: "intrinsic";
772
774
  /** Height in pixels (may be auto-calculated) */
773
775
  height?: number;
774
776
  /** Text fitting behavior */
@@ -177,6 +177,12 @@ type HeaderFooterLayoutInfo = {
177
177
  left: number;
178
178
  };
179
179
  };
180
+ type HeaderFooterHorizontalPosition = {
181
+ relativeTo?: string;
182
+ posOffset?: number;
183
+ align?: string;
184
+ alignment?: string;
185
+ };
180
186
  /**
181
187
  * Resolve the CSS `left` (px string) for an anchored object (image or text box)
182
188
  * in a header/footer, honoring `wp:positionH` (relativeTo page/margin, align
@@ -184,12 +190,11 @@ type HeaderFooterLayoutInfo = {
184
190
  * a page-centered banner in the header lands centered like Word, not pinned to
185
191
  * the left. Ported from eigenpal/docx-editor#700.
186
192
  */
187
- declare function resolveHeaderFooterFloatLeft(width: number, h: {
188
- relativeTo?: string;
189
- posOffset?: number;
190
- align?: string;
191
- alignment?: string;
192
- } | undefined, layout: HeaderFooterLayoutInfo): string;
193
+ declare function resolveHeaderFooterFloatLeft(width: number, h: HeaderFooterHorizontalPosition | undefined, layout: HeaderFooterLayoutInfo): string;
194
+ declare function resolveHeaderFooterIntrinsicFrameHorizontalPosition(h: HeaderFooterHorizontalPosition | undefined, layout: HeaderFooterLayoutInfo): {
195
+ left: string;
196
+ transform?: string;
197
+ };
193
198
  /**
194
199
  * Resolve the on-page coordinates of an anchored floating image.
195
200
  *
@@ -297,4 +302,4 @@ declare function findPageShellForPmPos(container: HTMLElement, pmPos: number): {
297
302
  isExact: boolean;
298
303
  } | null;
299
304
  //#endregion
300
- export { type AnchoredImagePosition, FootnoteRenderItem, type HeaderFooterContent, HeaderFooterLayoutInfo, PAGE_CLASS_NAMES, PAINTER_PAINTED_EVENT, PageFloatingImage, type PageGeometry, PainterPaintedDetail, type RenderContext, RenderPageOptions, applySectionHeaderFooterOptions, calculateFootnoteAreaRenderHeight, computePageFingerprint, emuToPixels, findPageShellForPmPos, floatingTableReservesBand, getDefaultPageFontFamily, isFloatingImageRun, renderAllPagesNow, renderFloatingImagesLayer, renderFootnoteArea, renderPage, renderPages, resolveAnchoredImagePosition, resolveHeaderFooterFloatLeft };
305
+ export { type AnchoredImagePosition, FootnoteRenderItem, type HeaderFooterContent, HeaderFooterLayoutInfo, PAGE_CLASS_NAMES, PAINTER_PAINTED_EVENT, PageFloatingImage, type PageGeometry, PainterPaintedDetail, type RenderContext, RenderPageOptions, applySectionHeaderFooterOptions, calculateFootnoteAreaRenderHeight, computePageFingerprint, emuToPixels, findPageShellForPmPos, floatingTableReservesBand, getDefaultPageFontFamily, isFloatingImageRun, renderAllPagesNow, renderFloatingImagesLayer, renderFootnoteArea, renderPage, renderPages, resolveAnchoredImagePosition, resolveHeaderFooterFloatLeft, resolveHeaderFooterIntrinsicFrameHorizontalPosition };
@@ -207,6 +207,37 @@ function applyContentAreaStyles(element, page) {
207
207
  function getPositionAlignment(position) {
208
208
  return position?.align ?? position?.alignment;
209
209
  }
210
+ function resolveHeaderFooterHorizontalAnchorPoint(h, layout) {
211
+ if (!h) return {
212
+ left: 0,
213
+ alignment: "left"
214
+ };
215
+ if (h.posOffset !== void 0) {
216
+ const pageOffset = h.relativeTo === "page" ? -layout.flowLeft : 0;
217
+ return {
218
+ left: emuToPixels(h.posOffset) + pageOffset,
219
+ alignment: "left"
220
+ };
221
+ }
222
+ let align = getPositionAlignment(h);
223
+ if (align === "inside") align = "left";
224
+ else if (align === "outside") align = "right";
225
+ const alignment = align === "center" || align === "right" ? align : "left";
226
+ const frameWidth = h.relativeTo === "page" ? layout.pageWidth : layout.contentWidth;
227
+ const frameLeft = h.relativeTo === "page" ? -layout.flowLeft : 0;
228
+ if (alignment === "center") return {
229
+ left: frameLeft + frameWidth / 2,
230
+ alignment
231
+ };
232
+ if (alignment === "right") return {
233
+ left: frameLeft + frameWidth,
234
+ alignment
235
+ };
236
+ return {
237
+ left: frameLeft,
238
+ alignment
239
+ };
240
+ }
210
241
  function resolveHeaderFooterFloatTop(floatImg, layout) {
211
242
  const v = floatImg.position.vertical;
212
243
  if (!v) return floatImg.paragraphY;
@@ -261,20 +292,24 @@ function resolveHeaderFooterFloatingTablePosition(floating, measure, layout, sou
261
292
  * the left. Ported from eigenpal/docx-editor#700.
262
293
  */
263
294
  function resolveHeaderFooterFloatLeft(width, h, layout) {
264
- if (!h) return "0";
265
- let align = getPositionAlignment(h);
266
- if (align === "inside") align = "left";
267
- else if (align === "outside") align = "right";
268
- if (h.relativeTo === "page") {
269
- if (h.posOffset !== void 0) return `${emuToPixels(h.posOffset) - layout.flowLeft}px`;
270
- if (align === "right") return `${layout.pageWidth - width - layout.flowLeft}px`;
271
- if (align === "center") return `${(layout.pageWidth - width) / 2 - layout.flowLeft}px`;
272
- if (align === "left") return `${-layout.flowLeft}px`;
273
- }
274
- if (h.posOffset !== void 0) return `${emuToPixels(h.posOffset)}px`;
275
- if (align === "right") return `${layout.contentWidth - width}px`;
276
- if (align === "center") return `${(layout.contentWidth - width) / 2}px`;
277
- return "0";
295
+ const anchor = resolveHeaderFooterHorizontalAnchorPoint(h, layout);
296
+ let widthFactor = 0;
297
+ if (anchor.alignment === "right") widthFactor = 1;
298
+ else if (anchor.alignment === "center") widthFactor = .5;
299
+ const left = anchor.left - width * widthFactor;
300
+ return left === 0 ? "0" : `${left}px`;
301
+ }
302
+ function resolveHeaderFooterIntrinsicFrameHorizontalPosition(h, layout) {
303
+ const anchor = resolveHeaderFooterHorizontalAnchorPoint(h, layout);
304
+ if (anchor.alignment === "center") return {
305
+ left: `${anchor.left}px`,
306
+ transform: "translateX(-50%)"
307
+ };
308
+ if (anchor.alignment === "right") return {
309
+ left: `${anchor.left}px`,
310
+ transform: "translateX(-100%)"
311
+ };
312
+ return { left: anchor.left === 0 ? "0" : `${anchor.left}px` };
278
313
  }
279
314
  function applyHeaderFooterFloatHorizontalPosition(img, floatImg, layout) {
280
315
  img.style.left = resolveHeaderFooterFloatLeft(floatImg.width, floatImg.position.horizontal, layout);
@@ -500,6 +535,12 @@ function renderHeaderFooterContent(content, context, options, layout) {
500
535
  }, layout) : cursorY;
501
536
  fragEl.style.top = `${textBoxTop}px`;
502
537
  fragEl.style.left = resolveHeaderFooterFloatLeft(measure.width, block.position?.horizontal, layout);
538
+ if (block.widthMode === "intrinsic") {
539
+ const horizontalPosition = resolveHeaderFooterIntrinsicFrameHorizontalPosition(block.position?.horizontal, layout);
540
+ fragEl.style.width = "max-content";
541
+ fragEl.style.left = horizontalPosition.left;
542
+ fragEl.style.transform = horizontalPosition.transform ?? "";
543
+ }
503
544
  if (block.wrapType === "behind") fragEl.style.zIndex = "-1";
504
545
  containerEl.append(fragEl);
505
546
  if (!isPositionedHeaderFooterTextBoxBlock(block)) cursorY += measure.height;
@@ -1823,4 +1864,4 @@ function depopulatePageShell(shell, pageDataMap) {
1823
1864
  }
1824
1865
  }
1825
1866
  //#endregion
1826
- export { PAGE_CLASS_NAMES, PAINTER_PAINTED_EVENT, applySectionHeaderFooterOptions, calculateFootnoteAreaRenderHeight, computePageFingerprint, emuToPixels, findPageShellForPmPos, floatingTableReservesBand, getDefaultPageFontFamily, isFloatingImageRun, renderAllPagesNow, renderFloatingImagesLayer, renderFootnoteArea, renderPage, renderPages, resolveAnchoredImagePosition, resolveHeaderFooterFloatLeft };
1867
+ export { PAGE_CLASS_NAMES, PAINTER_PAINTED_EVENT, applySectionHeaderFooterOptions, calculateFootnoteAreaRenderHeight, computePageFingerprint, emuToPixels, findPageShellForPmPos, floatingTableReservesBand, getDefaultPageFontFamily, isFloatingImageRun, renderAllPagesNow, renderFloatingImagesLayer, renderFootnoteArea, renderPage, renderPages, resolveAnchoredImagePosition, resolveHeaderFooterFloatLeft, resolveHeaderFooterIntrinsicFrameHorizontalPosition };
@@ -12,6 +12,7 @@ import { getAutomaticTextColorForBackground } from "./documentColors.js";
12
12
  import { applyImageVisualAttrs, hasImageCrop, hasImageVisualAttrs } from "./renderImage.js";
13
13
  import { renderParagraphFragment } from "./renderParagraph.js";
14
14
  import { renderTextBoxFragment } from "./renderTextBox.js";
15
+ import { ownedRowBottomBorderOffsets } from "./tableRowPaintGeometry.js";
15
16
  //#region src/layout-painter/renderTable.ts
16
17
  /**
17
18
  * Table Renderer
@@ -36,6 +37,7 @@ const TABLE_CLASS_NAMES = {
36
37
  tableEdgeHandleRight: "layout-table-edge-handle-right"
37
38
  };
38
39
  const CELL_DIAGONAL_BORDER_CLASS = "layout-table-cell-diagonal-border";
40
+ const CELL_BOTTOM_BORDER_CLASS = "layout-table-cell-bottom-border";
39
41
  function renderCellContent({ cell, cellMeasure, context, doc, contentWidthOverride, pageContentPosition }) {
40
42
  const contentEl = doc.createElement("div");
41
43
  contentEl.className = TABLE_CLASS_NAMES.cellContent;
@@ -355,7 +357,7 @@ function renderTableCell({ cell, cellMeasure, x, width, rowHeight, borderFlags,
355
357
  if (cell.borders) {
356
358
  if (borderFlags.drawTop) applyBorder(cellEl, "top", cell.borders.top);
357
359
  applyBorder(cellEl, "right", cell.borders.right);
358
- applyBorder(cellEl, "bottom", cell.borders.bottom);
360
+ if (borderFlags.drawBottom) applyBorder(cellEl, "bottom", cell.borders.bottom);
359
361
  if (borderFlags.drawLeft) applyBorder(cellEl, "left", cell.borders.left);
360
362
  }
361
363
  if (cell.background) {
@@ -444,7 +446,26 @@ function renderTableCell({ cell, cellMeasure, x, width, rowHeight, borderFlags,
444
446
  return cellEl;
445
447
  }
446
448
  const hasVisibleBorder = (border) => border !== void 0 && border.style !== "none" && border.style !== "nil";
447
- function renderTableRow({ row, rowMeasure, rowIndex, y, columnWidths, totalRows, context, doc, rowYPositions, isFirstRowInFragment, bidi = false, columnsPinned = false, cellGrid, cellPlacements, contentClip, pageContentPosition, inlineOffset = 0 }) {
449
+ const isMinimumHeightRow = (row) => row?.height !== void 0 && row.heightRule !== "exact";
450
+ const rowHasVerticalMerge = (row) => row?.cells.some((cell) => (cell.rowSpan ?? 1) > 1) === true;
451
+ const continuationRowBottomBorderOffsets = (fragment, block, measure, cellGrid) => {
452
+ const rowHeights = measure.rows.slice(fragment.fromRow, fragment.toRow).map(({ height }) => height);
453
+ const fragmentHasVerticalMerge = block.rows.slice(fragment.fromRow, fragment.toRow).some((row, fragmentRowIndex) => rowHasVerticalMerge(row) || (cellGrid.occupiedColumnsByRow.get(fragment.fromRow + fragmentRowIndex)?.size ?? 0) > 0);
454
+ if (fragment.continuesFromPrev !== true || fragment.headerRowCount || fragment.topClip !== void 0 || fragment.bottomClip !== void 0 || fragmentHasVerticalMerge) return rowHeights.map(() => 0);
455
+ const snapAfterRow = rowHeights.slice(0, -1).map((_, fragmentRowIndex) => {
456
+ const rowIndex = fragment.fromRow + fragmentRowIndex;
457
+ const row = block.rows[rowIndex];
458
+ const nextRow = block.rows[rowIndex + 1];
459
+ return isMinimumHeightRow(row) && isMinimumHeightRow(nextRow) && !rowHasVerticalMerge(row) && !rowHasVerticalMerge(nextRow) && row?.cells.some((cell) => hasVisibleBorder(cell.borders?.bottom)) === true;
460
+ });
461
+ if (!snapAfterRow.some(Boolean)) return rowHeights.map(() => 0);
462
+ return ownedRowBottomBorderOffsets({
463
+ origin: fragment.y,
464
+ rowHeights,
465
+ snapAfterRow
466
+ });
467
+ };
468
+ function renderTableRow({ row, rowMeasure, rowIndex, y, columnWidths, totalRows, context, doc, rowYPositions, isFirstRowInFragment, bidi = false, columnsPinned = false, cellGrid, cellPlacements, contentClip, pageContentPosition, inlineOffset = 0, bottomBorderOffset = 0 }) {
448
469
  const rowEl = doc.createElement("div");
449
470
  rowEl.className = TABLE_CLASS_NAMES.row;
450
471
  rowEl.style.position = "absolute";
@@ -477,6 +498,7 @@ function renderTableRow({ row, rowMeasure, rowIndex, y, columnWidths, totalRows,
477
498
  const leftCell = getSourceCellAt(cellGrid, rowIndex, bidi ? columnIndex + colSpan : columnIndex - 1);
478
499
  const drawTop = isFirstRow || !hasVisibleBorder(aboveCell?.borders?.bottom);
479
500
  const drawLeft = isFirstCol || !hasVisibleBorder(leftCell?.borders?.right);
501
+ const paintBottomBorderSeparately = bottomBorderOffset > 0 && hasVisibleBorder(cell.borders?.bottom);
480
502
  const cellEl = renderTableCell({
481
503
  cell,
482
504
  cellMeasure,
@@ -485,6 +507,7 @@ function renderTableRow({ row, rowMeasure, rowIndex, y, columnWidths, totalRows,
485
507
  rowHeight: cellHeight,
486
508
  borderFlags: {
487
509
  drawTop,
510
+ drawBottom: !paintBottomBorderSeparately,
488
511
  isLastRow,
489
512
  drawLeft,
490
513
  isLastCol
@@ -503,6 +526,20 @@ function renderTableRow({ row, rowMeasure, rowIndex, y, columnWidths, totalRows,
503
526
  cellEl.dataset["columnIndex"] = String(columnIndex);
504
527
  if (rowSpan > 1) cellEl.dataset["rowSpan"] = String(rowSpan);
505
528
  rowEl.append(cellEl);
529
+ if (paintBottomBorderSeparately) {
530
+ const bottomBorderEl = doc.createElement("div");
531
+ bottomBorderEl.className = CELL_BOTTOM_BORDER_CLASS;
532
+ bottomBorderEl.style.position = "absolute";
533
+ bottomBorderEl.style.left = `${cellLeft}px`;
534
+ bottomBorderEl.style.top = "0";
535
+ bottomBorderEl.style.width = `${width}px`;
536
+ bottomBorderEl.style.height = `${cellHeight + bottomBorderOffset}px`;
537
+ bottomBorderEl.style.boxSizing = "border-box";
538
+ bottomBorderEl.style.pointerEvents = "none";
539
+ bottomBorderEl.style.zIndex = "1";
540
+ applyBorder(bottomBorderEl, "bottom", cell.borders?.bottom);
541
+ rowEl.append(bottomBorderEl);
542
+ }
506
543
  }
507
544
  return rowEl;
508
545
  }
@@ -568,6 +605,7 @@ function renderTableFragment(fragment, block, measure, context, options = {}) {
568
605
  columnWidths: measure.columnWidths,
569
606
  bidi: block.bidi === true
570
607
  });
608
+ const contentRowBottomBorderOffsets = continuationRowBottomBorderOffsets(fragment, block, measure, cellGrid);
571
609
  const headerRowCount = fragment.headerRowCount ?? 0;
572
610
  let y = 0;
573
611
  if (headerRowCount > 0 && fragment.continuesFromPrev) for (let hdrIdx = 0; hdrIdx < headerRowCount; hdrIdx++) {
@@ -646,6 +684,7 @@ function renderTableFragment(fragment, block, measure, context, options = {}) {
646
684
  columnsPinned,
647
685
  cellGrid,
648
686
  cellPlacements,
687
+ bottomBorderOffset: contentRowBottomBorderOffsets[rowIndex - fragment.fromRow] ?? 0,
649
688
  ...contentClip ? { contentClip } : {},
650
689
  ...tablePageContentPosition ? { pageContentPosition: {
651
690
  ...tablePageContentPosition,
@@ -0,0 +1,15 @@
1
+ //#region src/layout-painter/tableRowPaintGeometry.d.ts
2
+ type OwnedRowBottomBorderOffsetsOptions = {
3
+ origin: number;
4
+ rowHeights: readonly number[];
5
+ /** Whether the row ending at this index owns a visible shared bottom edge. */
6
+ snapAfterRow: readonly boolean[];
7
+ };
8
+ /**
9
+ * Offset bottom-owned shared edges onto CSS pixel boundaries. Source row
10
+ * geometry remains unchanged, so content floors and the final band edge stay
11
+ * exact; only the independently painted border moves.
12
+ */
13
+ declare const ownedRowBottomBorderOffsets: ({ origin, rowHeights, snapAfterRow }: OwnedRowBottomBorderOffsetsOptions) => number[];
14
+ //#endregion
15
+ export { ownedRowBottomBorderOffsets };
@@ -0,0 +1,22 @@
1
+ //#region src/layout-painter/tableRowPaintGeometry.ts
2
+ const CSS_PIXEL_ROUNDING_EPSILON = 1e-6;
3
+ /**
4
+ * Offset bottom-owned shared edges onto CSS pixel boundaries. Source row
5
+ * geometry remains unchanged, so content floors and the final band edge stay
6
+ * exact; only the independently painted border moves.
7
+ */
8
+ const ownedRowBottomBorderOffsets = ({ origin, rowHeights, snapAfterRow }) => {
9
+ const offsets = [];
10
+ let boundary = origin;
11
+ for (let rowIndex = 0; rowIndex < rowHeights.length; rowIndex++) {
12
+ boundary += rowHeights[rowIndex] ?? 0;
13
+ if (rowIndex === rowHeights.length - 1 || snapAfterRow[rowIndex] !== true) {
14
+ offsets.push(0);
15
+ continue;
16
+ }
17
+ offsets.push(Math.ceil(boundary - CSS_PIXEL_ROUNDING_EPSILON) - boundary);
18
+ }
19
+ return offsets;
20
+ };
21
+ //#endregion
22
+ export { ownedRowBottomBorderOffsets };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/folio-core",
3
- "version": "0.31.0",
3
+ "version": "0.31.2",
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",