Skip to content
Open
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
17 changes: 16 additions & 1 deletion packages/layout-engine/layout-bridge/src/cacheInvalidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,20 +109,35 @@ export function computeSectionMetadataHash(sections: SectionMetadata[]): string
* @returns Constraints hash string
*/
export function computeConstraintsHash(constraints: HeaderFooterConstraints): string {
const { width, height, pageWidth, margins, overflowBaseHeight } = constraints;
const { width, height, pageWidth, pageHeight, margins, overflowBaseHeight } = constraints;

const parts = [`w:${width}`, `h:${height}`];

if (pageWidth !== undefined) {
parts.push(`pw:${pageWidth}`);
}
if (pageHeight !== undefined) {
parts.push(`ph:${pageHeight}`);
}

if (overflowBaseHeight !== undefined) {
parts.push(`obh:${overflowBaseHeight}`);
}

if (margins) {
parts.push(`ml:${margins.left}`, `mr:${margins.right}`);
if (margins.top !== undefined) {
parts.push(`mt:${margins.top}`);
}
if (margins.bottom !== undefined) {
parts.push(`mb:${margins.bottom}`);
}
if (margins.header !== undefined) {
parts.push(`mh:${margins.header}`);
}
if (margins.footer !== undefined) {
parts.push(`mf:${margins.footer}`);
}
}

return parts.join('|');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,19 @@ describe('Cache Invalidation', () => {
width: 500,
height: 100,
pageWidth: 600,
margins: { left: 50, right: 50 },
pageHeight: 800,
margins: { left: 50, right: 50, top: 40, bottom: 60, header: 30, footer: 20 },
};

const hash = computeConstraintsHash(constraints);
expect(hash).toContain('pw:600');
expect(hash).toContain('ph:800');
expect(hash).toContain('ml:50');
expect(hash).toContain('mr:50');
expect(hash).toContain('mt:40');
expect(hash).toContain('mb:60');
expect(hash).toContain('mh:30');
expect(hash).toContain('mf:20');
});

