Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion packages/layout-engine/layout-bridge/src/remeasure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,38 @@ const getRunWidth = (run: Run): number => {
return typeof width === 'number' ? width : 0;
};

const isAtomicLayoutRun = (run: Run): boolean => 'src' in run || run.kind === 'math' || run.kind === 'fieldAnnotation';

const getAtomicRunLayoutWidth = (run: Run): number => {
const margins = run as { distLeft?: number; distRight?: number };
return getRunWidth(run) + (margins.distLeft ?? 0) + (margins.distRight ?? 0);
};

const getAtomicRunLayoutHeight = (run: Run): number => {
const baseHeight = typeof (run as { height?: number }).height === 'number' ? (run as { height: number }).height : 0;
const margins = run as { distTop?: number; distBottom?: number };
return baseHeight + (margins.distTop ?? 0) + (margins.distBottom ?? 0);
};

/** Max atomic (image/math/field) height for runs actually included on [fromRun, toRun]. */
const getLineMaxAtomicHeight = (
runs: Run[],
fromRun: number,
fromChar: number,
toRun: number,
toChar: number,
): number => {
let max = 0;
for (let r = fromRun; r <= toRun; r += 1) {
const run = runs[r];
if (!isAtomicLayoutRun(run)) continue;
if (r === toRun && toChar === 0) continue;
if (r === fromRun && r === toRun && toChar <= fromChar) continue;
max = Math.max(max, getAtomicRunLayoutHeight(run));
}
return max;
};

