Skip to content

Commit ac63995

Browse files
committed
fix: add support for odd/even headers
1 parent c4d9770 commit ac63995

7 files changed

Lines changed: 142 additions & 48 deletions

File tree

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,32 @@ export function buildMultiSectionIdentifier(
285285
identifier.footerIds.odd = identifier.footerIds.odd ?? converterIds.footerIds.odd ?? null;
286286
}
287287

288+
// PM section metadata often lists only headerReference types present in that sectPr snapshot;
289+
// converter.headerIds still has even/odd from the full package. Merge those into per-section
290+
// rows so getHeaderFooterTypeForSection sees hasEven and returns 'even' on even pages.
291+
if (converterIds?.headerIds) {
292+
const c = converterIds.headerIds;
293+
for (const [idx, row] of identifier.sectionHeaderIds) {
294+
identifier.sectionHeaderIds.set(idx, {
295+
default: row.default ?? c.default ?? null,
296+
first: row.first ?? c.first ?? null,
297+
even: row.even ?? c.even ?? null,
298+
odd: row.odd ?? c.odd ?? null,
299+
});
300+
}
301+
}
302+
if (converterIds?.footerIds) {
303+
const c = converterIds.footerIds;
304+
for (const [idx, row] of identifier.sectionFooterIds) {
305+
identifier.sectionFooterIds.set(idx, {
306+
default: row.default ?? c.default ?? null,
307+
first: row.first ?? c.first ?? null,
308+
even: row.even ?? c.even ?? null,
309+
odd: row.odd ?? c.odd ?? null,
310+
});
311+
}
312+
}
313+
288314
return identifier;
289315
}
290316

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,10 +397,22 @@ describe('headerFooterUtils', () => {
397397
expect(identifier.headerIds.first).toBe('section-h-first');
398398
// Converter IDs should only fill in gaps
399399
expect(identifier.headerIds.even).toBe('converter-h-even');
400+
expect(identifier.sectionHeaderIds.get(0)?.even).toBe('converter-h-even');
400401
expect(identifier.footerIds.default).toBe('section-f-default');
401402
expect(identifier.footerIds.odd).toBe('converter-f-odd');
402403
});
403404

