Docx writer: fix section breaks with --top-level-division.

Previously, the fix for #10578 (removing leading section break to avoid
blank first page) was implemented inside blocksToOpenXML, which is called
recursively for Div contents. Since makeSectionsWithOffsets wraps each
section in a Div, this caused ALL section breaks to be stripped, not just
the first one (#11482).

This commit fixes the issue by tracking whether we've processed the first
section header using a new stFirstSectionHeader state flag. Section breaks
are now correctly added between chapters/parts while still avoiding a
blank first page.

Closes #11482.
See also #10578.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John MacFarlane
2026-02-23 10:46:18 +00:00
parent be5bafcfc9
commit e270beac1a
3 changed files with 77 additions and 12 deletions
+14 -12
View File
@@ -20,7 +20,7 @@ Conversion of 'Pandoc' documents to docx.
-}
module Text.Pandoc.Writers.Docx.OpenXML ( writeOpenXML, maxListLevel ) where
import Control.Monad ((>=>), when, unless)
import Control.Monad (when, unless)
import Control.Applicative ((<|>))
import Control.Monad.Except (catchError)
import Crypto.Hash (hashWith, SHA1(SHA1))
@@ -329,14 +329,6 @@ blocksToOpenXML :: (PandocMonad m) => WriterOptions -> [Block] -> WS m [Content]
blocksToOpenXML opts =
fmap concat . mapM (blockToOpenXML opts)
. separateTables . filter (not . isForeignRawBlock)
>=>
\case
a@(x:xs) -> do
sep <- sectionSeparator
if Just x == sep
then pure xs
else pure a
[] -> pure []
isForeignRawBlock :: Block -> Bool
isForeignRawBlock (RawBlock format _) = format /= "openxml"
@@ -414,9 +406,19 @@ blockToOpenXML' opts (Header lev (ident,_,kvs) lst) = do
Nothing -> return []
else return []
contents <- (number ++) <$> inlinesToOpenXML opts lst
addSectionBreak <- sectionSeparator >>= \case
Just sep | isSection -> pure (sep:)
_ -> pure id
-- Add section break before section-level headers, but not for the first one
-- to avoid a blank first page (#10578, #11482).
addSectionBreak <- if isSection
then do
isFirst <- gets stFirstSectionHeader
if isFirst
then do
modify $ \s -> s { stFirstSectionHeader = False }
pure id
else sectionSeparator >>= \case
Just sep -> pure (sep:)
Nothing -> pure id
else pure id
addSectionBreak <$>
if T.null ident
then return [Elem $ mknode "w:p" [] (map Elem paraProps ++ contents)]
+2
View File
@@ -121,6 +121,7 @@ data WriterState = WriterState{
, stDelId :: Int
, stStyleMaps :: StyleMaps
, stFirstPara :: Bool
, stFirstSectionHeader :: Bool -- ^ True until first section header is processed
, stNumIdUsed :: Bool -- ^ True if the current numId (envListNumId) has been used.
-- Should only be used once, for the first paragraph.
, stInTable :: Bool
@@ -146,6 +147,7 @@ defaultWriterState = WriterState{
, stDelId = 1
, stStyleMaps = StyleMaps M.empty M.empty
, stFirstPara = False
, stFirstSectionHeader = True
, stNumIdUsed = False
, stInTable = False
, stInList = False
+61
View File
@@ -222,6 +222,67 @@ tests = [ testGroup "inlines"
"docx/document-properties-short-desc.native"
"docx/golden/document-properties-short-desc.docx"
]
, testGroup "top-level-division"
-- Helper to count occurrences of a substring
-- Note: counts by splitting on "<w:sectPr" which marks section properties
[ testCase "no section break before first chapter (#10578)" $ do
-- With --top-level-division=chapter, there should be no section
-- break before the first chapter (to avoid blank first page)
let opts = def{ writerTopLevelDivision = TopLevelChapter }
bs <- runIOorExplode $ do
setVerbosity ERROR
let doc = Pandoc mempty
[ Header 1 ("ch1", [], []) [Str "Chapter", Space, Str "1"]
, Para [Str "First", Space, Str "chapter."]
]
writeDocx opts doc
let archive = toArchive bs
entry <- case findEntryByPath "word/document.xml" archive of
Nothing -> assertFailure "Missing word/document.xml in output docx"
Just e -> return e
let docXml = show (fromEntry entry)
-- Count occurrences of "<w:sectPr" (opening tag for section properties)
let countOccurrences needle haystack =
length (filter (needle `isPrefixOf`) (tails haystack))
where tails [] = []; tails s@(_:xs) = s : tails xs
let sectPrCount = countOccurrences "<w:sectPr" docXml
-- Should have exactly 1 sectPr (the final document section),
-- not 2 (which would mean one before the chapter heading)
assertBool ("Expected 1 sectPr (final section only), found " ++ show sectPrCount)
(sectPrCount == 1)
, testCase "section breaks between chapters (#11482)" $ do
-- With --top-level-division=chapter, there should be section
-- breaks between chapters (but not before the first one)
let opts = def{ writerTopLevelDivision = TopLevelChapter }
bs <- runIOorExplode $ do
setVerbosity ERROR
let doc = Pandoc mempty
[ Header 1 ("ch1", [], []) [Str "Chapter", Space, Str "1"]
, Para [Str "First", Space, Str "chapter."]
, Header 1 ("ch2", [], []) [Str "Chapter", Space, Str "2"]
, Para [Str "Second", Space, Str "chapter."]
, Header 1 ("ch3", [], []) [Str "Chapter", Space, Str "3"]
, Para [Str "Third", Space, Str "chapter."]
]
writeDocx opts doc
let archive = toArchive bs
entry <- case findEntryByPath "word/document.xml" archive of
Nothing -> assertFailure "Missing word/document.xml in output docx"
Just e -> return e
let docXml = show (fromEntry entry)
-- Count occurrences of "<w:sectPr" (opening tag for section properties)
let countOccurrences needle haystack =
length (filter (needle `isPrefixOf`) (tails haystack))
where tails [] = []; tails s@(_:xs) = s : tails xs
let sectPrCount = countOccurrences "<w:sectPr" docXml
-- Should have 3 sectPr elements:
-- - 1 before chapter 2
-- - 1 before chapter 3
-- - 1 final document section
-- (No section break before chapter 1)
assertBool ("Expected 3 sectPr elements, found " ++ show sectPrCount)
(sectPrCount == 3)
]
, testGroup "reference docx"
[ testCase "no media directory override in content types" $ do
let opts = def{ writerReferenceDoc = Just "docx/inline_images.docx" }