Signal drop!
Relay (operand.online) is unreachable.
Usually, a dropped signal means an upgrade is happening. Hold on!
Sorry, no connección.
Hang in there while we get back on track
gram: docs
> ./packages/core/src/layout-bridge/measuring/measureBlocksPipeline.ts
/**
* Floating-aware block measurement pipeline.
*
* Pre-scans a block list to extract exclusion zones from anchored images,
* floating tables, and floating text boxes; groups co-located floats so
* their combined exclusion applies starting from the earliest anchor; then
* walks the blocks calling the caller-supplied `measureBlock` with the
* active zones and cumulative Y at each step.
*
* Adapters (React, Vue) provide their own `measureBlock` so they can
* decide e.g. whether to cache paragraph measures. The orchestration,
* extraction, and grouping live here so both adapters stay in lockstep.
*
* @packageDocumentation
* @public
*/
import {
isFloatingTextBoxBlock,
isWrapNone,
type FlowBlock,
type ImageRun,
type ImageRunPosition,
type Measure,
type ParagraphBlock,
type TableBlock,
type TextBoxBlock,
} from '../../layout-engine';
import { isTextWrappingFloatingImageRun } from '../../layout-painter/floatingImageFlow';
import {
resolveAnchoredObjectVerticalTop,
type PageGeometry,
} from '../../layout-painter/anchoredObjectPosition';
import { emuToPixels } from '../../utils/units';
import { clampFloatingWrapMargins } from './measureParagraph';
import type { FloatingImageZone } from './floatingZones';
import { measureTableBlock } from '../measureTable';
/**
* A floating exclusion zone tagged with the block index that anchors it.
*/
interface FloatingZoneWithAnchor extends FloatingImageZone {
anchorBlockIndex: number;
/** True for floats positioned relative to page/margin (not paragraph). */
isMarginRelative?: boolean;
}
/**
* Maximum block-index distance for paragraph-relative floats to be considered
* co-located. Anchors within this window with overlapping Y ranges get merged
* so a body paragraph between them sees the combined exclusion zone. Beyond
* this window we keep zones independent — different sections of the document
* routinely have float topY values that coincidentally overlap.
*/
const ANCHOR_PROXIMITY = 4;
/**
* Block-measurement callback shape passed to {@link measureBlocksWithFloats}.
* Adapters (React, Vue) supply this so they can decide platform-specific
* concerns (e.g. paragraph-measure caching, per-section width) while
* sharing the floating-zone orchestration. This is adapter-author API,
* not end-consumer API.
*
* @public
*/
export type MeasureBlockFn = (
block: FlowBlock,
contentWidth: number,
floatingZones?: FloatingImageZone[],
cumulativeY?: number
) => Measure;
/**
* Page geometry (CSS px) used to resolve page/margin-relative anchored objects
* into content-area coordinates — currently the vertical anchor of a top-pinned
* `topAndBottom` band. Same shape the painter uses (see `pageGeometryFromPage`),
* so both paths resolve to identical positions.
*
* @public
*/
export type FloatPageGeometry = PageGeometry;
/**
* Walk `blocks` and produce one `Measure` per block. Before measuring, this
* extracts floating exclusion zones (images / floating tables / floating
* textboxes), groups overlapping co-located floats, and threads the active
* zones plus cumulative Y into each `measureBlock` call.
*
* Pass `pageGeometry` whenever the document may contain page/margin-anchored
* `topAndBottom` text boxes (e.g. a title banner pinned to the page top):
* without it their reserved band falls back to flow-relative Y and the band
* won't line up with where the painter places the box. Build it with the
* shared `pageGeometryFromPage` helper.
*
* @public
*/
export function measureBlocksWithFloats(
blocks: FlowBlock[],
contentWidth: number | number[],
measureBlock: MeasureBlockFn,
pageGeometry?: FloatPageGeometry
): Measure[] {
const defaultWidth = Array.isArray(contentWidth) ? (contentWidth[0] ?? 0) : contentWidth;
const floatingZonesWithAnchors = extractFloatingZones(
blocks,
defaultWidth,
measureBlock,
pageGeometry
);
const marginRelative = floatingZonesWithAnchors.filter((z) => z.isMarginRelative);
const paragraphRelative = floatingZonesWithAnchors.filter((z) => !z.isMarginRelative);
// Margin-relative zones at the same Y likely belong to the same page —
// group by topY and re-anchor to the earliest block index so subsequent
// paragraphs see the combined zone.
const marginByTopY = new Map<number, FloatingZoneWithAnchor[]>();
for (const z of marginRelative) {
const group = marginByTopY.get(z.topY) ?? [];
group.push(z);
marginByTopY.set(z.topY, group);
}
// Paragraph-relative zones merge only when (a) Y ranges overlap AND
// (b) anchors are within ANCHOR_PROXIMITY blocks. The proximity bound
// keeps unrelated floats in distant sections from being merged just
// because their paragraph-local topY values happen to overlap.
const paragraphGroups = groupOverlappingZones(paragraphRelative, ANCHOR_PROXIMITY);
const adjustedZones: FloatingZoneWithAnchor[] = [];
collectReanchoredToEarliest(paragraphGroups, adjustedZones);
collectReanchoredToEarliest(Array.from(marginByTopY.values()), adjustedZones);
const zonesByAnchor = new Map<number, FloatingImageZone[]>();
for (const z of adjustedZones) {
// A page/margin-pinned full-width band (e.g. a title banner at the top of
// the page) reserves space from the top of content, so it must reach the
// blocks that precede its own anchor paragraph. Anchor it at block 0 and
// keep its content-relative topY/bottomY (cumulativeY is then the running
// content offset for the blocks it covers).
//
// Caveat: this pre-pagination pass has no page/section model, so "top of
// content" means the start of the whole flow. Exact for the common case
// (single banner near the document start); a band anchored on a later page
// or in a later section with different geometry can over-reach. Follow-up.
const anchor = z.fullWidthBlock && z.isMarginRelative ? 0 : z.anchorBlockIndex;
const existing = zonesByAnchor.get(anchor) ?? [];
// Strip the anchor-tracking fields; the rest IS a FloatingImageZone. Spread
// (rather than copying each field) so new zone fields can't be dropped here.
const { anchorBlockIndex: _anchorBlockIndex, isMarginRelative: _isMarginRelative, ...zone } = z;
existing.push(zone);
zonesByAnchor.set(anchor, existing);
}
// Derive from the map keys, not the raw zones — full-width bands are
// re-anchored to block 0 above, and the activation set must match.
const anchorIndices = new Set(zonesByAnchor.keys());
let cumulativeY = 0;
let activeZones: FloatingImageZone[] = [];
return blocks.map((block, blockIndex) => {
if (anchorIndices.has(blockIndex)) {
cumulativeY = 0;
activeZones = zonesByAnchor.get(blockIndex) ?? [];
}
const zones = activeZones.length > 0 ? activeZones : undefined;
const blockWidth = Array.isArray(contentWidth)
? (contentWidth[blockIndex] ?? defaultWidth)
: contentWidth;
const measure = measureBlock(block, blockWidth, zones, cumulativeY);
if ('totalHeight' in measure) {
// Floating tables don't advance flow Y (their wrap zone already
// accounts for vertical space). Other blocks do.
if (!(block.kind === 'table' && (block as TableBlock).floating)) {
cumulativeY += measure.totalHeight;
}
}
return measure;
});
}
/**
* Extract floating exclusion zones from all blocks that anchor floats —
* paragraph runs (images), top-level floating tables, and top-level
* floating textboxes. The returned zones are in content-area coordinates
* relative to each anchor block; the orchestration loop in
* {@link measureBlocksWithFloats} re-anchors and threads them through.
*/
function extractFloatingZones(
blocks: FlowBlock[],
contentWidth: number,
measureBlock: MeasureBlockFn,
pageGeometry?: FloatPageGeometry
): FloatingZoneWithAnchor[] {
const zones: FloatingZoneWithAnchor[] = [];
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
const block = blocks[blockIndex];
switch (block.kind) {
case 'paragraph':
extractImageZonesFromParagraph(block as ParagraphBlock, blockIndex, contentWidth, zones);
break;
case 'table':
extractFloatingTableZone(
block as TableBlock,
blockIndex,
contentWidth,
measureBlock,
zones
);
break;
case 'textBox':
extractFloatingTextBoxZone(
block as TextBoxBlock,
blockIndex,
contentWidth,
zones,
pageGeometry
);
break;
}
}
return zones;
}
/**
* Resolve left/right exclusion margins for an OOXML-positioned anchored
* object (image or text box). Shared between image-in-paragraph and
* top-level textbox extraction since both use the same
* `ImageRunPosition` shape and `cssFloat` fallback.
*/
function computeAnchoredMargins(
position: ImageRunPosition | undefined,
cssFloat: 'left' | 'right' | 'none' | undefined,
width: number,
distLeft: number,
distRight: number,
contentWidth: number
): { leftMargin: number; rightMargin: number } {
let leftMargin = 0;
let rightMargin = 0;
const h = position?.horizontal;
if (h?.align === 'left') {
leftMargin = width + distRight;
} else if (h?.align === 'right') {
rightMargin = width + distLeft;
} else if (h?.posOffset !== undefined) {
const x = emuToPixels(h.posOffset);
if (x < contentWidth / 2) {
leftMargin = x + width + distRight;
} else {
rightMargin = contentWidth - x + distLeft;
}
} else if (cssFloat === 'left') {
leftMargin = width + distRight;
} else if (cssFloat === 'right') {
rightMargin = width + distLeft;
}
return clampFloatingWrapMargins(leftMargin, rightMargin, contentWidth);
}
/**
* True when an OOXML position anchors vertically against the page or
* margin (not the surrounding paragraph). Margin/page-relative zones
* apply globally across blocks instead of attaching to one anchor
* paragraph.
*/
function isPositionMarginRelative(position: ImageRunPosition | undefined): boolean {
const rel = position?.vertical?.relativeTo;
return rel === 'margin' || rel === 'page';
}
function extractImageZonesFromParagraph(
paragraphBlock: ParagraphBlock,
blockIndex: number,
contentWidth: number,
out: FloatingZoneWithAnchor[]
): void {
for (const run of paragraphBlock.runs) {
if (run.kind !== 'image') continue;
const imgRun = run as ImageRun;
if (!isTextWrappingFloatingImageRun(imgRun)) continue;
const distTop = imgRun.distTop ?? 0;
const distBottom = imgRun.distBottom ?? 0;
const distLeft = imgRun.distLeft ?? 12;
const distRight = imgRun.distRight ?? 12;
let topY = 0;
const v = imgRun.position?.vertical;
if (v?.align === 'top' && v.relativeTo === 'margin') {
topY = 0;
} else if (v?.posOffset !== undefined) {
topY = emuToPixels(v.posOffset);
}
const bottomY = topY + imgRun.height;
const { leftMargin, rightMargin } = computeAnchoredMargins(
imgRun.position,
imgRun.cssFloat,
imgRun.width,
distLeft,
distRight,
contentWidth
);
if (leftMargin > 0 || rightMargin > 0) {
out.push({
leftMargin,
rightMargin,
topY: topY - distTop,
bottomY: bottomY + distBottom,
anchorBlockIndex: blockIndex,
isMarginRelative: isPositionMarginRelative(imgRun.position),
});
}
}
}
function extractFloatingTableZone(
tableBlock: TableBlock,
blockIndex: number,
contentWidth: number,
measureBlock: MeasureBlockFn,
out: FloatingZoneWithAnchor[]
): void {
const floating = tableBlock.floating;
if (!floating) return;
const tableMeasure = measureTableBlock(tableBlock, contentWidth, measureBlock);
const tableWidth = tableMeasure.totalWidth;
const tableHeight = tableMeasure.totalHeight;
const distLeft = floating.leftFromText ?? 12;
const distRight = floating.rightFromText ?? 12;
const distTop = floating.topFromText ?? 0;
const distBottom = floating.bottomFromText ?? 0;
// Tables use OOXML `w:tblpXSpec` / `tblpX` instead of the image-style
// `align` / `posOffset`, so the common helper above doesn't apply.
let x = 0;
if (floating.tblpX !== undefined) {
x = floating.tblpX;
} else if (floating.tblpXSpec) {
if (floating.tblpXSpec === 'left' || floating.tblpXSpec === 'inside') {
x = 0;
} else if (floating.tblpXSpec === 'right' || floating.tblpXSpec === 'outside') {
x = contentWidth - tableWidth;
} else if (floating.tblpXSpec === 'center') {
x = (contentWidth - tableWidth) / 2;
}
} else if (tableBlock.justification === 'center') {
x = (contentWidth - tableWidth) / 2;
} else if (tableBlock.justification === 'right') {
x = contentWidth - tableWidth;
}
let leftMargin = 0;
let rightMargin = 0;
if (x < contentWidth / 2) {
leftMargin = x + tableWidth + distRight;
} else {
rightMargin = contentWidth - x + distLeft;
}
({ leftMargin, rightMargin } = clampFloatingWrapMargins(leftMargin, rightMargin, contentWidth));
const topY = floating.tblpY ?? 0;
const bottomY = topY + tableHeight;
out.push({
leftMargin,
rightMargin,
topY: topY - distTop,
bottomY: bottomY + distBottom,
anchorBlockIndex: blockIndex,
});
}
function extractFloatingTextBoxZone(
tbBlock: TextBoxBlock,
blockIndex: number,
contentWidth: number,
out: FloatingZoneWithAnchor[],
pageGeometry?: FloatPageGeometry
): void {
if (!isFloatingTextBoxBlock(tbBlock)) return;
if (isWrapNone(tbBlock.wrapType)) return;
const tbWidth = tbBlock.width ?? 0;
const tbHeight = tbBlock.height ?? 0;
if (tbWidth <= 0 || tbHeight <= 0) return;
const distTop = tbBlock.distTop ?? 0;
const distBottom = tbBlock.distBottom ?? 0;
const distLeft = tbBlock.distLeft ?? 12;
const distRight = tbBlock.distRight ?? 12;
// NOTE: the page-pinned topAndBottom band below is currently text-box only.
// A topAndBottom anchored *image* is still laid out as a block image on its
// own line at its anchor (see extractImageZonesFromParagraph / renderPage),
// so a page-anchored image band is not yet honored — follow-up.
//
// topAndBottom: reserve a full-width vertical band so body text flows above
// and below the box. Page/margin-relative boxes (e.g. a banner pinned to the
// page top) need their offset translated into content-area coordinates.
if (tbBlock.wrapType === 'topAndBottom') {
// Resolve the band's vertical top via the SAME resolver the painter uses,
// so the reserved band lines up with where the box is painted regardless of
// anchor kind (page / margin / topMargin / bottomMargin, align or posOffset).
// fragmentY=0: a topAndBottom band is page/margin-pinned; the paragraph-Y
// fallback only applies to genuinely paragraph-relative boxes, which this
// pre-pagination pass anchors at their own block (cumulativeY 0 there).
const rawTopY = resolveAnchoredObjectVerticalTop(
{ width: tbWidth, height: tbHeight, position: tbBlock.position },
0,
pageGeometry
);
// Signed top may be negative when the box reaches up into the top margin.
// The band reserves only the part intruding into content (topY clamped at
// 0), but its bottom is measured from the true top so the reserved height
// matches how far the box extends below the content edge.
const bottomY = rawTopY + tbHeight + distBottom;
if (bottomY <= 0) return;
out.push({
leftMargin: 0,
rightMargin: 0,
topY: Math.max(0, rawTopY - distTop),
bottomY,
anchorBlockIndex: blockIndex,
isMarginRelative: isPositionMarginRelative(tbBlock.position),
fullWidthBlock: true,
});
return;
}
let topY = 0;
if (tbBlock.position?.vertical?.posOffset !== undefined) {
topY = emuToPixels(tbBlock.position.vertical.posOffset);
}
const bottomY = topY + tbHeight;
const { leftMargin, rightMargin } = computeAnchoredMargins(
tbBlock.position,
tbBlock.cssFloat,
tbWidth,
distLeft,
distRight,
contentWidth
);
if (leftMargin <= 0 && rightMargin <= 0) return;
out.push({
leftMargin,
rightMargin,
topY: topY - distTop,
bottomY: bottomY + distBottom,
anchorBlockIndex: blockIndex,
isMarginRelative: isPositionMarginRelative(tbBlock.position),
});
}
/**
* Group `zones` such that any two whose Y ranges overlap AND whose
* anchorBlockIndex differs by no more than `maxAnchorGap` land in the same
* group. Single-pass; groups merge transitively as zones connect them.
*/
function groupOverlappingZones(
zones: FloatingZoneWithAnchor[],
maxAnchorGap: number
): FloatingZoneWithAnchor[][] {
const groups: FloatingZoneWithAnchor[][] = [];
for (const z of zones) {
const target = groups.find((g) =>
g.some(
(other) =>
Math.abs(other.anchorBlockIndex - z.anchorBlockIndex) <= maxAnchorGap &&
z.topY < other.bottomY &&
z.bottomY > other.topY
)
);
if (target) target.push(z);
else groups.push([z]);
}
return groups;
}
/**
* Re-anchor every zone in each group to the group's earliest block index and
* append the result to `out`.
*/
function collectReanchoredToEarliest(
groups: FloatingZoneWithAnchor[][],
out: FloatingZoneWithAnchor[]
): void {
for (const group of groups) {
const minAnchor = Math.min(...group.map((z) => z.anchorBlockIndex));
for (const z of group) {
out.push({ ...z, anchorBlockIndex: minAnchor });
}
}
}