/**
* Checks if a break run is a line break (as opposed to page/column break).
*
Expand Down Expand Up @@ -1395,6 +1427,17 @@ export function remeasureParagraph(
endChar = text.length > 0 ? text.length : start + 1;
continue;
}
if (text.length === 0 && isAtomicLayoutRun(run)) {
const atomicWidth = getAtomicRunLayoutWidth(run);
if (width > 0 && width + atomicWidth > effectiveMaxWidth - WIDTH_FUDGE_PX) {
didBreakInThisLine = true;
break;
}
width += atomicWidth;
endRun = r;
endChar = 1;
continue;
}
for (let c = start; c < text.length; c += 1) {
const ch = text[c];
if (ch === '\t') {
Expand Down Expand Up @@ -1488,6 +1531,8 @@ export function remeasureParagraph(
endChar = startChar + 1;
}

const lineMaxAtomicHeight = getLineMaxAtomicHeight(runs, startRun, startChar, endRun, endChar);

const line: Line = {
fromRun: startRun,
fromChar: startChar,
Expand All @@ -1496,8 +1541,9 @@ export function remeasureParagraph(
width,
ascent: 0,
descent: 0,
lineHeight: lineHeightForRuns(runs, startRun, endRun, lastMeasuredFontSize),
lineHeight: Math.max(lineHeightForRuns(runs, startRun, endRun, lastMeasuredFontSize), lineMaxAtomicHeight),
maxWidth: effectiveMaxWidth,
...(lineMaxAtomicHeight > 0 ? { maxImageHeight: lineMaxAtomicHeight } : {}),
Comment thread
VladaHarbour marked this conversation as resolved.
};
lines.push(line);
if (lineMaxTextFontSize > 0) {
Expand Down
49 changes: 49 additions & 0 deletions packages/layout-engine/layout-bridge/test/remeasure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1638,4 +1638,53 @@ describe('remeasureParagraph', () => {
expect(measure.lines[0].width).toBeGreaterThan(0);
});
});

describe('atomic runs', () => {
it('does not retain image line height after rewinding past an inline image break point', () => {
const block = createBlock([
textRun('A '),
{
kind: 'image',
src: 'data:image/png;base64,abc',
width: 179,
height: 179,
pmStart: 3,
pmEnd: 4,
},
textRun('B'.repeat(30)),
]);

// Fits "A " + image, then overflows on following text and rewinds to the space.
const measure = remeasureParagraph(block, 200);

expect(measure.lines.length).toBeGreaterThan(1);
expect(measure.lines[0].toRun).toBe(0);
expect(measure.lines[0].toChar).toBe(2);
expect(measure.lines[0].maxImageHeight).toBeUndefined();
expect(measure.lines[0].lineHeight).toBeCloseTo(16 * 1.2);
expect(measure.lines[1].maxImageHeight).toBe(179);
});

it('measures image-only paragraphs with correct width and line height', () => {
const block = createBlock([
{
kind: 'image',
src: 'data:image/png;base64,abc',
width: 179,
height: 179,
pmStart: 1,
pmEnd: 2,
},
]);
block.attrs = { alignment: 'center' };

const measure = remeasureParagraph(block, 179.8);

expect(measure.lines).toHaveLength(1);
expect(measure.lines[0].width).toBe(179);
expect(measure.lines[0].lineHeight).toBe(179);
expect(measure.lines[0].maxImageHeight).toBe(179);
expect(measure.totalHeight).toBe(179);
});
});
});
64 changes: 64 additions & 0 deletions packages/layout-engine/layout-engine/src/layout-drawing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,70 @@ describe('layoutDrawingBlock', () => {
expect(fragment.x).toBe(400);
});

it('should center inline textboxShape drawings using paragraph alignment metadata', () => {
const context = createMockContext(
{
drawingKind: 'textboxShape',
attrs: {
pmStart: 10,
pmEnd: 11,
wrap: { type: 'Inline' },
inlineParagraphAlignment: 'center',
},
},
{ width: 200, height: 150 },
);
const state = context.ensurePage();

layoutDrawingBlock(context);

const fragment = state.page.fragments[0] as DrawingFragment;
// alignBox = 600, extra = 600 - 200 = 400, x = 0 + 200 = 200
expect(fragment.x).toBe(200);
});

it('should right-align inline textboxShape drawings using paragraph alignment metadata', () => {
const context = createMockContext(
{
drawingKind: 'textboxShape',
attrs: {
pmStart: 10,
pmEnd: 11,
wrap: { type: 'Inline' },
inlineParagraphAlignment: 'right',
},
},
{ width: 200, height: 150 },
);
const state = context.ensurePage();

layoutDrawingBlock(context);

const fragment = state.page.fragments[0] as DrawingFragment;
expect(fragment.x).toBe(400);
});

it('should not apply paragraph alignment metadata when textboxShape is not inline', () => {
const context = createMockContext(
{
drawingKind: 'textboxShape',
attrs: {
pmStart: 10,
pmEnd: 11,
wrap: { type: 'Square' },
inlineParagraphAlignment: 'center',
},
},
{ width: 200, height: 150 },
);
const state = context.ensurePage();

layoutDrawingBlock(context);

const fragment = state.page.fragments[0] as DrawingFragment;
expect(fragment.x).toBe(0);
});

it('should not apply paragraph alignment metadata when shapeGroup is not inline', () => {
const context = createMockContext(
{
Expand Down
7 changes: 5 additions & 2 deletions packages/layout-engine/layout-engine/src/layout-drawing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,10 @@ export function layoutDrawingBlock({
const maxWidthForBlock =
attrs?.isFullWidth === true && maxWidth > 0 ? Math.max(1, maxWidth - indentLeft - indentRight) : maxWidth;
const rawWrap = attrs?.wrap as { type?: unknown } | undefined;
const isInlineShapeGroup = block.drawingKind === 'shapeGroup' && rawWrap?.type === 'Inline';
// Inline shape groups and textboxes render at their authored width, so a centered or
// right-aligned host paragraph must offset the whole box within the column (SD: IT-1140).
const isInlineAlignableDrawing =
(block.drawingKind === 'shapeGroup' || block.drawingKind === 'textboxShape') && rawWrap?.type === 'Inline';
const inlineParagraphAlignment =
attrs?.inlineParagraphAlignment === 'center' || attrs?.inlineParagraphAlignment === 'right'
? attrs.inlineParagraphAlignment
Expand Down Expand Up @@ -117,7 +120,7 @@ export function layoutDrawingBlock({

const pmRange = extractBlockPmRange(block);
let x = columnX(state) + marginLeft + indentLeft;
if (isInlineShapeGroup && inlineParagraphAlignment) {
if (isInlineAlignableDrawing && inlineParagraphAlignment) {
const pIndentLeft = typeof attrs?.paragraphIndentLeft === 'number' ? attrs.paragraphIndentLeft : 0;
const pIndentRight = typeof attrs?.paragraphIndentRight === 'number' ? attrs.paragraphIndentRight : 0;
const alignBox = Math.max(0, maxWidthForBlock - pIndentLeft - pIndentRight);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,116 @@ describe('shapes converter', () => {
},
});
});

it('derives inlineParagraphAlignment from a centered wrapper paragraph for inline textboxes', () => {
const node: PMNode = {
type: 'shapeContainer',
attrs: {
width: 199,
height: 191,
wrap: { type: 'Inline' },
wrapperParagraph: {
textAlign: 'center',
paragraphProperties: { justification: 'center' },
},
},
};

const result = shapeContainerNodeToDrawingBlock(node, mockBlockIdGenerator, mockPositionMap) as DrawingBlock & {
attrs?: Record<string, unknown>;
};

expect(result.attrs?.inlineParagraphAlignment).toBe('center');
});

it('derives wrapper paragraph indents with inlineParagraphAlignment for centered inline textboxes', () => {
const node: PMNode = {
type: 'shapeContainer',
attrs: {
width: 199,
height: 191,
wrap: { type: 'Inline' },
wrapperParagraph: {
textAlign: 'center',
paragraphProperties: {
justification: 'center',
indent: { left: 720, right: 360 },
},
},
},
};

const result = shapeContainerNodeToDrawingBlock(node, mockBlockIdGenerator, mockPositionMap) as DrawingBlock & {
attrs?: Record<string, unknown>;
};

expect(result.attrs?.inlineParagraphAlignment).toBe('center');
expect(result.attrs?.paragraphIndentLeft).toBe(48);
expect(result.attrs?.paragraphIndentRight).toBe(24);
});

it('derives inlineParagraphAlignment "center" from a distribute wrapper paragraph', () => {
const node: PMNode = {
type: 'shapeContainer',
attrs: {
width: 199,
height: 191,
wrap: { type: 'Inline' },
wrapperParagraph: {
textAlign: 'justify',
paragraphProperties: { justification: 'distribute' },
},
},
};

const result = shapeContainerNodeToDrawingBlock(node, mockBlockIdGenerator, mockPositionMap) as DrawingBlock & {
attrs?: Record<string, unknown>;
};

expect(result.attrs?.inlineParagraphAlignment).toBe('center');
});

it('does not derive inlineParagraphAlignment for non-inline (anchored) textboxes', () => {
const node: PMNode = {
type: 'shapeContainer',
attrs: {
width: 199,
height: 191,
wrap: { type: 'Square' },
wrapperParagraph: {
textAlign: 'center',
paragraphProperties: { justification: 'center' },
},
},
};

const result = shapeContainerNodeToDrawingBlock(node, mockBlockIdGenerator, mockPositionMap) as DrawingBlock & {
attrs?: Record<string, unknown>;
};

expect(result.attrs?.inlineParagraphAlignment).toBeUndefined();
});

it('does not derive inlineParagraphAlignment for left-aligned wrapper paragraphs', () => {
const node: PMNode = {
type: 'shapeContainer',
attrs: {
width: 199,
height: 191,
wrap: { type: 'Inline' },
wrapperParagraph: {
textAlign: 'left',
paragraphProperties: { justification: 'left' },
},
},
};

const result = shapeContainerNodeToDrawingBlock(node, mockBlockIdGenerator, mockPositionMap) as DrawingBlock & {
attrs?: Record<string, unknown>;
};

expect(result.attrs?.inlineParagraphAlignment).toBeUndefined();
});
});

describe('shapeTextboxNodeToDrawingBlock', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
mergeWrapDistancesFromPadding,
ptToPx,
} from '../utilities.js';
import { computeParagraphAttrs } from '../attributes/index.js';
import { getLastParagraphFont } from './paragraph.js';

// ============================================================================
Expand Down Expand Up @@ -837,6 +838,41 @@ export function shapeGroupNodeToDrawingBlock(
});
}

/**
* Inline textbox/shape paragraphs whose only content is the drawing are lifted to the
* document top level by the importer, which stashes the original host paragraph props on
* `wrapperParagraph`. Those drawings keep their authored width, so a centered/right-aligned
* host paragraph must still offset the whole box on the page. Derive the alignment metadata
* the layout engine consumes (`inlineParagraphAlignment` plus wrapper indents) from that
* wrapper (SD: IT-1140).
*
* Only applies to inline-wrapped drawings; anchored drawings are positioned by their anchor.
*/
const resolveInlineAlignmentFromWrapper = (rawAttrs: Record<string, unknown>): Record<string, unknown> => {
const wrapType = (rawAttrs.wrap as { type?: unknown } | undefined)?.type;
if (wrapType !== 'Inline' || !isPlainObject(rawAttrs.wrapperParagraph)) {
return {};
}
const wrapper = rawAttrs.wrapperParagraph as Record<string, unknown>;
const paragraphProperties = isPlainObject(wrapper.paragraphProperties)
? (wrapper.paragraphProperties as Record<string, unknown>)
: undefined;
// w:jc="distribute" visually centers a sole inline drawing; the PM `textAlign` attribute
// collapses it to 'justify', so fall back to the raw justification to detect it.
const justification = paragraphProperties?.justification;
const textAlign = typeof wrapper.textAlign === 'string' ? wrapper.textAlign : undefined;
const effectiveAlignment = justification === 'distribute' ? 'center' : textAlign;
if (effectiveAlignment !== 'center' && effectiveAlignment !== 'right') {
return {};
}
const metadata: Record<string, unknown> = { inlineParagraphAlignment: effectiveAlignment };
const { paragraphAttrs } = computeParagraphAttrs({ type: 'paragraph', attrs: wrapper } as PMNode);
const indent = paragraphAttrs.indent;
if (typeof indent?.left === 'number') metadata.paragraphIndentLeft = indent.left;
if (typeof indent?.right === 'number') metadata.paragraphIndentRight = indent.right;
return metadata;
};

/**
* Convert a ProseMirror shapeContainer node to a DrawingBlock
*
Expand Down Expand Up @@ -869,6 +905,7 @@ export function shapeContainerNodeToDrawingBlock(
return buildDrawingBlock(
{
...rawAttrs,
...resolveInlineAlignmentFromWrapper(rawAttrs),
...(textContent ? { textContent } : {}),
...(rawAttrs.textAlign == null && textContent?.horizontalAlign ? { textAlign: textContent.horizontalAlign } : {}),
...(rawAttrs.textInsets == null ? { textInsets: resolveTextboxInsetsFromAttrs(textboxAttrs) } : {}),
Expand Down
Loading
Loading