Skip to content

Commit f866ee0

Browse files
artem-harbourcaio-pizzolharbournick
authored
fix: centered image (#2762)
* fix: centered image * fix(layout-engine): center inline shapeGroup within indented text box The centering math was using the full column width, ignoring paragraph indents (w:ind). This meant centered/right-aligned inline wpg groups in indented paragraphs were offset from where Word places them. Now propagates paragraphAttrs.indent through the drawing block attrs and subtracts it from the alignment box in layoutDrawingBlock. * test(layout-engine): add missing alignment guard and edge case tests Covers all ST_Jc values (left/justify/distribute produce no offset), non-shapeGroup drawingKind guard (image/vectorShape/chart), missing and empty wrap guards, oversized group scaling interaction, right alignment propagation, and left/justify non-propagation in pm-adapter. * fix(pm-adapter): treat w:jc=distribute as center for inline shapeGroups Word distributes remaining space equally around single inline content, which visually centers a sole inline drawing. normalizeAlignment collapses 'distribute' to 'justify', so we check the raw justification value from resolvedParagraphProperties to distinguish it from 'both' (which only stretches inter-word spacing and does not center). --------- Co-authored-by: Caio Pizzol <caio@harbourshare.com> Co-authored-by: Nick Bernal <nick@superdoc.dev>
1 parent 49d4728 commit f866ee0

4 files changed

Lines changed: 590 additions & 1 deletion

File tree

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

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,5 +778,248 @@ describe('layoutDrawingBlock', () => {
778778
expect(fragment.width).toBe(600);
779779
expect(fragment.height).toBeCloseTo(333 * expectedScale, 10); // Allow floating point precision
780780
});
781+
782+
it('should center inline shapeGroup drawings using paragraph alignment metadata', () => {
783+
const context = createMockContext(
784+
{
785+
drawingKind: 'shapeGroup',
786+
attrs: {
787+
pmStart: 10,
788+
pmEnd: 11,
789+
wrap: { type: 'Inline' },
790+
inlineParagraphAlignment: 'center',
791+
},
792+
},
793+
{ width: 200, height: 150 },
794+
);
795+
const state = context.ensurePage();
796+
797+
layoutDrawingBlock(context);
798+
799+
const fragment = state.page.fragments[0] as DrawingFragment;
800+
expect(fragment.x).toBe(200);
801+
});
802+
803+
it('should right-align inline shapeGroup drawings using paragraph alignment metadata', () => {
804+
const context = createMockContext(
805+
{
806+
drawingKind: 'shapeGroup',
807+
attrs: {
808+
pmStart: 10,
809+
pmEnd: 11,
810+
wrap: { type: 'Inline' },
811+
inlineParagraphAlignment: 'right',
812+
},
813+
},
814+
{ width: 200, height: 150 },
815+
);
816+
const state = context.ensurePage();
817+
818+
layoutDrawingBlock(context);
819+
820+
const fragment = state.page.fragments[0] as DrawingFragment;
821+
expect(fragment.x).toBe(400);
822+
});
823+
824+
it('should not apply paragraph alignment metadata when shapeGroup is not inline', () => {
825+
const context = createMockContext(
826+
{
827+
drawingKind: 'shapeGroup',
828+
attrs: {
829+
pmStart: 10,
830+
pmEnd: 11,
831+
wrap: { type: 'Square' },
832+
inlineParagraphAlignment: 'center',
833+
},
834+
},
835+
{ width: 200, height: 150 },
836+
);
837+
const state = context.ensurePage();
838+
839+
layoutDrawingBlock(context);
840+
841+
const fragment = state.page.fragments[0] as DrawingFragment;
842+
expect(fragment.x).toBe(0);
843+
});
844+
845+
it('should center within indented text box when paragraph has left indent', () => {
846+
const context = createMockContext(
847+
{
848+
drawingKind: 'shapeGroup',
849+
attrs: {
850+
pmStart: 10,
851+
pmEnd: 11,
852+
wrap: { type: 'Inline' },
853+
inlineParagraphAlignment: 'center',
854+
paragraphIndentLeft: 48,
855+
},
856+
},
857+
{ width: 200, height: 150 },
858+
);
859+
const state = context.ensurePage();
860+
861+
layoutDrawingBlock(context);
862+
863+
const fragment = state.page.fragments[0] as DrawingFragment;
864+
// alignBox = 600 - 48 = 552, extra = 552 - 200 = 352, x = 0 + 48 + 176 = 224
865+
expect(fragment.x).toBe(224);
866+
});
867+
868+
it('should center within indented text box when paragraph has left and right indent', () => {
869+
const context = createMockContext(
870+
{
871+
drawingKind: 'shapeGroup',
872+
attrs: {
873+
pmStart: 10,
874+
pmEnd: 11,
875+
wrap: { type: 'Inline' },
876+
inlineParagraphAlignment: 'center',
877+
paragraphIndentLeft: 48,
878+
paragraphIndentRight: 48,
879+
},
880+
},
881+
{ width: 200, height: 150 },
882+
);
883+
const state = context.ensurePage();
884+
885+
layoutDrawingBlock(context);
886+
887+
const fragment = state.page.fragments[0] as DrawingFragment;
888+
// alignBox = 600 - 48 - 48 = 504, extra = 504 - 200 = 304, x = 0 + 48 + 152 = 200
889+
expect(fragment.x).toBe(200);
890+
});
891+
892+
it('should right-align within indented text box when paragraph has left indent', () => {
893+
const context = createMockContext(
894+
{
895+
drawingKind: 'shapeGroup',
896+
attrs: {
897+
pmStart: 10,
898+
pmEnd: 11,
899+
wrap: { type: 'Inline' },
900+
inlineParagraphAlignment: 'right',
901+
paragraphIndentLeft: 96,
902+
},
903+
},
904+
{ width: 200, height: 150 },
905+
);
906+
const state = context.ensurePage();
907+
908+
layoutDrawingBlock(context);
909+
910+
const fragment = state.page.fragments[0] as DrawingFragment;
911+
// alignBox = 600 - 96 = 504, extra = 504 - 200 = 304, x = 0 + 96 + 304 = 400
912+
expect(fragment.x).toBe(400);
913+
});
914+
915+
it('should not offset when alignment is left or justify', () => {
916+
for (const alignment of ['left', 'justify'] as const) {
917+
const context = createMockContext(
918+
{
919+
drawingKind: 'shapeGroup',
920+
attrs: {
921+
pmStart: 10,
922+
pmEnd: 11,
923+
wrap: { type: 'Inline' },
924+
inlineParagraphAlignment: alignment,
925+
},
926+
},
927+
{ width: 200, height: 150 },
928+
);
929+
const state = context.ensurePage();
930+
931+
layoutDrawingBlock(context);
932+
933+
const fragment = state.page.fragments[0] as DrawingFragment;
934+
expect(fragment.x).toBe(0);
935+
}
936+
});
937+
938+
it('should not offset non-shapeGroup drawings even with inline wrap and alignment', () => {
939+
for (const drawingKind of ['image', 'vectorShape', 'chart'] as const) {
940+
const context = createMockContext(
941+
{
942+
drawingKind,
943+
attrs: {
944+
pmStart: 10,
945+
pmEnd: 11,
946+
wrap: { type: 'Inline' },
947+
inlineParagraphAlignment: 'center',
948+
},
949+
},
950+
{ width: 200, height: 150 },
951+
);
952+
const state = context.ensurePage();
953+
954+
layoutDrawingBlock(context);
955+
956+
const fragment = state.page.fragments[0] as DrawingFragment;
957+
expect(fragment.x).toBe(0);
958+
}
959+
});
960+
961+
it('should not offset shapeGroup when wrap is undefined', () => {
962+
const context = createMockContext(
963+
{
964+
drawingKind: 'shapeGroup',
965+
attrs: {
966+
pmStart: 10,
967+
pmEnd: 11,
968+
inlineParagraphAlignment: 'center',
969+
},
970+
},
971+
{ width: 200, height: 150 },
972+
);
973+
const state = context.ensurePage();
974+
975+
layoutDrawingBlock(context);
976+
977+
const fragment = state.page.fragments[0] as DrawingFragment;
978+
expect(fragment.x).toBe(0);
979+
});
980+
981+
it('should not offset shapeGroup when wrap has no type', () => {
982+
const context = createMockContext(
983+
{
984+
drawingKind: 'shapeGroup',
985+
attrs: {
986+
pmStart: 10,
987+
pmEnd: 11,
988+
wrap: {},
989+
inlineParagraphAlignment: 'center',
990+
},
991+
},
992+
{ width: 200, height: 150 },
993+
);
994+
const state = context.ensurePage();
995+
996+
layoutDrawingBlock(context);
997+
998+
const fragment = state.page.fragments[0] as DrawingFragment;
999+
expect(fragment.x).toBe(0);
1000+
});
1001+
1002+
it('should not shift oversized centered shapeGroup after width scaling', () => {
1003+
const context = createMockContext(
1004+
{
1005+
drawingKind: 'shapeGroup',
1006+
attrs: {
1007+
pmStart: 10,
1008+
pmEnd: 11,
1009+
wrap: { type: 'Inline' },
1010+
inlineParagraphAlignment: 'center',
1011+
},
1012+
},
1013+
{ width: 800, height: 600 },
1014+
);
1015+
const state = context.ensurePage();
1016+
1017+
layoutDrawingBlock(context);
1018+
1019+
const fragment = state.page.fragments[0] as DrawingFragment;
1020+
// Scaled to maxWidthForBlock (600), no slack left, x = 0
1021+
expect(fragment.width).toBe(600);
1022+
expect(fragment.x).toBe(0);
1023+
});
7811024
});
7821025
});

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ export function layoutDrawingBlock({
8585
const indentRight = typeof attrs?.hrIndentRight === 'number' ? attrs.hrIndentRight : 0;
8686
const maxWidthForBlock =
8787
attrs?.isFullWidth === true && maxWidth > 0 ? Math.max(1, maxWidth - indentLeft - indentRight) : maxWidth;
88+
const rawWrap = attrs?.wrap as { type?: unknown } | undefined;
89+
const isInlineShapeGroup = block.drawingKind === 'shapeGroup' && rawWrap?.type === 'Inline';
90+
const inlineParagraphAlignment =
91+
attrs?.inlineParagraphAlignment === 'center' || attrs?.inlineParagraphAlignment === 'right'
92+
? attrs.inlineParagraphAlignment
93+
: undefined;
8894

8995
if (width > maxWidthForBlock && maxWidthForBlock > 0) {
9096
const scale = maxWidthForBlock / width;
@@ -107,12 +113,20 @@ export function layoutDrawingBlock({
107113
}
108114

109115
const pmRange = extractBlockPmRange(block);
116+
let x = columnX(state.columnIndex) + marginLeft + indentLeft;
117+
if (isInlineShapeGroup && inlineParagraphAlignment) {
118+
const pIndentLeft = typeof attrs?.paragraphIndentLeft === 'number' ? attrs.paragraphIndentLeft : 0;
119+
const pIndentRight = typeof attrs?.paragraphIndentRight === 'number' ? attrs.paragraphIndentRight : 0;
120+
const alignBox = Math.max(0, maxWidthForBlock - pIndentLeft - pIndentRight);
121+
const extra = Math.max(0, alignBox - width);
122+
x += pIndentLeft + (inlineParagraphAlignment === 'center' ? extra / 2 : extra);
123+
}
110124

111125
const fragment: DrawingFragment = {
112126
kind: 'drawing',
113127
blockId: block.id,
114128
drawingKind: block.drawingKind,
115-
x: columnX(state.columnIndex) + marginLeft + indentLeft,
129+
x,
116130
y: state.cursorY + marginTop,
117131
width,
118132
height,

0 commit comments

Comments
 (0)