@@ -354,3 +354,200 @@ describe('sections adapter DOCX integration', () => {
354354 }
355355 } ) ;
356356} ) ;
357+
358+ /**
359+ * SD-2137 regression suite: clearing header/footer refs must survive export.
360+ *
361+ * The h_f-normal.docx fixture has section-0 with:
362+ * headerRefs: { even: rId7, default: rId8 }
363+ * footerRefs: { even: rId9, default: rId10 }
364+ *
365+ * The exporter has a fallback (exporter.js ~L267) that re-injects a default
366+ * header/footer reference when the sectPr has *no* headerReference elements
367+ * and converter.headerIds.default is still truthy. That fallback must NOT
368+ * fire after an explicit clearHeaderFooterRef mutation.
369+ */
370+ describe ( 'SD-2137: clearHeaderFooterRef must remove refs from exported DOCX' , ( ) => {
371+ let hfDocData : LoadedDocData ;
372+ let editor : Editor | undefined ;
373+
374+ beforeAll ( async ( ) => {
375+ hfDocData = await loadTestDataForEditorTests ( 'h_f-normal.docx' ) ;
376+ } ) ;
377+
378+ beforeEach ( ( ) => {
379+ registerPartDescriptor ( settingsPartDescriptor ) ;
380+ } ) ;
381+
382+ afterEach ( ( ) => {
383+ editor ?. destroy ( ) ;
384+ editor = undefined ;
385+ clearPartDescriptors ( ) ;
386+ clearInvalidationHandlers ( ) ;
387+ } ) ;
388+
389+ function createEditor ( ) : Editor {
390+ const result = initTestEditor ( {
391+ content : hfDocData . docx ,
392+ media : hfDocData . media ,
393+ mediaFiles : hfDocData . mediaFiles ,
394+ fonts : hfDocData . fonts ,
395+ useImmediateSetTimeout : false ,
396+ } ) ;
397+ editor = result . editor ;
398+ return editor ;
399+ }
400+
401+ it ( 'clearing the only remaining header/default ref removes it from exported document.xml' , async ( ) => {
402+ const ed = createEditor ( ) ;
403+ const section0 = getSectionAddressByIndex ( ed , 0 ) ;
404+
405+ // Verify initial state: section-0 has both even + default header refs.
406+ const beforeDomain = resolveSectionProjections ( ed ) . find ( ( s ) => s . range . sectionIndex === 0 ) ! . domain ;
407+ expect ( beforeDomain . headerRefs ?. default ) . toBeTruthy ( ) ;
408+ expect ( beforeDomain . headerRefs ?. even ) . toBeTruthy ( ) ;
409+
410+ // Remove the even header first, leaving header/default as the sole ref.
411+ const clearEven = sectionsClearHeaderFooterRefAdapter (
412+ ed ,
413+ { target : section0 , kind : 'header' , variant : 'even' } ,
414+ DIRECT_MUTATION_OPTIONS ,
415+ ) ;
416+ expect ( clearEven . success ) . toBe ( true ) ;
417+
418+ // Now clear header/default — this is the SD-2137 operation.
419+ const clearDefault = sectionsClearHeaderFooterRefAdapter (
420+ ed ,
421+ { target : section0 , kind : 'header' , variant : 'default' } ,
422+ DIRECT_MUTATION_OPTIONS ,
423+ ) ;
424+ expect ( clearDefault . success ) . toBe ( true ) ;
425+
426+ // Verify the domain reports no header refs.
427+ const afterDomain = resolveSectionProjections ( ed ) . find ( ( s ) => s . range . sectionIndex === 0 ) ! . domain ;
428+ expect ( afterDomain . headerRefs ?. default ) . toBeUndefined ( ) ;
429+
430+ // Export and verify no w:headerReference of any type in the body sectPr.
431+ const exported = await exportDocxFiles ( ed ) ;
432+ const documentXml = exported [ 'word/document.xml' ] ;
433+ expect ( documentXml ) . not . toContain ( 'w:headerReference' ) ;
434+ } ) ;
435+
436+ it ( 'clearing footer/default preserves footer/even in exported document.xml' , async ( ) => {
437+ const ed = createEditor ( ) ;
438+ const section0 = getSectionAddressByIndex ( ed , 0 ) ;
439+
440+ // Verify initial state: section-0 has both even + default footer refs.
441+ const beforeDomain = resolveSectionProjections ( ed ) . find ( ( s ) => s . range . sectionIndex === 0 ) ! . domain ;
442+ expect ( beforeDomain . footerRefs ?. default ) . toBeTruthy ( ) ;
443+ expect ( beforeDomain . footerRefs ?. even ) . toBeTruthy ( ) ;
444+ const evenFooterRefId = beforeDomain . footerRefs ! . even ! ;
445+
446+ // Clear footer/default — even should survive.
447+ const clearResult = sectionsClearHeaderFooterRefAdapter (
448+ ed ,
449+ { target : section0 , kind : 'footer' , variant : 'default' } ,
450+ DIRECT_MUTATION_OPTIONS ,
451+ ) ;
452+ expect ( clearResult . success ) . toBe ( true ) ;
453+
454+ // Export and verify footer/default is gone but footer/even remains.
455+ const exported = await exportDocxFiles ( ed ) ;
456+ const documentXml = exported [ 'word/document.xml' ] ;
457+ expect ( documentXml ) . not . toMatch ( / w : f o o t e r R e f e r e n c e [ ^ > ] * w : t y p e = " d e f a u l t " / ) ;
458+ expect ( documentXml ) . toContain ( `r:id="${ evenFooterRefId } "` ) ;
459+ } ) ;
460+
461+ it ( 'exact SD-2137 repro: clear header/default + footer/default with only footer/even surviving' , async ( ) => {
462+ const ed = createEditor ( ) ;
463+ const section0 = getSectionAddressByIndex ( ed , 0 ) ;
464+
465+ // Shape the fixture to match the bug report: headerRefs={default}, footerRefs={default, even}.
466+ // h_f-normal.docx starts with headerRefs={even, default} — remove even header first.
467+ const clearEvenHeader = sectionsClearHeaderFooterRefAdapter (
468+ ed ,
469+ { target : section0 , kind : 'header' , variant : 'even' } ,
470+ DIRECT_MUTATION_OPTIONS ,
471+ ) ;
472+ expect ( clearEvenHeader . success ) . toBe ( true ) ;
473+
474+ const beforeDomain = resolveSectionProjections ( ed ) . find ( ( s ) => s . range . sectionIndex === 0 ) ! . domain ;
475+ expect ( beforeDomain . headerRefs ?. default ) . toBeTruthy ( ) ;
476+ expect ( beforeDomain . headerRefs ?. even ) . toBeUndefined ( ) ;
477+ expect ( beforeDomain . footerRefs ?. default ) . toBeTruthy ( ) ;
478+ expect ( beforeDomain . footerRefs ?. even ) . toBeTruthy ( ) ;
479+ const evenFooterRefId = beforeDomain . footerRefs ! . even ! ;
480+
481+ // Run the exact SD-2137 operations: clear header/default, then footer/default.
482+ const clearHeaderDefault = sectionsClearHeaderFooterRefAdapter (
483+ ed ,
484+ { target : section0 , kind : 'header' , variant : 'default' } ,
485+ DIRECT_MUTATION_OPTIONS ,
486+ ) ;
487+ expect ( clearHeaderDefault . success ) . toBe ( true ) ;
488+
489+ const clearFooterDefault = sectionsClearHeaderFooterRefAdapter (
490+ ed ,
491+ { target : section0 , kind : 'footer' , variant : 'default' } ,
492+ DIRECT_MUTATION_OPTIONS ,
493+ ) ;
494+ expect ( clearFooterDefault . success ) . toBe ( true ) ;
495+
496+ // Export and verify:
497+ // - No header references of any type.
498+ // - No footer/default, but footer/even survives.
499+ const exported = await exportDocxFiles ( ed ) ;
500+ const documentXml = exported [ 'word/document.xml' ] ;
501+ expect ( documentXml ) . not . toContain ( 'w:headerReference' ) ;
502+ expect ( documentXml ) . not . toMatch ( / w : f o o t e r R e f e r e n c e [ ^ > ] * w : t y p e = " d e f a u l t " / ) ;
503+ expect ( documentXml ) . toMatch ( / w : f o o t e r R e f e r e n c e [ ^ > ] * w : t y p e = " e v e n " / ) ;
504+ expect ( documentXml ) . toContain ( `r:id="${ evenFooterRefId } "` ) ;
505+ } ) ;
506+
507+ it ( 'clearing header/default on a paragraph-owned sectPr (non-final section) removes it from export' , async ( ) => {
508+ const ed = createEditor ( ) ;
509+
510+ // Create a section break so section-0 gets a paragraph-owned sectPr.
511+ const breakResult = createSectionBreakAdapter (
512+ ed ,
513+ { at : { kind : 'documentEnd' } , breakType : 'nextPage' } ,
514+ DIRECT_MUTATION_OPTIONS ,
515+ ) ;
516+ expect ( breakResult . success ) . toBe ( true ) ;
517+
518+ // Section breaks don't propagate header/footer refs. The body section
519+ // (now section-1) retains the original refs — borrow one for section-0.
520+ const headerRefId = resolveSectionProjections ( ed ) . find ( ( s ) => s . range . sectionIndex === 1 ) ?. domain . headerRefs
521+ ?. default ;
522+ expect ( headerRefId ) . toBeTruthy ( ) ;
523+
524+ const section0 = getSectionAddressByIndex ( ed , 0 ) ;
525+ const setResult = sectionsSetHeaderFooterRefAdapter (
526+ ed ,
527+ { target : section0 , kind : 'header' , variant : 'default' , refId : headerRefId ! } ,
528+ DIRECT_MUTATION_OPTIONS ,
529+ ) ;
530+ expect ( setResult . success ) . toBe ( true ) ;
531+
532+ // Now clear header/default on the paragraph-owned section.
533+ const clearResult = sectionsClearHeaderFooterRefAdapter (
534+ ed ,
535+ { target : section0 , kind : 'header' , variant : 'default' } ,
536+ DIRECT_MUTATION_OPTIONS ,
537+ ) ;
538+ expect ( clearResult . success ) . toBe ( true ) ;
539+
540+ // The paragraph-owned sectPr export path has no fallback injection,
541+ // so clearing should always work. Verify via export.
542+ const exported = await exportDocxFiles ( ed ) ;
543+ const documentXml = exported [ 'word/document.xml' ] ;
544+
545+ // Extract all sectPr blocks from the XML.
546+ const sectPrBlocks = documentXml . match ( / < w : s e c t P r [ ^ > ] * > [ \s \S ] * ?< \/ w : s e c t P r > / g) ?? [ ] ;
547+ expect ( sectPrBlocks . length ) . toBeGreaterThanOrEqual ( 2 ) ;
548+
549+ // The first sectPr (paragraph-owned, section-0) should have no header/default.
550+ const paragraphSectPr = sectPrBlocks [ 0 ] ! ;
551+ expect ( paragraphSectPr ) . not . toMatch ( / w : h e a d e r R e f e r e n c e [ ^ > ] * w : t y p e = " d e f a u l t " / ) ;
552+ } ) ;
553+ } ) ;
0 commit comments