Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
792ac8e
fix(painter): stop double-subtracting table indent for column-anchore…
luccas-harbour Jun 12, 2026
6e05a71
fix: render DrawingML connector line ends correctly
luccas-harbour Jun 12, 2026
b8f2a28
fix: tune DrawingML connector arrowheads
luccas-harbour Jun 12, 2026
659db4c
fix: keep connector strokes uniform
luccas-harbour Jun 12, 2026
c20b5a4
fix: render connector arrows in target space
luccas-harbour Jun 12, 2026
6f1cea4
fix(painter): stretch connector SVG viewBox
luccas-harbour Jun 12, 2026
cb94937
fix: render vector shape picture fills
luccas-harbour Jun 12, 2026
eab651f
fix: position page-relative header media
luccas-harbour Jun 12, 2026
5fb0439
fix: keep straight connector arrows uniform
luccas-harbour Jun 12, 2026
c2e7ee6
fix: share connector SVG helpers
luccas-harbour Jun 15, 2026
a0c01dc
fix: update header media positioning comments
luccas-harbour Jun 15, 2026
6a775a0
fix: remove stale table indent fixture
luccas-harbour Jun 15, 2026
7deab86
test: cover header media x-axis normalization
luccas-harbour Jun 15, 2026
303bbaf
fix: render node view picture fills
luccas-harbour Jun 15, 2026
10f3b59
fix: normalize column-relative header/footer media X via shared resolver
luccas-harbour Jun 15, 2026
235795c
feat: render horizontal bar charts with data labels and axis controls
luccas-harbour Jun 15, 2026
b890e71
fix: invalidate chart cache on data changes
luccas-harbour Jun 15, 2026
f012034
fix: size node view picture fills correctly
luccas-harbour Jun 15, 2026
589c899
fix: render column chart data labels
luccas-harbour Jun 15, 2026
e51a399
fix: preserve truncated chart label settings
luccas-harbour Jun 15, 2026
d7906a1
fix: honor bar chart data label positions
luccas-harbour Jun 15, 2026
16659a4
fix: avoid cross-axis header footer x rewrite
luccas-harbour Jun 15, 2026
e53bd7c
fix: hash object layout version values
luccas-harbour Jun 15, 2026
32e12e6
fix: share group connector svg padding
luccas-harbour Jun 15, 2026
a175bbe
fix: share converter media target normalization
luccas-harbour Jun 15, 2026
cbbc386
fix: improve chart rendering fidelity for borders, gridlines, and leg…
luccas-harbour Jun 15, 2026
3fb4f43
refactor: import connector utils directly from preset-geometry
luccascorrea Jun 23, 2026
0c8ce93
fix: gate header/footer X normalization on horizontal anchor
luccascorrea Jun 26, 2026
aa43234
fix(layout): keep header footer anchors container-local
luccascorrea Jun 26, 2026
9079277
fix(charts): count vertical labels in svg budget
luccascorrea Jun 26, 2026
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
29 changes: 28 additions & 1 deletion packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1075,14 +1075,26 @@ export type SolidFillWithAlpha = {
alpha: number;
};

/** Picture fill for DrawingML vector shapes. */
export type PictureFill = {
type: 'picture';
/** Image source path or hydrated data URI. */
src: string;
/** Source relationship id from the owning document part. */
rId?: string;
/** Source image extension, used when resolving media fallbacks. */
extension?: string;
};

/**
* Fill color for shapes. Can be:
* - string: Simple hex color (e.g., "#FF0000") for backward compatibility
* - GradientFill: Linear or radial gradient
* - SolidFillWithAlpha: Solid color with transparency
* - PictureFill: Image fill clipped by the shape geometry
* - null: No fill
*/
export type FillColor = string | GradientFill | SolidFillWithAlpha | null;
export type FillColor = string | GradientFill | SolidFillWithAlpha | PictureFill | null;

/**
* Stroke color for shapes. Can be:
Expand Down Expand Up @@ -1361,12 +1373,23 @@ export type ChartSeriesData = {
xValues?: number[];
/** Optional bubble radius/size values for bubble charts. */
bubbleSizes?: number[];
/** Optional data-label settings from c:dLbls. */
dataLabels?: ChartDataLabelsConfig;
};

