Skip to content

Commit 46ae454

Browse files
VladaHarboursuperdoc-oss-port[bot]
authored andcommitted
fix: image rendering inside textbox
Co-authored-by: VladaHarbour <dataart.vladyslava@harbourcollaborators.com> Co-authored-by: Luccas Correa <luccascorrea@gmail.com> Co-authored-by: Luccas Correa <luccas@harbourshare.com> Source-PR: #3739 Closes #3739 Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: 7fc0712e48812f289c0482ce11e991fef5aee665 Ported-Public-Prefix: superdoc/public
1 parent 7c1a897 commit 46ae454

14 files changed

Lines changed: 912 additions & 126 deletions

File tree

packages/layout-engine/layout-bridge/src/remeasure.ts

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ import {
1818
DEFAULT_TAB_INTERVAL_PX as _DEFAULT_TAB_INTERVAL_PX,
1919
} from '@superdoc/common/layout-constants';
2020
import { resolveListTextStartPx } from '@superdoc/common/list-marker-utils';
21+
import {
22+
isAtomicLayoutRun,
23+
getAtomicRunLayoutSize,
24+
type MeasureAtomicText,
25+
type MinimalAtomicRun,
26+
} from '@superdoc/common/atomic-run-size';
2127

2228
/**
2329
* Type definition for paragraph block attributes that include indentation and tab stops.
@@ -349,6 +355,49 @@ const getRunWidth = (run: Run): number => {
349355
return typeof width === 'number' ? width : 0;
350356
};
351357

358+
/**
359+
* Measures field annotation label text on the shared canvas. Mirrors the run's
360+
* font properties so the pill width tracks the painted glyphs. Falls back to a
361+
* proportional estimate when no canvas context is available (SSR), matching how
362+
* {@link measureRunSliceWidth} approximates ordinary text.
363+
*/
364+
const measureAtomicText: MeasureAtomicText = (text, run, fontSize) => {
365+
const family = (run.fontFamily as string | undefined) || 'Arial';
366+
const context = getCtx();
367+
if (!context) {
368+
return Math.max(0, text.length * (fontSize * 0.6));
369+
}
370+
const italic = run.italic ? 'italic ' : '';
371+
const bold = run.bold ? 'bold ' : '';
372+
context.font = `${italic}${bold}${fontSize}px ${family}`.trim();
373+
return context.measureText(text).width;
374+
};
375+
376+
const getAtomicRunLayoutWidth = (run: Run): number =>
377+
getAtomicRunLayoutSize(run as MinimalAtomicRun, measureAtomicText).width;
378+
379+
const getAtomicRunLayoutHeight = (run: Run): number =>
380+
getAtomicRunLayoutSize(run as MinimalAtomicRun, measureAtomicText).height;
381+
382+
/** Max atomic (image/math/field) height for runs actually included on [fromRun, toRun]. */
383+
const getLineMaxAtomicHeight = (
384+
runs: Run[],
385+
fromRun: number,
386+
fromChar: number,
387+
toRun: number,
388+
toChar: number,
389+
): number => {
390+
let max = 0;
391+
for (let r = fromRun; r <= toRun; r += 1) {
392+
const run = runs[r];
393+
if (!isAtomicLayoutRun(run)) continue;
394+
if (r === toRun && toChar === 0) continue;
395+
if (r === fromRun && r === toRun && toChar <= fromChar) continue;
396+
max = Math.max(max, getAtomicRunLayoutHeight(run));
397+
}
398+
return max;
399+
};
400+
352401
/**
353402
* Checks if a break run is a line break (as opposed to page/column break).
354403
*
@@ -672,7 +721,10 @@ const scanTabAlignmentGroup = (
672721

673722
const text = runText(run);
674723
if (!text) {
675-
const runWidth = getRunWidth(run);
724+
// Atomic runs (image/math/field annotation) carry their box in the shared sizer,
725+
// not in `run.width` — field annotations in particular are 0 via getRunWidth, which
726+
// would zero out the group and skip center/right/decimal alignment.
727+
const runWidth = isAtomicLayoutRun(run) ? getAtomicRunLayoutWidth(run) : getRunWidth(run);
676728
if (runWidth > 0) {
677729
totalWidth += runWidth;
678730
endRun = r;
@@ -742,7 +794,7 @@ const measureTabAlignmentGroupInLine = (
742794

743795
const text = runText(run);
744796
if (!text) {
745-
totalWidth += getRunWidth(run);
797+
totalWidth += isAtomicLayoutRun(run) ? getAtomicRunLayoutWidth(run) : getRunWidth(run);
746798
continue;
747799
}
748800

@@ -1001,7 +1053,27 @@ const applyTabLayoutToLines = (
10011053

10021054
const text = runText(run);
10031055
if (!text) {
1004-
cursorX += getRunWidth(run);
1056+
const atomicWidth = isAtomicLayoutRun(run) ? getAtomicRunLayoutWidth(run) : getRunWidth(run);
1057+
// Position atomic runs (image/math/field annotation) that follow an
1058+
// end/center/decimal tab so the pill aligns to the stop, matching the DOM
1059+
// measurer. Without this the pending alignment would leak to a later text run.
1060+
const pendingTabAlign = consumePendingTabAlignStart();
1061+
if (pendingTabAlign != null) {
1062+
const segment: LineSegment = {
1063+
runIndex,
1064+
fromChar: 0,
1065+
toChar: 1,
1066+
width: atomicWidth,
1067+
x: pendingTabAlign.paintX,
1068+
...(pendingTabAlign.precedingTabEndX !== undefined
1069+
? { precedingTabEndX: pendingTabAlign.precedingTabEndX }
1070+
: {}),
1071+
};
1072+
cursorX = pendingTabAlign.layoutX + atomicWidth;
1073+
segments.push(segment);
1074+
} else {
1075+
cursorX += atomicWidth;
1076+
}
10051077
lineWidth = Math.max(lineWidth, cursorX);
10061078
continue;
10071079
}
@@ -1395,6 +1467,17 @@ export function remeasureParagraph(
13951467
endChar = text.length > 0 ? text.length : start + 1;
13961468
continue;
13971469
}
1470+
if (text.length === 0 && isAtomicLayoutRun(run)) {
1471+
const atomicWidth = getAtomicRunLayoutWidth(run);
1472+
if (width > 0 && width + atomicWidth > effectiveMaxWidth - WIDTH_FUDGE_PX) {
1473+
didBreakInThisLine = true;
1474+
break;
1475+
}
1476+
width += atomicWidth;
1477+
endRun = r;
1478+
endChar = 1;
1479+
continue;
1480+
}
13981481
for (let c = start; c < text.length; c += 1) {
13991482
const ch = text[c];
14001483
if (ch === '\t') {
@@ -1488,6 +1571,8 @@ export function remeasureParagraph(
14881571
endChar = startChar + 1;
14891572
}
14901573

1574+
const lineMaxAtomicHeight = getLineMaxAtomicHeight(runs, startRun, startChar, endRun, endChar);
1575+
14911576
const line: Line = {
14921577
fromRun: startRun,
14931578
fromChar: startChar,
@@ -1496,8 +1581,9 @@ export function remeasureParagraph(
14961581
width,
14971582
ascent: 0,
14981583
descent: 0,
1499-
lineHeight: lineHeightForRuns(runs, startRun, endRun, lastMeasuredFontSize),
1584+
lineHeight: Math.max(lineHeightForRuns(runs, startRun, endRun, lastMeasuredFontSize), lineMaxAtomicHeight),
15001585
maxWidth: effectiveMaxWidth,
1586+
...(lineMaxAtomicHeight > 0 ? { maxImageHeight: lineMaxAtomicHeight } : {}),
15011587
};
15021588
lines.push(line);
15031589
if (lineMaxTextFontSize > 0) {

packages/layout-engine/layout-bridge/test/remeasure.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1638,4 +1638,125 @@ describe('remeasureParagraph', () => {
16381638
expect(measure.lines[0].width).toBeGreaterThan(0);
16391639
});
16401640
});
1641+
1642+
describe('atomic runs', () => {
1643+
it('does not retain image line height after rewinding past an inline image break point', () => {
1644+
const block = createBlock([
1645+
textRun('A '),
1646+
{
1647+
kind: 'image',
1648+
src: 'data:image/png;base64,abc',
1649+
width: 179,
1650+
height: 179,
1651+
pmStart: 3,
1652+
pmEnd: 4,
1653+
},
1654+
textRun('B'.repeat(30)),
1655+
]);
1656+
1657+
// Fits "A " + image, then overflows on following text and rewinds to the space.
1658+
const measure = remeasureParagraph(block, 200);
1659+
1660+
expect(measure.lines.length).toBeGreaterThan(1);
1661+
expect(measure.lines[0].toRun).toBe(0);
1662+
expect(measure.lines[0].toChar).toBe(2);
1663+
expect(measure.lines[0].maxImageHeight).toBeUndefined();
1664+
expect(measure.lines[0].lineHeight).toBeCloseTo(16 * 1.2);
1665+
expect(measure.lines[1].maxImageHeight).toBe(179);
1666+
});
1667+
1668+
it('measures image-only paragraphs with correct width and line height', () => {
1669+
const block = createBlock([
1670+
{
1671+
kind: 'image',
1672+
src: 'data:image/png;base64,abc',
1673+
width: 179,
1674+
height: 179,
1675+
pmStart: 1,
1676+
pmEnd: 2,
1677+
},
1678+
]);
1679+
block.attrs = { alignment: 'center' };
1680+
1681+
const measure = remeasureParagraph(block, 179.8);
1682+
1683+
expect(measure.lines).toHaveLength(1);
1684+
expect(measure.lines[0].width).toBe(179);
1685+
expect(measure.lines[0].lineHeight).toBe(179);
1686+
expect(measure.lines[0].maxImageHeight).toBe(179);
1687+
expect(measure.totalHeight).toBe(179);
1688+
});
1689+
1690+
it('sizes field annotation pills from displayLabel + padding (not run.width/height)', () => {
1691+
// Regression: field annotation runs carry no top-level width/height, so the old
1692+
// atomic sizing measured them 0x0 and the pill collapsed. The shared sizer now
1693+
// measures the label (4 chars * 10px) + pill padding (8px) = 48px wide.
1694+
const block = createBlock([
1695+
{
1696+
kind: 'fieldAnnotation',
1697+
variant: 'text',
1698+
displayLabel: 'Name',
1699+
fontSize: 16,
1700+
pmStart: 1,
1701+
pmEnd: 2,
1702+
} as unknown as Run,
1703+
]);
1704+
1705+
const measure = remeasureParagraph(block, 200);
1706+
1707+
expect(measure.lines).toHaveLength(1);
1708+
expect(measure.lines[0].width).toBe(48);
1709+
// Pill height = fontSize * 1.2 + vertical padding (6) = 25.2, taller than the
1710+
// 16px-text line height, so it drives both maxImageHeight and lineHeight.
1711+
expect(measure.lines[0].maxImageHeight).toBeCloseTo(16 * 1.2 + 6);
1712+
expect(measure.lines[0].lineHeight).toBeCloseTo(16 * 1.2 + 6);
1713+
});
1714+
1715+
it('sizes math runs from precomputed dimensions without adding dist* margins', () => {
1716+
const block = createBlock([
1717+
{
1718+
kind: 'math',
1719+
ommlJson: {},
1720+
textContent: '',
1721+
width: 30,
1722+
height: 24,
1723+
// dist* is not part of the math box; it must be ignored.
1724+
distLeft: 9,
1725+
distTop: 9,
1726+
pmStart: 1,
1727+
pmEnd: 2,
1728+
} as unknown as Run,
1729+
]);
1730+
1731+
const measure = remeasureParagraph(block, 200);
1732+
1733+
expect(measure.lines).toHaveLength(1);
1734+
expect(measure.lines[0].width).toBe(30);
1735+
expect(measure.lines[0].maxImageHeight).toBe(24);
1736+
});
1737+
1738+
it('right-aligns a field annotation following an end tab (atomic group sizing)', () => {
1739+
// Regression: the tab look-ahead measured the field with getRunWidth (0), so the
1740+
// group width was zero and the aligned branch was skipped, leaving the pill at the
1741+
// tab stop instead of ending on it. The pill is 'AB' (2*10) + 8 padding = 28px, so
1742+
// a right (end) stop at 100px must start it at 72px.
1743+
const tabStop: TabStop = { pos: pxToTwips(100), val: 'end' };
1744+
const field = {
1745+
kind: 'fieldAnnotation',
1746+
variant: 'text',
1747+
displayLabel: 'AB',
1748+
fontSize: 16,
1749+
pmStart: 3,
1750+
pmEnd: 4,
1751+
} as unknown as Run;
1752+
const block = createBlock([textRun('X'), tabRun(), field], { tabs: [tabStop] });
1753+
1754+
const measure = remeasureParagraph(block, 200);
1755+
1756+
expect(measure.lines).toHaveLength(1);
1757+
const fieldSegment = measure.lines[0].segments?.find((segment) => segment.runIndex === 2);
1758+
expect(fieldSegment?.width).toBe(28);
1759+
expect(fieldSegment?.x).toBeCloseTo(72, 1);
1760+
});
1761+
});
16411762
});

packages/layout-engine/layout-engine/src/layout-drawing.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,70 @@ describe('layoutDrawingBlock', () => {
842842
expect(fragment.x).toBe(400);
843843
});
844844

845+
it('should center inline textboxShape drawings using paragraph alignment metadata', () => {
846+
const context = createMockContext(
847+
{
848+
drawingKind: 'textboxShape',
849+
attrs: {
850+
pmStart: 10,
851+
pmEnd: 11,
852+
wrap: { type: 'Inline' },
853+
inlineParagraphAlignment: 'center',
854+
},
855+
},
856+
{ width: 200, height: 150 },
857+
);
858+
const state = context.ensurePage();
859+
860+
layoutDrawingBlock(context);
861+
862+
const fragment = state.page.fragments[0] as DrawingFragment;
863+
// alignBox = 600, extra = 600 - 200 = 400, x = 0 + 200 = 200
864+
expect(fragment.x).toBe(200);
865+
});
866+
867+
it('should right-align inline textboxShape drawings using paragraph alignment metadata', () => {
868+
const context = createMockContext(
869+
{
870+
drawingKind: 'textboxShape',
871+
attrs: {
872+
pmStart: 10,
873+
pmEnd: 11,
874+
wrap: { type: 'Inline' },
875+
inlineParagraphAlignment: 'right',
876+
},
877+
},
878+
{ width: 200, height: 150 },
879+
);
880+
const state = context.ensurePage();
881+
882+
layoutDrawingBlock(context);
883+
884+
const fragment = state.page.fragments[0] as DrawingFragment;
885+
expect(fragment.x).toBe(400);
886+
});
887+
888+
it('should not apply paragraph alignment metadata when textboxShape is not inline', () => {
889+
const context = createMockContext(
890+
{
891+
drawingKind: 'textboxShape',
892+
attrs: {
893+
pmStart: 10,
894+
pmEnd: 11,
895+
wrap: { type: 'Square' },
896+
inlineParagraphAlignment: 'center',
897+
},
898+
},
899+
{ width: 200, height: 150 },
900+
);
901+
const state = context.ensurePage();
902+
903+
layoutDrawingBlock(context);
904+
905+
const fragment = state.page.fragments[0] as DrawingFragment;
906+
expect(fragment.x).toBe(0);
907+
});
908+
845909
it('should not apply paragraph alignment metadata when shapeGroup is not inline', () => {
846910
const context = createMockContext(
847911
{

packages/layout-engine/layout-engine/src/layout-drawing.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,10 @@ export function layoutDrawingBlock({
8989
const maxWidthForBlock =
9090
attrs?.isFullWidth === true && maxWidth > 0 ? Math.max(1, maxWidth - indentLeft - indentRight) : maxWidth;
9191
const rawWrap = attrs?.wrap as { type?: unknown } | undefined;
92-
const isInlineShapeGroup = block.drawingKind === 'shapeGroup' && rawWrap?.type === 'Inline';
92+
// Inline shape groups and textboxes render at their authored width, so a centered or
93+
// right-aligned host paragraph must offset the whole box within the column (SD: IT-1140).
94+
const isInlineAlignableDrawing =
95+
(block.drawingKind === 'shapeGroup' || block.drawingKind === 'textboxShape') && rawWrap?.type === 'Inline';
9396
const inlineParagraphAlignment =
9497
attrs?.inlineParagraphAlignment === 'center' || attrs?.inlineParagraphAlignment === 'right'
9598
? attrs.inlineParagraphAlignment
@@ -117,7 +120,7 @@ export function layoutDrawingBlock({
117120

118121
const pmRange = extractBlockPmRange(block);
119122
let x = columnX(state) + marginLeft + indentLeft;
120-
if (isInlineShapeGroup && inlineParagraphAlignment) {
123+
if (isInlineAlignableDrawing && inlineParagraphAlignment) {
121124
const pIndentLeft = typeof attrs?.paragraphIndentLeft === 'number' ? attrs.paragraphIndentLeft : 0;
122125
const pIndentRight = typeof attrs?.paragraphIndentRight === 'number' ? attrs.paragraphIndentRight : 0;
123126
const alignBox = Math.max(0, maxWidthForBlock - pIndentLeft - pIndentRight);

0 commit comments

Comments
 (0)