diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts
index b73c27ee11..6e4d3204ef 100644
--- a/packages/layout-engine/layout-engine/src/index.test.ts
+++ b/packages/layout-engine/layout-engine/src/index.test.ts
@@ -5587,3 +5587,312 @@ describe('requirePageBoundary edge cases', () => {
});
});
});
+
+describe('alternateHeaders (odd/even header differentiation)', () => {
+ // Two tall paragraphs (400px each) that force a 2-page layout.
+ const tallBlock = (id: string): FlowBlock => ({
+ kind: 'paragraph',
+ id,
+ runs: [],
+ });
+ const tallMeasure = makeMeasure([400]);
+
+ it('selects even/odd header heights when alternateHeaders is true', () => {
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ alternateHeaders: true,
+ headerContentHeights: {
+ odd: 80, // Odd pages: header pushes body start down
+ even: 40, // Even pages: smaller header
+ },
+ };
+
+ const layout = layoutDocument([tallBlock('p1'), tallBlock('p2')], [tallMeasure, tallMeasure], options);
+
+ expect(layout.pages).toHaveLength(2);
+
+ // Page 1 is odd (documentPageNumber=1) → uses 'odd' header height (80px)
+ // Body should start at max(margin.top, margin.header + headerContentHeight) = max(50, 30+80) = 110
+ const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1');
+ expect(p1Fragment).toBeDefined();
+ expect(p1Fragment!.y).toBeCloseTo(110, 0);
+
+ // Page 2 is even (documentPageNumber=2) → uses 'even' header height (40px)
+ // Body should start at max(margin.top, margin.header + headerContentHeight) = max(50, 30+40) = 70
+ const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2');
+ expect(p2Fragment).toBeDefined();
+ expect(p2Fragment!.y).toBeCloseTo(70, 0);
+ });
+
+ it('uses default header height for all pages when alternateHeaders is false', () => {
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ alternateHeaders: false,
+ headerContentHeights: {
+ default: 60,
+ odd: 80,
+ even: 40,
+ },
+ };
+
+ const layout = layoutDocument([tallBlock('p1'), tallBlock('p2')], [tallMeasure, tallMeasure], options);
+
+ expect(layout.pages).toHaveLength(2);
+
+ // Both pages use 'default' header height (60px)
+ // Body start = max(50, 30+60) = 90
+ const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1');
+ const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2');
+ expect(p1Fragment!.y).toBeCloseTo(90, 0);
+ expect(p2Fragment!.y).toBeCloseTo(90, 0);
+ });
+
+ it('defaults to false when alternateHeaders is omitted', () => {
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ // alternateHeaders not set
+ headerContentHeights: {
+ default: 60,
+ odd: 80,
+ even: 40,
+ },
+ };
+
+ const layout = layoutDocument([tallBlock('p1'), tallBlock('p2')], [tallMeasure, tallMeasure], options);
+
+ expect(layout.pages).toHaveLength(2);
+
+ // Both pages should use 'default' (60px), not odd/even
+ const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1');
+ const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2');
+ expect(p1Fragment!.y).toBeCloseTo(90, 0);
+ expect(p2Fragment!.y).toBeCloseTo(90, 0);
+ });
+
+ it('first page uses first variant when titlePg is enabled with alternateHeaders', () => {
+ const sectionBreak: SectionBreakBlock = {
+ kind: 'sectionBreak',
+ id: 'sb',
+ attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 },
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ };
+
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ alternateHeaders: true,
+ sectionMetadata: [{ sectionIndex: 0, titlePg: true }],
+ headerContentHeights: {
+ first: 100, // First page: tallest header
+ odd: 80,
+ even: 40,
+ },
+ };
+
+ const layout = layoutDocument(
+ [sectionBreak, tallBlock('p1'), tallBlock('p2'), tallBlock('p3')],
+ [{ kind: 'sectionBreak' }, tallMeasure, tallMeasure, tallMeasure],
+ options,
+ );
+
+ expect(layout.pages.length).toBeGreaterThanOrEqual(3);
+
+ // Page 1 (first page of section, titlePg=true) → 'first' variant → 100px
+ // Body start = max(50, 30+100) = 130
+ const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1');
+ expect(p1Fragment).toBeDefined();
+ expect(p1Fragment!.y).toBeCloseTo(130, 0);
+
+ // Page 2 (documentPageNumber=2, even) → 'even' variant → 40px
+ // Body start = max(50, 30+40) = 70
+ const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2');
+ expect(p2Fragment).toBeDefined();
+ expect(p2Fragment!.y).toBeCloseTo(70, 0);
+
+ // Page 3 (documentPageNumber=3, odd) → 'odd' variant → 80px
+ // Body start = max(50, 30+80) = 110
+ const p3Fragment = layout.pages[2].fragments.find((f) => f.blockId === 'p3');
+ expect(p3Fragment).toBeDefined();
+ expect(p3Fragment!.y).toBeCloseTo(110, 0);
+ });
+
+ it('multi-section: uses document page number for even/odd, not section-relative', () => {
+ // Section 1 has 3 pages (pages 1-3), section 2 starts on page 4.
+ // Page 4 is even by document number, but sectionPageNumber=1 (odd).
+ // The fix ensures document page number is used for even/odd.
+ const sb1: SectionBreakBlock = {
+ kind: 'sectionBreak',
+ id: 'sb1',
+ attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 },
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ };
+ const sb2: SectionBreakBlock = {
+ kind: 'sectionBreak',
+ id: 'sb2',
+ type: 'nextPage',
+ attrs: { source: 'sectPr', sectionIndex: 1 },
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ };
+
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ alternateHeaders: true,
+ sectionMetadata: [{ sectionIndex: 0 }, { sectionIndex: 1 }],
+ headerContentHeights: {
+ odd: 80,
+ even: 40,
+ },
+ };
+
+ const layout = layoutDocument(
+ [sb1, tallBlock('p1'), tallBlock('p2'), tallBlock('p3'), sb2, tallBlock('p4')],
+ [{ kind: 'sectionBreak' }, tallMeasure, tallMeasure, tallMeasure, { kind: 'sectionBreak' }, tallMeasure],
+ options,
+ );
+
+ expect(layout.pages.length).toBeGreaterThanOrEqual(4);
+
+ // Page 4 (documentPageNumber=4, even) → should use 'even' header (40px)
+ // NOT 'odd' which would happen if sectionPageNumber (1) were used
+ // Body start = max(50, 30+40) = 70
+ const p4Fragment = layout.pages[3]?.fragments.find((f) => f.blockId === 'p4');
+ expect(p4Fragment).toBeDefined();
+ expect(p4Fragment!.y).toBeCloseTo(70, 0);
+ });
+
+ it('selects even/odd footer heights when alternateHeaders is true', () => {
+ // The footer-height path uses the per-rId map + sectionMetadata.footerRefs.
+ // Exposing the variant selection through `footerContentHeights` alone is not
+ // sufficient — without refs, the code falls back to 'default' for the footer
+ // variant regardless. We need the ref map to observe variant switching on
+ // `page.margins.bottom`.
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, footer: 30 },
+ alternateHeaders: true,
+ sectionMetadata: [{ sectionIndex: 0, footerRefs: { odd: 'rIdFooterOdd', even: 'rIdFooterEven' } }],
+ footerContentHeightsByRId: new Map([
+ ['rIdFooterOdd', 80], // Odd pages: larger footer
+ ['rIdFooterEven', 40], // Even pages: smaller footer
+ ]),
+ };
+
+ const layout = layoutDocument([tallBlock('p1'), tallBlock('p2')], [tallMeasure, tallMeasure], options);
+
+ expect(layout.pages).toHaveLength(2);
+
+ // Page 1 is odd → 'odd' footer (80px) → bottom = max(50, 30+80) = 110
+ // Page 2 is even → 'even' footer (40px) → bottom = max(50, 30+40) = 70
+ // Body-top Y is footer-independent, so assert on the effective bottom margin
+ // the paginator stamped on each page.
+ expect(layout.pages[0].margins?.bottom).toBeCloseTo(110, 0);
+ expect(layout.pages[1].margins?.bottom).toBeCloseTo(70, 0);
+ });
+
+ it('falls back to default header when only default is defined with alternateHeaders', () => {
+ // Production path: a document with `w:evenAndOddHeaders` on but only a
+ // `default` header authored. sectionMetadata supplies the `default` ref and
+ // the per-rId height map supplies its measurement. Step-3 fallback at
+ // index.ts:1345-1349 kicks in and `effectiveVariantType` drops to 'default'.
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ alternateHeaders: true,
+ sectionMetadata: [{ sectionIndex: 0, headerRefs: { default: 'rIdHeaderDefault' } }],
+ headerContentHeightsByRId: new Map([['rIdHeaderDefault', 60]]),
+ };
+
+ const layout = layoutDocument([tallBlock('p1'), tallBlock('p2')], [tallMeasure, tallMeasure], options);
+
+ expect(layout.pages).toHaveLength(2);
+
+ // Both pages fall back to the default header (60px), so body start is the
+ // same on odd and even: max(50, 30+60) = 90.
+ const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1');
+ const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2');
+ expect(p1Fragment!.y).toBeCloseTo(90, 0);
+ expect(p2Fragment!.y).toBeCloseTo(90, 0);
+ // Effective top margin is also 90 on both pages.
+ expect(layout.pages[0].margins?.top).toBeCloseTo(90, 0);
+ expect(layout.pages[1].margins?.top).toBeCloseTo(90, 0);
+ });
+
+ it('multi-section + titlePg + alternateHeaders: first page of section 2 lands on an even doc-page', () => {
+ // Most realistic mixed case. Section 1 has 3 pages (docPN 1-3). Section 2
+ // has titlePg=true and starts on docPN=4.
+ // - Page 4 is sectionPageNumber=1 for section 2 + titlePg=true → 'first'
+ // - Page 5 is docPN=5 (odd) → 'odd' (regardless of section-relative number)
+ // - Page 6 is docPN=6 (even) → 'even'
+ // If the code used sectionPageNumber for even/odd, pages 5 and 6 would be
+ // swapped (section-relative 2 and 3 respectively). This guards both titlePg
+ // and the docPN rule across a section boundary.
+ const sb1: SectionBreakBlock = {
+ kind: 'sectionBreak',
+ id: 'sb1',
+ attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 },
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ };
+ const sb2: SectionBreakBlock = {
+ kind: 'sectionBreak',
+ id: 'sb2',
+ type: 'nextPage',
+ attrs: { source: 'sectPr', sectionIndex: 1 },
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ };
+
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ alternateHeaders: true,
+ sectionMetadata: [{ sectionIndex: 0 }, { sectionIndex: 1, titlePg: true }],
+ headerContentHeights: {
+ first: 100, // section 2 title-page header
+ odd: 80,
+ even: 40,
+ },
+ };
+
+ const layout = layoutDocument(
+ [sb1, tallBlock('p1'), tallBlock('p2'), tallBlock('p3'), sb2, tallBlock('p4'), tallBlock('p5'), tallBlock('p6')],
+ [
+ { kind: 'sectionBreak' },
+ tallMeasure,
+ tallMeasure,
+ tallMeasure,
+ { kind: 'sectionBreak' },
+ tallMeasure,
+ tallMeasure,
+ tallMeasure,
+ ],
+ options,
+ );
+
+ expect(layout.pages.length).toBeGreaterThanOrEqual(6);
+
+ // Page 4: section 2 first page + titlePg → 'first' (100px) → y = max(50, 30+100) = 130
+ const p4Fragment = layout.pages[3]?.fragments.find((f) => f.blockId === 'p4');
+ expect(p4Fragment).toBeDefined();
+ expect(p4Fragment!.y).toBeCloseTo(130, 0);
+
+ // Page 5: docPN=5, odd → 'odd' (80px) → y = max(50, 30+80) = 110
+ // If sectionPageNumber were used: sectionPN=2 → 'even' (40) → y = 70 (wrong)
+ const p5Fragment = layout.pages[4]?.fragments.find((f) => f.blockId === 'p5');
+ expect(p5Fragment).toBeDefined();
+ expect(p5Fragment!.y).toBeCloseTo(110, 0);
+
+ // Page 6: docPN=6, even → 'even' (40px) → y = max(50, 30+40) = 70
+ // If sectionPageNumber were used: sectionPN=3 → 'odd' (80) → y = 110 (wrong)
+ const p6Fragment = layout.pages[5]?.fragments.find((f) => f.blockId === 'p6');
+ expect(p6Fragment).toBeDefined();
+ expect(p6Fragment!.y).toBeCloseTo(70, 0);
+ });
+});
diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts
index 32a2aea6c1..413ce32247 100644
--- a/packages/layout-engine/layout-engine/src/index.ts
+++ b/packages/layout-engine/layout-engine/src/index.ts
@@ -528,6 +528,17 @@ export type LayoutOptions = {
* behavior for paragraph-free overlays.
*/
allowSectionBreakOnlyPageFallback?: boolean;
+ /**
+ * Whether the document has odd/even header/footer differentiation enabled.
+ * Corresponds to the w:evenAndOddHeaders element in OOXML settings.xml.
+ * When true, odd pages use the 'odd' variant and even pages use the 'even' variant.
+ * When false or omitted, all pages use the 'default' variant.
+ *
+ * Must stay in sync with `getHeaderFooterTypeForSection` in
+ * `layout-bridge/src/headerFooterUtils.ts` — both sides read this value
+ * and must agree on variant selection.
+ */
+ alternateHeaders?: boolean;
};
export type HeaderFooterConstraints = {
@@ -669,23 +680,29 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
/**
* Determines the header/footer variant type for a given page based on section settings.
*
- * @param sectionPageNumber - The page number within the current section (1-indexed)
+ * Takes a params object because the two page-number fields have very similar
+ * names and types — a positional call site is easy to get wrong.
+ *
+ * @param sectionPageNumber - The page number within the current section (1-indexed), used for titlePg
+ * @param documentPageNumber - The absolute document page number (1-indexed), used for even/odd
* @param titlePgEnabled - Whether the section has "different first page" enabled
- * @param alternateHeaders - Whether the section has odd/even differentiation enabled
+ * @param alternateHeaders - Whether the document has odd/even differentiation enabled
* @returns The variant type: 'first', 'even', 'odd', or 'default'
*/
- const getVariantTypeForPage = (
- sectionPageNumber: number,
- titlePgEnabled: boolean,
- alternateHeaders: boolean,
- ): 'default' | 'first' | 'even' | 'odd' => {
+ const getVariantTypeForPage = (args: {
+ sectionPageNumber: number;
+ documentPageNumber: number;
+ titlePgEnabled: boolean;
+ alternateHeaders: boolean;
+ }): 'default' | 'first' | 'even' | 'odd' => {
// First page of section with titlePg enabled uses 'first' variant
- if (sectionPageNumber === 1 && titlePgEnabled) {
+ if (args.sectionPageNumber === 1 && args.titlePgEnabled) {
return 'first';
}
- // Alternate headers (even/odd differentiation)
- if (alternateHeaders) {
- return sectionPageNumber % 2 === 0 ? 'even' : 'odd';
+ // Alternate headers: even/odd based on document page number, matching
+ // the rendering side (getHeaderFooterTypeForSection in headerFooterUtils.ts)
+ if (args.alternateHeaders) {
+ return args.documentPageNumber % 2 === 0 ? 'even' : 'odd';
}
return 'default';
};
@@ -1295,11 +1312,15 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
// Get section metadata for titlePg setting
const sectionMetadata = sectionMetadataList[activeSectionIndex];
const titlePgEnabled = sectionMetadata?.titlePg ?? false;
- // TODO: Support alternateHeaders (odd/even) when needed
- const alternateHeaders = false;
+ const alternateHeaders = options.alternateHeaders ?? false;
// Determine which header/footer variant applies to this page
- const variantType = getVariantTypeForPage(sectionPageNumber, titlePgEnabled, alternateHeaders);
+ const variantType = getVariantTypeForPage({
+ sectionPageNumber,
+ documentPageNumber: newPageNumber,
+ titlePgEnabled,
+ alternateHeaders,
+ });
// Resolve header/footer refs for margin calculation using OOXML inheritance model.
// This must match the rendering logic in PresentationEditor to ensure margins
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 64498efb5d..2c9d1dab9d 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
@@ -5410,12 +5410,17 @@ export class PresentationEditor extends EventEmitter {
this.#hiddenHost.style.width = `${pageSize.w}px`;
+ const alternateHeaders = Boolean(
+ (this.#editor as EditorWithConverter | undefined)?.converter?.pageStyles?.alternateHeaders,
+ );
+
return {
flowMode: 'paginated',
pageSize,
margins: resolvedMargins,
...(columns ? { columns } : {}),
sectionMetadata,
+ alternateHeaders,
};
}
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts
index e7f3e45202..40f9fd8b4e 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts
@@ -772,6 +772,64 @@ describe('PresentationEditor', () => {
});
});
+ describe('alternateHeaders threading (paginated flow)', () => {
+ it('forwards converter.pageStyles.alternateHeaders=true to incrementalLayout', async () => {
+ mockEditorConverterStore.current.pageStyles = { alternateHeaders: true };
+
+ editor = new PresentationEditor({
+ element: container,
+ documentId: 'alt-headers-true-doc',
+ mode: 'docx',
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ expect(mockIncrementalLayout).toHaveBeenCalled();
+ const layoutOptions = mockIncrementalLayout.mock.calls[mockIncrementalLayout.mock.calls.length - 1]?.[3] as {
+ flowMode?: string;
+ alternateHeaders?: boolean;
+ };
+ expect(layoutOptions.flowMode).toBe('paginated');
+ expect(layoutOptions.alternateHeaders).toBe(true);
+ });
+
+ it('forwards alternateHeaders=false when converter.pageStyles.alternateHeaders is unset', async () => {
+ // converter has no pageStyles.alternateHeaders by default
+ editor = new PresentationEditor({
+ element: container,
+ documentId: 'alt-headers-default-doc',
+ mode: 'docx',
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ const layoutOptions = mockIncrementalLayout.mock.calls[mockIncrementalLayout.mock.calls.length - 1]?.[3] as {
+ flowMode?: string;
+ alternateHeaders?: boolean;
+ };
+ expect(layoutOptions.flowMode).toBe('paginated');
+ expect(layoutOptions.alternateHeaders).toBe(false);
+ });
+
+ it('coerces falsy but non-boolean pageStyles.alternateHeaders values to false', async () => {
+ // Defensive check: if a converter emits undefined/null/0/'', we still want a clean boolean
+ mockEditorConverterStore.current.pageStyles = { alternateHeaders: 0 };
+
+ editor = new PresentationEditor({
+ element: container,
+ documentId: 'alt-headers-coerce-doc',
+ mode: 'docx',
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ const layoutOptions = mockIncrementalLayout.mock.calls[mockIncrementalLayout.mock.calls.length - 1]?.[3] as {
+ alternateHeaders?: boolean;
+ };
+ expect(layoutOptions.alternateHeaders).toBe(false);
+ });
+ });
+
describe('scrollToPage', () => {
const buildMixedPageLayout = () => ({
layout: {
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts
index 0328fcb44c..e442a6f34e 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts
@@ -122,6 +122,7 @@ export type ResolvedLayoutOptions =
margins: ResolvedMarginsBase;
columns?: { count: number; gap: number };
sectionMetadata: SectionMetadata[];
+ alternateHeaders?: boolean;
}
| {
flowMode: 'semantic';
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js
index f4a2706bf7..f6cb80864b 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js
@@ -1241,7 +1241,7 @@ function getNumberingDefinitions(docx, converter) {
* @param {Object} docx The parsed docx object
* @returns {Boolean} True if the document has alternating headers and footers, false otherwise
*/
-const isAlternatingHeadersOddEven = (docx) => {
+export const isAlternatingHeadersOddEven = (docx) => {
const settings = docx['word/settings.xml'];
if (!settings || !settings.elements?.length) return false;
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js
index 889ebfadc9..b64c9c7997 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js
@@ -3,6 +3,7 @@ import {
collapseWhitespaceNextToInlinePassthrough,
defaultNodeListHandler,
filterOutRootInlineNodes,
+ isAlternatingHeadersOddEven,
normalizeTableBookmarksInContent,
} from './docxImporter.js';
@@ -484,3 +485,40 @@ describe('docPartObj paragraph import regression', () => {
expect(result[0].content?.[0]?.content?.[0]).toMatchObject({ type: 'text', text: 'Header text' });
});
});
+
+describe('isAlternatingHeadersOddEven', () => {
+ // Helper: build the same docx-object shape the XML parser produces
+ const makeSettingsDocx = (settingsChildren) => ({
+ 'word/settings.xml': {
+ elements: [
+ {
+ type: 'element',
+ name: 'w:settings',
+ elements: settingsChildren,
+ },
+ ],
+ },
+ });
+
+ it('returns true when word/settings.xml contains ', () => {
+ const docx = makeSettingsDocx([{ type: 'element', name: 'w:evenAndOddHeaders' }]);
+ expect(isAlternatingHeadersOddEven(docx)).toBe(true);
+ });
+
+ it('returns false when is absent', () => {
+ const docx = makeSettingsDocx([
+ { type: 'element', name: 'w:zoom', attributes: { 'w:percent': '100' } },
+ { type: 'element', name: 'w:defaultTabStop', attributes: { 'w:val': '720' } },
+ ]);
+ expect(isAlternatingHeadersOddEven(docx)).toBe(false);
+ });
+
+ it('returns false when word/settings.xml is missing', () => {
+ expect(isAlternatingHeadersOddEven({})).toBe(false);
+ });
+
+ it('returns false when settings has no elements', () => {
+ expect(isAlternatingHeadersOddEven({ 'word/settings.xml': { elements: [] } })).toBe(false);
+ expect(isAlternatingHeadersOddEven({ 'word/settings.xml': {} })).toBe(false);
+ });
+});
diff --git a/tests/behavior/tests/headers/odd-even-header-variants.spec.ts b/tests/behavior/tests/headers/odd-even-header-variants.spec.ts
new file mode 100644
index 0000000000..c71eff1656
--- /dev/null
+++ b/tests/behavior/tests/headers/odd-even-header-variants.spec.ts
@@ -0,0 +1,88 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { expect, test } from '../../fixtures/superdoc.js';
+
+// This test verifies the per-page header-variant selection behavior for documents
+// with `w:evenAndOddHeaders` enabled. The layout engine picks the correct header
+// rId per document page (odd pages use the default/odd header, even pages use the
+// even header), and the DomPainter renders the correct header text per page.
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+// `h_f-normal-odd-even.docx` (test-corpus/pagination/) has:
+// - settings.xml:
+// - header default (rId8) text: "1 | Odd page | header, page numbers at the right"
+// - header even (rId7) text: "2 | Even page header, page numbers at the right"
+// - 110 paragraphs → many rendered pages
+// - No titlePg, so page 1 uses the default (odd) header.
+const DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/h_f-normal-odd-even.docx');
+
+test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull');
+
+test('odd/even pages render the correct header variant per document page number', async ({ superdoc }) => {
+ await superdoc.loadDocument(DOC_PATH);
+ await superdoc.waitForStable();
+
+ const pages = superdoc.page.locator('.superdoc-page[data-page-number]');
+ await expect(pages.first()).toBeVisible({ timeout: 15_000 });
+
+ // Need at least three pages to observe the odd → even → odd alternation.
+ await expect.poll(async () => await pages.count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(3);
+
+ const pageCount = await pages.count();
+
+ // Page 1 — docPN=1, odd, no titlePg → default variant (rId8) → "Odd page" text.
+ const page1Header = pages.nth(0).locator('.superdoc-page-header');
+ await expect(page1Header).toContainText('Odd page');
+ await expect(page1Header).not.toContainText('Even page');
+
+ // Page 2 — docPN=2, even → even variant (rId7) → "Even page" text.
+ const page2Header = pages.nth(1).locator('.superdoc-page-header');
+ await expect(page2Header).toContainText('Even page');
+ await expect(page2Header).not.toContainText('Odd page');
+
+ // Page 3 — docPN=3, odd → default variant again → "Odd page" text.
+ // This is what confirms the alternation is driven by documentPageNumber, not
+ // a one-off flag.
+ const page3Header = pages.nth(2).locator('.superdoc-page-header');
+ await expect(page3Header).toContainText('Odd page');
+ await expect(page3Header).not.toContainText('Even page');
+
+ // Spot-check the data-block-id on each header so the test also catches
+ // regressions that produce the right TEXT but from the wrong rId (e.g., if
+ // every page linked to the same header fragment).
+ const page1BlockId = await page1Header.locator('[data-block-id]').first().getAttribute('data-block-id');
+ const page2BlockId = await page2Header.locator('[data-block-id]').first().getAttribute('data-block-id');
+ const page3BlockId = await page3Header.locator('[data-block-id]').first().getAttribute('data-block-id');
+
+ expect(page1BlockId, 'page 1 should use a different header fragment than page 2').not.toBe(page2BlockId);
+ expect(page1BlockId, 'page 1 and page 3 should use the same (default/odd) header fragment').toBe(page3BlockId);
+
+ // Bonus: if the doc produced enough pages, page 4 should alternate back to even.
+ if (pageCount >= 4) {
+ const page4Header = pages.nth(3).locator('.superdoc-page-header');
+ await expect(page4Header).toContainText('Even page');
+ }
+});
+
+test('footers follow the same odd/even alternation as headers', async ({ superdoc }) => {
+ await superdoc.loadDocument(DOC_PATH);
+ await superdoc.waitForStable();
+
+ const pages = superdoc.page.locator('.superdoc-page[data-page-number]');
+ await expect.poll(async () => await pages.count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(3);
+
+ // Footers aren't always in the initial viewport; scroll to each page to make
+ // sure the footer is mounted before asserting.
+ for (const [idx, expected] of [
+ [0, 'Odd page'],
+ [1, 'Even'],
+ [2, 'Odd page'],
+ ] as const) {
+ const pageEl = pages.nth(idx);
+ await pageEl.scrollIntoViewIfNeeded();
+ const footer = pageEl.locator('.superdoc-page-footer');
+ await expect(footer).toContainText(expected);
+ }
+});