From 98c7247088dbff618a4ce530ac95c198c27153cc Mon Sep 17 00:00:00 2001 From: Gabriel Chittolina Date: Mon, 6 Apr 2026 16:28:08 -0300 Subject: [PATCH] fix: page number on footer not properly aligned --- .../layout-bridge/src/cacheInvalidation.ts | 17 ++++- .../test/cacheInvalidation.test.ts | 8 ++- .../layout-engine/src/index.d.ts | 4 +- .../layout-engine/layout-engine/src/index.ts | 4 +- .../normalize-header-footer-fragments.test.ts | 35 ++++++++-- .../src/normalize-header-footer-fragments.ts | 8 ++- .../painters/dom/src/index.test.ts | 67 ++++++++++++++++++- .../painters/dom/src/renderer.ts | 16 +++-- .../header-footer/HeaderFooterPerRidLayout.ts | 10 ++- .../presentation-editor/PresentationEditor.ts | 1 + 10 files changed, 150 insertions(+), 20 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/cacheInvalidation.ts b/packages/layout-engine/layout-bridge/src/cacheInvalidation.ts index 2b142c9e07..9b85ff1d47 100644 --- a/packages/layout-engine/layout-bridge/src/cacheInvalidation.ts +++ b/packages/layout-engine/layout-bridge/src/cacheInvalidation.ts @@ -109,13 +109,16 @@ 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}`); @@ -123,6 +126,18 @@ export function computeConstraintsHash(constraints: HeaderFooterConstraints): st 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('|'); diff --git a/packages/layout-engine/layout-bridge/test/cacheInvalidation.test.ts b/packages/layout-engine/layout-bridge/test/cacheInvalidation.test.ts index b685f5f7ec..6e43d29578 100644 --- a/packages/layout-engine/layout-bridge/test/cacheInvalidation.test.ts +++ b/packages/layout-engine/layout-bridge/test/cacheInvalidation.test.ts @@ -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', () => { diff --git a/packages/layout-engine/layout-engine/src/index.d.ts b/packages/layout-engine/layout-engine/src/index.d.ts index 795acc4fd0..f5a112edd1 100644 --- a/packages/layout-engine/layout-engine/src/index.d.ts +++ b/packages/layout-engine/layout-engine/src/index.d.ts @@ -46,8 +46,9 @@ 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; @@ -55,6 +56,7 @@ export type HeaderFooterConstraints = { top?: number; bottom?: number; header?: number; + footer?: number; }; /** * Optional base height used to bound behindDoc overflow handling. diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index f6a6aed266..65e135ebc5 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -523,8 +523,9 @@ 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; @@ -532,6 +533,7 @@ export type HeaderFooterConstraints = { top?: number; bottom?: number; header?: number; + footer?: number; }; /** * Optional base height used to bound behindDoc overflow handling. diff --git a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts index d7b85d3386..6a669bf847 100644 --- a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts +++ b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts @@ -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 @@ -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); }); @@ -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); }); @@ -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); }); @@ -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); }); @@ -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', () => { diff --git a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts index 7b61098beb..c300e6eb9f 100644 --- a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts +++ b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts @@ -19,6 +19,7 @@ export type RegionConstraints = { top?: number; bottom?: number; header?: number; + footer?: number; }; }; @@ -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); + } + return pageHeight - (constraints.margins?.bottom ?? 0); } function isAnchoredFragment(fragment: Fragment): boolean { diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index bd51b91238..3b3ac4c2cf 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -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); }); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 1d01335d31..3b7866301d 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2352,6 +2352,17 @@ 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; @@ -2359,11 +2370,6 @@ export class DomPainter { 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); } diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts index 9bb300610e..1e64da6318 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts @@ -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. @@ -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, }; } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 9a4a7ea4be..fdddfb0009 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -5543,6 +5543,7 @@ export class PresentationEditor extends EventEmitter { top: marginTop, bottom: marginBottom, header: headerMargin, + footer: footerMargin, }, overflowBaseHeight, };