it('should produce different hashes for different constraints', () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/layout-engine/layout-engine/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,17 @@ export type HeaderFooterConstraints = {
/**
* Page margins for anchor positioning.
* `left`/`right`: horizontal page-relative conversion.
* `top`/`bottom`: vertical margin-relative conversion and footer band origin.
* `top`/`bottom`: vertical margin-relative conversion and fallback footer band origin.
* `header`: header distance from page top edge (header band origin).
* `footer`: footer distance from page bottom edge (footer band origin).
*/
margins?: {
left: number;
right: number;
top?: number;
bottom?: number;
header?: number;
footer?: number;
};
/**
* Optional base height used to bound behindDoc overflow handling.
Expand Down
4 changes: 3 additions & 1 deletion packages/layout-engine/layout-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,15 +523,17 @@ export type HeaderFooterConstraints = {
/**
* Page margins for anchor positioning.
* `left`/`right`: horizontal page-relative conversion.
* `top`/`bottom`: vertical margin-relative conversion and footer band origin.
* `top`/`bottom`: vertical margin-relative conversion and fallback footer band origin.
* `header`: header distance from page top edge (header band origin).
* `footer`: footer distance from page bottom edge (footer band origin).
*/
margins?: {
left: number;
right: number;
top?: number;
bottom?: number;
header?: number;
footer?: number;
};
/**
* Optional base height used to bound behindDoc overflow handling.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@ function makeDummyMeasure(): Measure {

const PAGE_HEIGHT = 1056;
const MARGIN_BOTTOM = 72;
const FOOTER_DISTANCE = 36;

const fullConstraints = {
pageHeight: PAGE_HEIGHT,
margins: { left: 72, right: 72, top: 72, bottom: MARGIN_BOTTOM, header: 36 },
margins: { left: 72, right: 72, top: 72, bottom: MARGIN_BOTTOM, header: 36, footer: FOOTER_DISTANCE },
};

const FOOTER_BAND_ORIGIN = PAGE_HEIGHT - MARGIN_BOTTOM; // 984
const FOOTER_BAND_ORIGIN = PAGE_HEIGHT - FOOTER_DISTANCE; // 1020

// ---------------------------------------------------------------------------
// Tests
Expand All @@ -46,7 +47,7 @@ describe('normalizeFragmentsForRegion (footer page-relative only)', () => {

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

// physicalY = 0, bandOrigin = 984
// physicalY = 0, bandOrigin = 1020
expect(fragment.y).toBe(0 - FOOTER_BAND_ORIGIN);
});

Expand All @@ -63,7 +64,7 @@ describe('normalizeFragmentsForRegion (footer page-relative only)', () => {

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

// physicalY = 1056 - 50 = 1006, bandOrigin = 984
// physicalY = 1056 - 50 = 1006, bandOrigin = 1020
expect(fragment.y).toBe(PAGE_HEIGHT - imgHeight - FOOTER_BAND_ORIGIN);
});

Expand All @@ -80,7 +81,7 @@ describe('normalizeFragmentsForRegion (footer page-relative only)', () => {

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

// physicalY = (1056 - 40) / 2 = 508, bandOrigin = 984
// physicalY = (1056 - 40) / 2 = 508, bandOrigin = 1020
expect(fragment.y).toBe((PAGE_HEIGHT - imgHeight) / 2 - FOOTER_BAND_ORIGIN);
});

Expand All @@ -96,7 +97,7 @@ describe('normalizeFragmentsForRegion (footer page-relative only)', () => {

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

// physicalY = 20, bandOrigin = 984
// physicalY = 20, bandOrigin = 1020
expect(fragment.y).toBe(20 - FOOTER_BAND_ORIGIN);
});

Expand All @@ -123,6 +124,28 @@ describe('normalizeFragmentsForRegion (footer page-relative only)', () => {

expect(fragment.y).toBe(PAGE_HEIGHT - 50 - FOOTER_BAND_ORIGIN);
});

it('falls back to bottom margin when footer distance is missing', () => {
const imgHeight = 40;
const block: FlowBlock = {
kind: 'image',
id: 'img-bottom',
src: 'test.png',
anchor: { isAnchored: true, vRelativeFrom: 'page', alignV: 'bottom', offsetV: 0 },
};
const fragment = makeAnchoredImageFragment('img-bottom', 0, imgHeight);
const pages = [{ number: 1, fragments: [fragment] }];

const withoutFooter = {
pageHeight: PAGE_HEIGHT,
margins: { left: 72, right: 72, top: 72, bottom: MARGIN_BOTTOM, header: 36 },
};

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

const fallbackOrigin = PAGE_HEIGHT - MARGIN_BOTTOM;
expect(fragment.y).toBe(PAGE_HEIGHT - imgHeight - fallbackOrigin);
});
});

describe('passthrough cases — fragments that must NOT be modified', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type RegionConstraints = {
top?: number;
bottom?: number;
header?: number;
footer?: number;
};
};

Expand Down Expand Up @@ -49,7 +50,12 @@ function computePhysicalAnchorY(block: ImageBlock | DrawingBlock, fragmentHeight
* footer-local y=0. This is the top of the bottom margin area.
*/
function computeFooterBandOrigin(constraints: RegionConstraints): number {
return (constraints.pageHeight ?? 0) - (constraints.margins?.bottom ?? 0);
const pageHeight = constraints.pageHeight ?? 0;
const footerDistance = constraints.margins?.footer;
if (typeof footerDistance === 'number' && Number.isFinite(footerDistance)) {
return pageHeight - Math.max(0, footerDistance);
Comment on lines +55 to +56
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clamp footer-origin math before normalizing footer anchors

The new footer-distance branch can produce a negative band origin when margins.footer > pageHeight (computeFooterBandOrigin returns pageHeight - footerDistance without clamping). In that case normalizeFragmentsForRegion writes fragment.y using a negative origin, but DomPainter’s getDecorationAnchorPageOriginY clamps the same expression to >= 0, so the two stages disagree and page-relative footer media render shifted by the clamp delta. This creates deterministic misplacement for malformed-but-parseable section margins and is introduced by this footer-distance path.

Useful? React with 👍 / 👎.

}
return pageHeight - (constraints.margins?.bottom ?? 0);
}

function isAnchoredFragment(fragment: Fragment): boolean {
Expand Down
67 changes: 64 additions & 3 deletions packages/layout-engine/painters/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4280,9 +4280,70 @@ describe('DomPainter', () => {
expect(footerFragmentEl).toBeTruthy();
// Footer container is at effectiveOffset (400px)
expect(footerEl.style.top).toBe(`${footerOffset}px`);
// Fragment uses band-local Y + container offset from band origin
// The exact top depends on getDecorationAnchorPageOriginY, but the
// key invariant is that the absolute page position is correct.
// Fragment uses band-local Y + container offset from band origin.
// With footer distance provided, anchors convert back to absolute page-space
// using the physical footer reference point (pageHeight - footerDistance).
const renderedPageTop = parseFloat(footerEl.style.top || '0') + parseFloat(footerFragmentEl.style.top || '0');
expect(renderedPageTop).toBe((layout.pageSize?.h ?? 0) - 20 + footerFragment.y);
});

it('falls back to bottom margin origin when footer distance is missing', () => {
const footerImageBlock: FlowBlock = {
kind: 'image',
id: 'footer-page-relative-img-missing',
src: 'data:image/png;base64,xxx',
anchor: {
isAnchored: true,
hRelativeFrom: 'page',
vRelativeFrom: 'page',
},
};
const footerImageMeasure: Measure = {
kind: 'image',
width: 20,
height: 20,
};
const footerOffset = 400;
const footerFragment: Fragment = {
kind: 'image',
blockId: footerImageBlock.id,
x: 0,
y: 25,
width: 20,
height: 20,
isAnchored: true,
};

const painter = createTestPainter({
blocks: [block, footerImageBlock],
measures: [measure, footerImageMeasure],
footerProvider: () => ({
fragments: [footerFragment],
height: 80,
offset: footerOffset,
}),
});

painter.paint(
{
...layout,
pages: [
{
...layout.pages[0],
number: 1,
margins: { left: 0, right: 0, bottom: 100 },
},
],
},
mount,
);

const footerEl = mount.querySelector('.superdoc-page-footer') as HTMLElement;
const footerFragmentEl = footerEl.querySelector(
'[data-block-id="footer-page-relative-img-missing"]',
) as HTMLElement;

expect(footerFragmentEl).toBeTruthy();
const renderedPageTop = parseFloat(footerEl.style.top || '0') + parseFloat(footerFragmentEl.style.top || '0');
expect(renderedPageTop).toBe(footerOffset + footerFragment.y);
});
Expand Down
16 changes: 11 additions & 5 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2352,18 +2352,24 @@ export class DomPainter {
return effectiveOffset;
}

const styledPageHeight = Number.parseFloat(pageEl.style.height || '');
const pageHeight =
page.size?.h ??
this.currentLayout?.pageSize?.h ??
(Number.isFinite(styledPageHeight) ? styledPageHeight : pageEl.clientHeight);

const footerDistance = page.margins?.footer;
if (typeof footerDistance === 'number' && Number.isFinite(footerDistance)) {
return Math.max(0, pageHeight - Math.max(0, footerDistance));
}

const bottomMargin = page.margins?.bottom;
if (bottomMargin == null) {
return effectiveOffset;
}

const footnoteReserve = page.footnoteReserved ?? 0;
const adjustedBottomMargin = Math.max(0, bottomMargin - footnoteReserve);
const styledPageHeight = Number.parseFloat(pageEl.style.height || '');
const pageHeight =
page.size?.h ??
this.currentLayout?.pageSize?.h ??
(Number.isFinite(styledPageHeight) ? styledPageHeight : pageEl.clientHeight);

return Math.max(0, pageHeight - adjustedBottomMargin);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function buildConstraintsForSection(section: SectionMetadata, fallback: Constrai
const marginT = section.margins?.top ?? fallback.margins?.top;
const marginB = section.margins?.bottom ?? fallback.margins?.bottom;
const marginHeader = section.margins?.header ?? fallback.margins?.header;
const marginFooter = section.margins?.footer ?? fallback.margins?.footer;
const contentWidth = pageW - marginL - marginR;
// Allow tables to extend beyond right margin when grid width > content width.
// Capped at pageWidth - marginLeft to avoid going past the page edge.
Expand All @@ -53,7 +54,14 @@ function buildConstraintsForSection(section: SectionMetadata, fallback: Constrai
height: sectionHeight,
pageWidth: pageW,
pageHeight: pageH,
margins: { left: marginL, right: marginR, top: marginT, bottom: marginB, header: marginHeader },
margins: {
left: marginL,
right: marginR,
top: marginT,
bottom: marginB,
header: marginHeader,
footer: marginFooter,
},
overflowBaseHeight: fallback.overflowBaseHeight,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5543,6 +5543,7 @@ export class PresentationEditor extends EventEmitter {
top: marginTop,
bottom: marginBottom,
header: headerMargin,
footer: footerMargin,
},
overflowBaseHeight,
};
Expand Down
Loading