/** Data-label configuration extracted from c:dLbls. */
export type ChartDataLabelsConfig = {
showValue?: boolean;
numberFormat?: string;
position?: string;
};

/** Axis configuration extracted from c:catAx / c:valAx. */
export type ChartAxisConfig = {
title?: string;
orientation?: 'minMax' | 'maxMin';
deleted?: boolean;
majorGridlines?: boolean;
};

/** Normalized chart data model parsed from OOXML chart XML. */
Expand All @@ -1377,6 +1400,8 @@ export type ChartModel = {
subType?: string;
/** Bar direction — 'col' for vertical columns, 'bar' for horizontal bars. */
barDirection?: 'col' | 'bar';
/** Gap width between bar groups as a percentage of bar width. */
gapWidth?: number;
/** Data series in the chart. */
series: ChartSeriesData[];
/** Category axis config. */
Expand All @@ -1387,6 +1412,8 @@ export type ChartModel = {
legendPosition?: string;
/** OOXML chart style ID. */
styleId?: number;
/** Whether the chart area outline should be painted. */
chartAreaBorder?: boolean;
};

/** Chart drawing block. */
Expand Down
31 changes: 19 additions & 12 deletions packages/layout-engine/layout-engine/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5209,43 +5209,50 @@ describe('layoutHeaderFooter', () => {
expect(imgFragWithout!.y).not.toBe(imgFragFooter!.y);
});

it('does NOT post-normalize page-relative anchors in header layout', () => {
it('post-normalizes page-relative anchors in header layout', () => {
const imageBlock: FlowBlock = {
kind: 'image',
id: 'img-page',
src: 'data:image/png;base64,xxx',
anchor: {
isAnchored: true,
hRelativeFrom: 'page',
alignH: 'center',
vRelativeFrom: 'page',
alignV: 'top',
offsetV: 10,
alignV: 'center',
offsetH: 0,
offsetV: 0,
},
wrap: { type: 'None' },
};
const imageMeasure: Measure = {
kind: 'image',
width: 50,
height: 30,
width: 762.24,
height: 1010.88,
};

const constraints = {
width: 200,
height: 800,
width: 624,
height: 864,
pageWidth: 816,
pageHeight: 1056,
margins: { left: 72, right: 72, top: 72, bottom: 72, header: 36 },
margins: { left: 96, right: 96, top: 96, bottom: 96, header: 48 },
};

// With kind='header': no normalization — Y stays as inner-layout computed it
const withHeader = layoutHeaderFooter([imageBlock], [imageMeasure], constraints, 'header');
const imgFrag = withHeader.pages[0]?.fragments.find((f) => f.kind === 'image');

// Without kind: same behavior (no normalization)
// Without kind: no header/footer normalization, so the inner synthetic canvas
// still resolves the anchor against the body-content-sized page.
const withoutKind = layoutHeaderFooter([imageBlock], [imageMeasure], constraints);
const imgFragNoKind = withoutKind.pages[0]?.fragments.find((f) => f.kind === 'image');

// Both should have the same Y — inner-layout raw position
expect(imgFrag).toBeDefined();
expect(imgFragNoKind).toBeDefined();
expect(imgFrag!.y).toBe(imgFragNoKind!.y);
expect(imgFrag!.x).toBeCloseTo((816 - 762.24) / 2);
expect(imgFrag!.y).toBeCloseTo((1056 - 1010.88) / 2);
expect(imgFragNoKind!.x).not.toBe(imgFrag!.x);
expect(imgFragNoKind!.y).not.toBe(imgFrag!.y);
});

it('keeps paragraph-relative tall non-page-covering header anchors in measurement height', () => {
Expand Down
15 changes: 5 additions & 10 deletions packages/layout-engine/layout-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3643,17 +3643,12 @@ export function layoutHeaderFooter(
remeasureParagraph,
});

// Post-normalize page-relative anchored fragment Y positions for footers.
// Post-normalize page-relative anchored fragment positions for headers/footers.
//
// The inner layoutDocument() uses the body content height as its page height,
// but page-relative anchors need the REAL physical page height to resolve
// bottom/center alignment correctly. This post-correction rewrites their Y
// to footer-band-local coordinates using the real page geometry.
//
// Headers don't need this: the inner layout's page-relative Y is already
// correct relative to the header container, and the painter handles the
// container-to-page offset via effectiveOffset subtraction.
if (kind === 'footer' && constraints.pageHeight != null) {
// The inner layoutDocument() uses the body content box as its page canvas, but
// page-relative anchors need the real physical page dimensions. This
// post-correction rewrites their coordinates using the real page geometry.
if (kind && constraints.pageHeight != null) {
normalizeFragmentsForRegion(layout.pages, blocks, measures, kind, constraints);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ function makeParaFragment(blockId: string, y: number): Fragment {
return { kind: 'para', blockId, x: 0, y, fromLine: 0, toLine: 1 } as Fragment;
}

function makeAnchoredImageFragment(blockId: string, y: number, height: number): Fragment {
return { kind: 'image', blockId, x: 0, y, height, isAnchored: true } as unknown as Fragment;
function makeAnchoredImageFragment(blockId: string, y: number, height: number, width = 0): Fragment {
return { kind: 'image', blockId, x: 0, y, width, height, isAnchored: true } as unknown as Fragment;
}

function makeDummyMeasure(): Measure {
Expand All @@ -23,6 +23,7 @@ const MARGIN_BOTTOM = 72;
const FOOTER_DISTANCE = 36;

const fullConstraints = {
pageWidth: 816,
pageHeight: PAGE_HEIGHT,
margins: { left: 72, right: 72, top: 72, bottom: MARGIN_BOTTOM, header: 36, footer: FOOTER_DISTANCE },
};
Expand All @@ -33,7 +34,158 @@ const FOOTER_BAND_ORIGIN = PAGE_HEIGHT - FOOTER_DISTANCE; // 1020
// Tests
// ---------------------------------------------------------------------------

describe('normalizeFragmentsForRegion (footer page-relative only)', () => {
describe('normalizeFragmentsForRegion (header/footer page-relative anchors)', () => {
describe('page-relative anchors in header', () => {
it('normalizes centered page-relative anchors against the physical page', () => {
const imgWidth = 762.24;
const imgHeight = 1010.88;
const block: FlowBlock = {
kind: 'image',
id: 'header-background',
src: 'test.png',
anchor: {
isAnchored: true,
hRelativeFrom: 'page',
alignH: 'center',
vRelativeFrom: 'page',
alignV: 'center',
offsetH: 0,
offsetV: 0,
},
wrap: { type: 'None' },
};
const fragment = makeAnchoredImageFragment('header-background', 0, imgHeight, imgWidth);
const pages = [{ number: 1, fragments: [fragment] }];

normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'header', fullConstraints);

expect(fragment.x).toBeCloseTo((816 - imgWidth) / 2);
expect(fragment.y).toBeCloseTo((PAGE_HEIGHT - imgHeight) / 2);
});

it('normalizes right-aligned page-relative anchors against the physical page', () => {
const imgWidth = 120;
const block: FlowBlock = {
kind: 'image',
id: 'header-logo',
src: 'test.png',
anchor: {
isAnchored: true,
hRelativeFrom: 'page',
alignH: 'right',
vRelativeFrom: 'page',
alignV: 'top',
offsetH: 12,
},
wrap: { type: 'None' },
};
const fragment = makeAnchoredImageFragment('header-logo', 0, 40, imgWidth);
const pages = [{ number: 1, fragments: [fragment] }];

normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'header', fullConstraints);

expect(fragment.x).toBe(816 - imgWidth - 12);
});

it('normalizes left page-relative anchors with explicit offsets', () => {
const block: FlowBlock = {
kind: 'image',
id: 'header-left-offset',
src: 'test.png',
anchor: {
isAnchored: true,
hRelativeFrom: 'page',
alignH: 'left',
vRelativeFrom: 'page',
alignV: 'top',
offsetH: 24,
},
wrap: { type: 'None' },
};
const fragment = makeAnchoredImageFragment('header-left-offset', 0, 40, 120);
const pages = [{ number: 1, fragments: [fragment] }];

normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'header', fullConstraints);

expect(fragment.x).toBe(24);
});

it('keeps normal page-relative anchors container-local on the horizontal axis', () => {
const imgWidth = 120;
const block: FlowBlock = {
kind: 'image',
id: 'header-normal-centered',
src: 'test.png',
anchor: {
isAnchored: true,
hRelativeFrom: 'page',
alignH: 'center',
vRelativeFrom: 'page',
alignV: 'top',
offsetV: 0,
},
wrap: { type: 'Square' },
};
const fragment = makeAnchoredImageFragment('header-normal-centered', 0, 40, imgWidth);
const pages = [{ number: 1, fragments: [fragment] }];

normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'header', fullConstraints);

expect(fragment.x).toBe((816 - imgWidth) / 2 - fullConstraints.margins.left);
expect(fragment.y).toBe(0);
});

it('keeps legacy zIndex zero page-relative anchors in physical page coordinates', () => {
const imgWidth = 120;
const block: FlowBlock = {
kind: 'image',
id: 'header-legacy-behind',
src: 'test.png',
anchor: {
isAnchored: true,
hRelativeFrom: 'page',
alignH: 'center',
vRelativeFrom: 'page',
alignV: 'top',
offsetV: 0,
},
};
const fragment = makeAnchoredImageFragment('header-legacy-behind', 0, 40, imgWidth);
(fragment as { behindDoc?: boolean }).behindDoc = undefined;
(fragment as { zIndex?: number }).zIndex = 0;
const pages = [{ number: 1, fragments: [fragment] }];

normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'header', fullConstraints);

expect(fragment.x).toBe((816 - imgWidth) / 2);
});

it('does not normalize column-relative X when Y is page-relative', () => {
const imgWidth = 830;
const block: FlowBlock = {
kind: 'image',
id: 'header-column-background',
src: 'test.png',
anchor: {
isAnchored: true,
hRelativeFrom: 'column',
offsetH: -76,
vRelativeFrom: 'page',
alignV: 'top',
offsetV: 8,
},
};
const fragment = makeAnchoredImageFragment('header-column-background', 0, 40, imgWidth);
fragment.x = -76;
const pages = [{ number: 1, fragments: [fragment] }];

normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'header', fullConstraints);

expect(fragment.x).toBe(-76);
expect(fragment.y).toBe(8);
});
});

describe('page-relative anchors in footer', () => {
it('normalizes a top-aligned anchor', () => {
const block: FlowBlock = {
Expand Down Expand Up @@ -85,6 +237,55 @@ describe('normalizeFragmentsForRegion (footer page-relative only)', () => {
expect(fragment.y).toBe((PAGE_HEIGHT - imgHeight) / 2 - FOOTER_BAND_ORIGIN);
});

it('normalizes footer X when the vertical anchor uses page coordinates', () => {
const imgWidth = 120;
const block: FlowBlock = {
kind: 'image',
id: 'footer-centered',
src: 'test.png',
anchor: {
isAnchored: true,
hRelativeFrom: 'page',
alignH: 'center',
vRelativeFrom: 'page',
alignV: 'bottom',
offsetV: 0,
},
wrap: { type: 'None' },
};
const fragment = makeAnchoredImageFragment('footer-centered', 0, 40, imgWidth);
const pages = [{ number: 1, fragments: [fragment] }];

normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'footer', fullConstraints);

expect(fragment.x).toBe((816 - imgWidth) / 2);
expect(fragment.y).toBe(PAGE_HEIGHT - 40 - FOOTER_BAND_ORIGIN);
});

it('normalizes page-relative X when the vertical anchor is not page-relative', () => {
const block: FlowBlock = {
kind: 'image',
id: 'footer-cross-axis',
src: 'test.png',
anchor: {
isAnchored: true,
hRelativeFrom: 'page',
alignH: 'center',
vRelativeFrom: 'paragraph',
offsetV: 0,
},
wrap: { type: 'None' },
};
const fragment = makeAnchoredImageFragment('footer-cross-axis', 12, 40, 120);
fragment.x = 24;
const pages = [{ number: 1, fragments: [fragment] }];

normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'footer', fullConstraints);

expect(fragment.x).toBe((816 - 120) / 2);
expect(fragment.y).toBe(12);
});

it('applies offsetV correctly', () => {
const block: FlowBlock = {
kind: 'image',
Expand Down
Loading
Loading