405+
it('fills per-section header maps from converter when section metadata omits even', () => {
406+
const sectionMetadata: SectionMetadata[] = [{ sectionIndex: 0, headerRefs: { default: 'r-default' } }];
407+
const identifier = buildMultiSectionIdentifier(
408+
sectionMetadata,
409+
{ alternateHeaders: true },
410+
{ headerIds: { default: 'r-default', even: 'r-even' } },
411+
);
412+
expect(identifier.sectionHeaderIds.get(0)?.even).toBe('r-even');
413+
expect(getHeaderFooterTypeForSection(2, 0, identifier, { kind: 'header', sectionPageNumber: 2 })).toBe('even');
414+
});
415+
404416
it('should handle missing converterIds parameter gracefully', () => {
405417
const sectionMetadata: SectionMetadata[] = [
406418
{

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
PageBreakBlock,
1717
TableBlock,
1818
TableMeasure,
19+
SectionMetadata,
1920
} from '@superdoc/contracts';
2021
import { layoutDocument, layoutHeaderFooter, type LayoutOptions } from './index.js';
2122

@@ -818,6 +819,70 @@ describe('layoutDocument', () => {
818819
expect(fragment.pmEnd).toBe(12);
819820
});
820821

822+
it('inflates top margin using transitive inherited first-header rId (multi-hop section metadata)', () => {
823+
const m = { top: 72, bottom: 72, left: 72, right: 72, header: 72, footer: 72 };
824+
const sb0: FlowBlock = {
825+
kind: 'sectionBreak',
826+
id: 'sb-0',
827+
type: 'continuous',
828+
margins: m,
829+
headerRefs: { default: 'd0', first: 'f0' },
830+
attrs: { isFirstSection: true, sectionIndex: 0 },
831+
};
832+
const sb1: FlowBlock = {
833+
kind: 'sectionBreak',
834+
id: 'sb-1',
835+
type: 'nextPage',
836+
margins: m,
837+
headerRefs: { default: 'd1' },
838+
attrs: { sectionIndex: 1 },
839+
};
840+
const sb2: FlowBlock = {
841+
kind: 'sectionBreak',
842+
id: 'sb-2',
843+
type: 'nextPage',
844+
margins: m,
845+
headerRefs: { default: 'd2' },
846+
attrs: { sectionIndex: 2 },
847+
};
848+
849+
const lineHeight = 40;
850+
const blocks: FlowBlock[] = [
851+
sb0,
852+
{ kind: 'paragraph', id: 'p0', runs: [] },
853+
sb1,
854+
{ kind: 'paragraph', id: 'p1', runs: [] },
855+
sb2,
856+
{ kind: 'paragraph', id: 'p2', runs: [] },
857+
];
858+
const measures: Measure[] = [
859+
{ kind: 'sectionBreak' },
860+
makeMeasure(Array(20).fill(lineHeight)),
861+
{ kind: 'sectionBreak' },
862+
makeMeasure(Array(20).fill(lineHeight)),
863+
{ kind: 'sectionBreak' },
864+
makeMeasure([lineHeight]),
865+
];
866+
867+
const tallFirst = 220;
868+
const sectionMetadata: SectionMetadata[] = [
869+
{ sectionIndex: 0, titlePg: true, headerRefs: { default: 'd0', first: 'f0' } },
870+
{ sectionIndex: 1, titlePg: true, headerRefs: { default: 'd1' } },
871+
{ sectionIndex: 2, titlePg: true, headerRefs: { default: 'd2' } },
872+
];
873+
874+
const layout = layoutDocument(blocks, measures, {
875+
pageSize: { w: 500, h: 600 },
876+
margins: m,
877+
sectionMetadata,
878+
headerContentHeightsByRId: new Map([['f0', tallFirst]]),
879+
});
880+
881+
const section2Page = layout.pages.find((p) => p.sectionIndex === 2);
882+
expect(section2Page).toBeDefined();
883+
expect(section2Page!.margins.top).toBeGreaterThanOrEqual(72 + tallFirst - 1);
884+
});
885+
821886
it('applies section break margins to subsequent pages', () => {
822887
const sectionBreakBlock: FlowBlock = {
823888
kind: 'sectionBreak',

packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts

Lines changed: 27 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2433,21 +2433,7 @@ export class PresentationEditor extends EventEmitter {
24332433
const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body';
24342434

24352435
if (layout && sessionMode === 'body') {
2436-
let pageIndex: number | null = null;
2437-
for (let idx = 0; idx < layout.pages.length; idx++) {
2438-
const page = layout.pages[idx];
2439-
for (const fragment of page.fragments) {
2440-
if (isFootnoteLayoutBlockId((fragment as { blockId?: string }).blockId)) {
2441-
continue;
2442-
}
2443-
const frag = fragment as { pmStart?: number; pmEnd?: number };
2444-
if (frag.pmStart != null && frag.pmEnd != null && clampedPos >= frag.pmStart && clampedPos <= frag.pmEnd) {
2445-
pageIndex = idx;
2446-
break;
2447-
}
2448-
}
2449-
if (pageIndex != null) break;
2450-
}
2436+
const pageIndex = this.#findPageIndexForPosition(layout, clampedPos);
24512437

24522438
if (pageIndex != null) {
24532439
const pageEl = getPageElementByIndex(this.#viewportHost, pageIndex);
@@ -2568,6 +2554,26 @@ export class PresentationEditor extends EventEmitter {
25682554
};
25692555
}
25702556

2557+
/**
2558+
* Find the 0-based page index whose body fragments contain `pos`, skipping footnote
2559+
* layout blocks. Returns null when no fragment reports pmStart/pmEnd for `pos`.
2560+
*/
2561+
#findPageIndexForPosition(layout: Layout, pos: number): number | null {
2562+
for (let idx = 0; idx < layout.pages.length; idx++) {
2563+
const page = layout.pages[idx];
2564+
for (const fragment of page.fragments) {
2565+
if (isFootnoteLayoutBlockId((fragment as { blockId?: string }).blockId)) {
2566+
continue;
2567+
}
2568+
const frag = fragment as { pmStart?: number; pmEnd?: number };
2569+
if (frag.pmStart != null && frag.pmEnd != null && pos >= frag.pmStart && pos <= frag.pmEnd) {
2570+
return idx;
2571+
}
2572+
}
2573+
}
2574+
return null;
2575+
}
2576+
25712577
/**
25722578
* Find the DOM element containing a specific document position.
25732579
* Returns the most specific (smallest range) matching element.
@@ -2635,21 +2641,7 @@ export class PresentationEditor extends EventEmitter {
26352641
const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body';
26362642
if (!layout || sessionMode !== 'body') return false;
26372643

2638-
let pageIndex: number | null = null;
2639-
for (let idx = 0; idx < layout.pages.length; idx++) {
2640-
const page = layout.pages[idx];
2641-
for (const fragment of page.fragments) {
2642-
if (isFootnoteLayoutBlockId((fragment as { blockId?: string }).blockId)) {
2643-
continue;
2644-
}
2645-
const frag = fragment as { pmStart?: number; pmEnd?: number };
2646-
if (frag.pmStart != null && frag.pmEnd != null && clampedPos >= frag.pmStart && clampedPos <= frag.pmEnd) {
2647-
pageIndex = idx;
2648-
break;
2649-
}
2650-
}
2651-
if (pageIndex != null) break;
2652-
}
2644+
const pageIndex = this.#findPageIndexForPosition(layout, clampedPos);
26532645
if (pageIndex == null) return false;
26542646

26552647
// Trigger virtualization to render the page
@@ -5374,6 +5366,8 @@ export class PresentationEditor extends EventEmitter {
53745366
this.#layoutOptions.pageSize = pageSize;
53755367
this.#layoutOptions.margins = margins;
53765368
const flowMode = this.#layoutOptions.flowMode ?? 'paginated';
5369+
const oddEvenHeadersFooters =
5370+
(this.#editor as EditorWithConverter)?.converter?.pageStyles?.alternateHeaders === true;
53775371

53785372
const resolvedMargins = {
53795373
top: margins.top!,
@@ -5413,6 +5407,7 @@ export class PresentationEditor extends EventEmitter {
54135407
marginBottom: semanticMargins.bottom,
54145408
},
54155409
sectionMetadata,
5410+
...(oddEvenHeadersFooters ? { oddEvenHeadersFooters: true } : {}),
54165411
};
54175412
}
54185413

@@ -5424,6 +5419,7 @@ export class PresentationEditor extends EventEmitter {
54245419
margins: resolvedMargins,
54255420
...(columns ? { columns } : {}),
54265421
sectionMetadata,
5422+
...(oddEvenHeadersFooters ? { oddEvenHeadersFooters: true } : {}),
54275423
};
54285424
}
54295425

@@ -6730,23 +6726,7 @@ export class PresentationEditor extends EventEmitter {
67306726

67316727
// Fallback: scan pages to find which one contains this position via fragments
67326728
// Note: pmStart/pmEnd are only present on some fragment types (ParaFragment, ImageFragment, DrawingFragment)
6733-
const pos = selection.from;
6734-
for (let pageIdx = 0; pageIdx < layout.pages.length; pageIdx++) {
6735-
const page = layout.pages[pageIdx];
6736-
for (const fragment of page.fragments) {
6737-
if (isFootnoteLayoutBlockId((fragment as { blockId?: string }).blockId)) {
6738-
continue;
6739-
}
6740-
const frag = fragment as { pmStart?: number; pmEnd?: number };
6741-
if (frag.pmStart != null && frag.pmEnd != null) {
6742-
if (pos >= frag.pmStart && pos <= frag.pmEnd) {
6743-
return pageIdx;
6744-
}
6745-
}
6746-
}
6747-
}
6748-
6749-
return 0;
6729+
return this.#findPageIndexForPosition(layout, selection.from) ?? 0;
67506730
}
67516731

67526732
#findRegionForPage(kind: 'header' | 'footer', pageIndex: number): HeaderFooterRegion | null {

packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1614,6 +1614,10 @@ export class HeaderFooterSessionManager {
16141614
let sectionRId: string | undefined;
16151615
if (page?.sectionRefs && kind === 'header') {
16161616
sectionRId = page.sectionRefs.headerRefs?.[headerFooterType as keyof typeof page.sectionRefs.headerRefs];
1617+
if (!sectionRId && headerFooterType && multiSectionId) {
1618+
const row = multiSectionId.sectionHeaderIds.get(sectionIndex);
1619+
sectionRId = row?.[headerFooterType] ?? multiSectionId.headerIds[headerFooterType] ?? undefined;
1620+
}
16171621
if (!sectionRId && headerFooterType && headerFooterType !== 'default' && sectionIndex > 0 && multiSectionId) {
16181622
const prevSectionIds = multiSectionId.sectionHeaderIds.get(sectionIndex - 1);
16191623
sectionRId = prevSectionIds?.[headerFooterType as keyof typeof prevSectionIds] ?? undefined;
@@ -1623,6 +1627,10 @@ export class HeaderFooterSessionManager {
16231627
}
16241628
} else if (page?.sectionRefs && kind === 'footer') {
16251629
sectionRId = page.sectionRefs.footerRefs?.[headerFooterType as keyof typeof page.sectionRefs.footerRefs];
1630+
if (!sectionRId && headerFooterType && multiSectionId) {
1631+
const row = multiSectionId.sectionFooterIds.get(sectionIndex);
1632+
sectionRId = row?.[headerFooterType] ?? multiSectionId.footerIds[headerFooterType] ?? undefined;
1633+
}
16261634
if (!sectionRId && headerFooterType && headerFooterType !== 'default' && sectionIndex > 0 && multiSectionId) {
16271635
const prevSectionIds = multiSectionId.sectionFooterIds.get(sectionIndex - 1);
16281636
sectionRId = prevSectionIds?.[headerFooterType as keyof typeof prevSectionIds] ?? undefined;

packages/super-editor/src/editors/v1/core/presentation-editor/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ export type ResolvedLayoutOptions =
122122
margins: ResolvedMarginsBase;
123123
columns?: { count: number; gap: number };
124124
sectionMetadata: SectionMetadata[];
125+
/** Document-level w:evenAndOddHeaders — used by layout pagination for per-variant margins */
126+
oddEvenHeadersFooters?: boolean;
125127
}
126128
| {
127129
flowMode: 'semantic';
@@ -136,6 +138,7 @@ export type ResolvedLayoutOptions =
136138
marginBottom: number;
137139
};
138140
sectionMetadata: SectionMetadata[];
141+
oddEvenHeadersFooters?: boolean;
139142
};
140143

141144
export type LayoutEngineOptions = {

packages/super-editor/src/editors/v1/dom-observer/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* @module dom-observer
99
*/
1010

11-
export { DomPositionIndex, type DomPositionIndexEntry, isFootnotePaintedBlockHost } from './DomPositionIndex.js';
11+
export { DomPositionIndex, type DomPositionIndexEntry } from './DomPositionIndex.js';
1212
export { DomPositionIndexObserverManager } from './DomPositionIndexObserverManager.js';
1313
export {
1414
type LayoutRect,

0 commit comments

Comments
 (0)