From 7c207a2447d63c9008b287a46afae1403387950c Mon Sep 17 00:00:00 2001 From: massifrg Date: Wed, 4 Mar 2026 13:31:43 +0100 Subject: [PATCH 01/10] DOCX writer: support for endnotes Initial support for endnotes for openxml. You get an endnote embedding a Note in a Span that has an "endnote" class. --- data/docx/[Content_Types].xml | 2 +- data/docx/word/endnotes.xml | 21 ++++++++++++++ data/docx/word/settings.xml | 4 +++ data/docx/word/styles.xml | 15 ++++++++++ src/Text/Pandoc/Writers/Docx.hs | 32 +++++++++++++++++++-- src/Text/Pandoc/Writers/Docx/OpenXML.hs | 37 ++++++++++++++++++------- src/Text/Pandoc/Writers/Docx/Types.hs | 8 ++++++ 7 files changed, 106 insertions(+), 13 deletions(-) create mode 100644 data/docx/word/endnotes.xml diff --git a/data/docx/[Content_Types].xml b/data/docx/[Content_Types].xml index 0c0118a88b37..0219fc66b6e2 100644 --- a/data/docx/[Content_Types].xml +++ b/data/docx/[Content_Types].xml @@ -1,2 +1,2 @@ - + diff --git a/data/docx/word/endnotes.xml b/data/docx/word/endnotes.xml new file mode 100644 index 000000000000..a9bb2c2174c1 --- /dev/null +++ b/data/docx/word/endnotes.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + Endnote Text. + + + + Endnote Block Text + + + diff --git a/data/docx/word/settings.xml b/data/docx/word/settings.xml index 19bd52257d8a..26136d89cb4d 100644 --- a/data/docx/word/settings.xml +++ b/data/docx/word/settings.xml @@ -16,6 +16,10 @@ + + + + diff --git a/data/docx/word/styles.xml b/data/docx/word/styles.xml index effc926c4a2e..5d2436ccc166 100644 --- a/data/docx/word/styles.xml +++ b/data/docx/word/styles.xml @@ -583,6 +583,14 @@ + + + + + + + + @@ -686,6 +694,13 @@ + + + + + + + diff --git a/src/Text/Pandoc/Writers/Docx.hs b/src/Text/Pandoc/Writers/Docx.hs index 782833965591..b15010390cdd 100644 --- a/src/Text/Pandoc/Writers/Docx.hs +++ b/src/Text/Pandoc/Writers/Docx.hs @@ -123,7 +123,7 @@ writeDocx opts doc = do [ mknode "w:numRestart" [("w:val","eachSect")] () ] ] - ((contents, footnotes, comments), st) <- runStateT + ((contents, footnotes, endnotes, comments), st) <- runStateT (runReaderT (writeOpenXML opts{ writerWrapText = WrapNone } doc') @@ -143,6 +143,8 @@ writeDocx opts doc = do (BL.fromStrict $ UTF8.fromText contents) let footnotesEntry = mkFootnotesEntry epochtime footnotes let footnoteRelEntry = mkFootnoteRelsEntry epochtime (stExternalLinks st) + let endnotesEntry = mkEndnotesEntry epochtime endnotes + let endnoteRelEntry = mkEndnoteRelsEntry epochtime (stExternalLinks st) let commentsEntry = mkCommentsEntry epochtime comments let styleEntry = mkStylesEntry epochtime styledoc styleMaps st opts numEntry <- mkNumberingEntry refArchive distArchive epochtime (stLists st) @@ -165,6 +167,7 @@ writeDocx opts doc = do let archive = foldr addEntryToArchive emptyArchive $ contentTypesEntry : relsEntry : contentEntry : relEntry : footnoteRelEntry : numEntry : styleEntry : footnotesEntry : + endnoteRelEntry : endnotesEntry : commentsEntry : docPropsEntry : customPropsEntry : settingsEntry : @@ -569,6 +572,8 @@ extractRelationships refArchive distArchive = do "theme/theme1.xml") ,("http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes", "footnotes.xml") + ,("http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes", + "endnotes.xml") ,("http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments", "comments.xml") ] @@ -595,6 +600,26 @@ mkFootnoteRelsEntry epochtime externalLinks = [("xmlns","http://schemas.openxmlformats.org/package/2006/relationships")] linkrels +-- | Create endnotes XML entry +mkEndnotesEntry :: Integer -> [Element] -> Entry +mkEndnotesEntry epochtime endnotes = + let notes = mknode "w:endnotes" stdAttributes endnotes + in toEntry "word/endnotes.xml" epochtime $ renderXml notes + +-- | Create endnote relationships entry +mkEndnoteRelsEntry :: Integer -> M.Map Text Text -> Entry +mkEndnoteRelsEntry epochtime externalLinks = + let linkrels = map toLinkRel $ M.toList externalLinks + toLinkRel (src, ident) = mknode "Relationship" + [("Type","http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink") + ,("Id",ident) + ,("Target",src) + ,("TargetMode","External")] () + in toEntry "word/_rels/endnotes.xml.rels" epochtime + $ renderXml $ mknode "Relationships" + [("xmlns","http://schemas.openxmlformats.org/package/2006/relationships")] + linkrels + -- | Create comments XML entry mkCommentsEntry :: Integer -> [Element] -> Entry mkCommentsEntry epochtime comments = @@ -663,6 +688,8 @@ mkContentTypesEntry epochtime imgs headers footers refArchive = "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml") ,("/word/footnotes.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml") + ,("/word/endnotes.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml") ] ++ map (\x -> (maybe "" (T.unpack . ("/word/" <>)) (extractTarget x), "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml")) headers ++ @@ -836,7 +863,8 @@ collectReferenceEntries refArchive distArchive headers footers = do , "word/_rels/" `isPrefixOf` eRelativePath e , ".xml.rels" `isSuffixOf` eRelativePath e , eRelativePath e /= "word/_rels/document.xml.rels" - , eRelativePath e /= "word/_rels/footnotes.xml.rels" ] + , eRelativePath e /= "word/_rels/footnotes.xml.rels" + , eRelativePath e /= "word/_rels/endnotes.xml.rels" ] let otherMediaEntries = [ e | e <- zEntries refArchive , "word/media/" `isPrefixOf` eRelativePath e ] return $ docPropsAppEntry : themeEntry : fontTableEntry : webSettingsEntry diff --git a/src/Text/Pandoc/Writers/Docx/OpenXML.hs b/src/Text/Pandoc/Writers/Docx/OpenXML.hs index 5490a732de52..cbff8cb1999a 100644 --- a/src/Text/Pandoc/Writers/Docx/OpenXML.hs +++ b/src/Text/Pandoc/Writers/Docx/OpenXML.hs @@ -229,11 +229,11 @@ sectionSeparator = do Nothing -> pure Nothing --- | Convert Pandoc document to rendered document contents plus two lists of --- OpenXML elements (footnotes and comments). +-- | Convert Pandoc document to rendered document contents plus three lists of +-- OpenXML elements (footnotes, endnotes and comments). writeOpenXML :: PandocMonad m => WriterOptions -> Pandoc - -> WS m (Text, [Element], [Element]) + -> WS m (Text, [Element], [Element], [Element]) writeOpenXML opts (Pandoc meta blocks) = do setupTranslations meta let includeTOC = writerTableOfContents opts || lookupMetaBool "toc" meta @@ -264,6 +264,7 @@ writeOpenXML opts (Pandoc meta blocks) = do doc' <- setFirstPara >> blocksToOpenXML opts blocks let body = vcat $ map (literal . showContent) doc' notes' <- gets (reverse . stFootnotes) + endnotes' <- gets (reverse . stEndnotes) comments <- gets (reverse . stComments) let toComment (kvs, ils) = do annotation <- inlinesToOpenXML opts ils @@ -322,7 +323,7 @@ writeOpenXML opts (Pandoc meta blocks) = do $ metadata tpl <- maybe (lift $ compileDefaultTemplate "openxml") pure $ writerTemplate opts let rendered = render Nothing $ renderTemplate tpl context - return (rendered, notes', comments') + return (rendered, notes', endnotes', comments') -- | Convert a list of Pandoc blocks to OpenXML. blocksToOpenXML :: (PandocMonad m) => WriterOptions -> [Block] -> WS m [Content] @@ -755,6 +756,11 @@ inlineToOpenXML' opts SoftBreak = inlineToOpenXML opts (Str " ") inlineToOpenXML' opts (Span ("",["mark"],[]) ils) = withTextProp (mknode "w:highlight" [("w:val","yellow")] ()) $ inlinesToOpenXML opts ils +inlineToOpenXML' opts (Span ("",["endnote"],[]) ils) = do + modify $ \s -> s { stInEndnote = True } + endnote <- inlinesToOpenXML opts ils + modify $ \s -> s { stInEndnote = False } + return endnote inlineToOpenXML' opts (Span ("",["csl-block"],[]) ils) = inlinesToOpenXML opts ils inlineToOpenXML' opts (Span ("",["csl-left-margin"],[]) ils) = @@ -905,28 +911,39 @@ inlineToOpenXML' opts (Code attrs str) = do Skylighting _ -> highlighted _ -> unhighlighted inlineToOpenXML' opts (Note bs) = do + isEndnote <- gets stInEndnote notes <- gets stFootnotes notenum <- getUniqueId - footnoteStyle <- rStyleM "Footnote Reference" + footnoteStyle <- rStyleM $ if isEndnote + then "Endnote Reference" + else "Footnote Reference" + let noteRefNodeName = if isEndnote then "w:endnoteRef" else "w:footnoteRef" let notemarker = mknode "w:r" [] [ mknode "w:rPr" [] footnoteStyle - , mknode "w:footnoteRef" [] () ] + , mknode noteRefNodeName [] () ] let notemarkerXml = RawInline (Format "openxml") $ ppElement notemarker let insertNoteRef (Plain ils : xs) = Plain (notemarkerXml : Space : ils) : xs insertNoteRef (Para ils : xs) = Para (notemarkerXml : Space : ils) : xs insertNoteRef xs = Para [notemarkerXml] : xs + let noteTextStyleName = if isEndnote then "Endnote Text" else "Footnote Text" contents <- local (\env -> env{ envListLevel = -1 , envParaProperties = mempty , envTextProperties = mempty , envInNote = True }) - (withParaPropM (pStyleM "Footnote Text") $ + (withParaPropM (pStyleM noteTextStyleName) $ blocksToOpenXML opts $ insertNoteRef bs) - let newnote = mknode "w:footnote" [("w:id", notenum)] contents - modify $ \s -> s{ stFootnotes = newnote : notes } + let noteNodeName = if isEndnote then "w:endnote" else "w:footnote" + let newnote = mknode noteNodeName [("w:id", notenum)] contents + modify $ \s -> if isEndnote + then s{ stEndnotes = newnote : notes } + else s{ stFootnotes = newnote : notes } + let noteReferenceNodeName = if isEndnote + then "w:endnoteReference" + else "w:footnoteReference" return [ Elem $ mknode "w:r" [] [ mknode "w:rPr" [] footnoteStyle - , mknode "w:footnoteReference" [("w:id", notenum)] () ] ] + , mknode noteReferenceNodeName [("w:id", notenum)] () ] ] -- internal link: inlineToOpenXML' opts (Link _ txt (T.uncons -> Just ('#', xs),_)) = do contents <- withTextPropM (rStyleM "Hyperlink") $ inlinesToOpenXML opts txt diff --git a/src/Text/Pandoc/Writers/Docx/Types.hs b/src/Text/Pandoc/Writers/Docx/Types.hs index febef6603dd3..a2a199e8c2b6 100644 --- a/src/Text/Pandoc/Writers/Docx/Types.hs +++ b/src/Text/Pandoc/Writers/Docx/Types.hs @@ -111,6 +111,7 @@ defaultWriterEnv = WriterEnv data WriterState = WriterState{ stFootnotes :: [Element] + , stEndnotes :: [Element] , stComments :: [([(Text, Text)], [Inline])] , stSectionIds :: Set.Set Text , stExternalLinks :: M.Map Text Text @@ -126,6 +127,7 @@ data WriterState = WriterState{ -- Should only be used once, for the first paragraph. , stInTable :: Bool , stInList :: Bool + , stInEndnote :: Bool , stTocTitle :: [Inline] , stDynamicParaProps :: Set.Set ParaStyleName , stDynamicTextProps :: Set.Set CharStyleName @@ -137,6 +139,7 @@ data WriterState = WriterState{ defaultWriterState :: WriterState defaultWriterState = WriterState{ stFootnotes = defaultFootnotes + , stEndnotes = defaultEndnotes , stComments = [] , stSectionIds = Set.empty , stExternalLinks = M.empty @@ -151,6 +154,7 @@ defaultWriterState = WriterState{ , stNumIdUsed = False , stInTable = False , stInList = False + , stInEndnote = False , stTocTitle = [Str "Table of Contents"] , stDynamicParaProps = Set.empty , stDynamicTextProps = Set.empty @@ -180,6 +184,10 @@ defaultFootnotes = [ mknode "w:footnote" [ mknode "w:r" [] [ mknode "w:continuationSeparator" [] ()]]]] +-- TODO: verify whether Word behaves the same with endnotes as it does with footnotes. +defaultEndnotes :: [Element] +defaultEndnotes = [] + pStyleM :: (PandocMonad m) => ParaStyleName -> WS m XML.Element pStyleM styleName = do pStyleMap <- gets (smParaStyle . stStyleMaps) From f3bb8bba3617e7bf75ae2205e8b3ce4402187c0d Mon Sep 17 00:00:00 2001 From: massifrg Date: Wed, 4 Mar 2026 17:34:44 +0100 Subject: [PATCH 02/10] DOCX: extension "endnotes" added A new extension "endnotes" to enable endnotes in docx format --- pandoc-lua-engine/test/lua/module/pandoc-format.lua | 1 + src/Text/Pandoc/Extensions.hs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/pandoc-lua-engine/test/lua/module/pandoc-format.lua b/pandoc-lua-engine/test/lua/module/pandoc-format.lua index 6a1915a163f7..d2a10b94e675 100644 --- a/pandoc-lua-engine/test/lua/module/pandoc-format.lua +++ b/pandoc-lua-engine/test/lua/module/pandoc-format.lua @@ -24,6 +24,7 @@ return { 'citations', 'east_asian_line_breaks', 'empty_paragraphs', + 'endnotes', 'gfm_auto_identifiers', 'native_numbering', 'styles', diff --git a/src/Text/Pandoc/Extensions.hs b/src/Text/Pandoc/Extensions.hs index 7fcb214cdbf4..82dadb36ac3b 100644 --- a/src/Text/Pandoc/Extensions.hs +++ b/src/Text/Pandoc/Extensions.hs @@ -68,6 +68,7 @@ data Extension = | Ext_element_citations -- ^ Use element-citation elements for JATS citations | Ext_emoji -- ^ Support emoji like :smile: | Ext_empty_paragraphs -- ^ Allow empty paragraphs + | Ext_endnotes -- ^ Endnotes support when footnotes are embedded in a Span.endnote | Ext_epub_html_exts -- ^ Recognise the EPUB extended version of HTML | Ext_escaped_line_breaks -- ^ Treat a backslash at EOL as linebreak | Ext_example_lists -- ^ Markdown-style numbered examples @@ -532,6 +533,7 @@ getAllExtensions f = universalExtensions <> getAll f [ Ext_raw_markdown ] getAll "docx" = autoIdExtensions <> extensionsFromList [ Ext_empty_paragraphs + , Ext_endnotes , Ext_native_numbering , Ext_styles , Ext_citations From 7a469963eb0b2b1ce3e3082f3f39cea4d69025e2 Mon Sep 17 00:00:00 2001 From: massifrg Date: Wed, 4 Mar 2026 22:11:47 +0100 Subject: [PATCH 03/10] DOCX writer: endnotes ext working The "endnotes" extension for docx is working with the docx Writer. No tests for this feature yet, but old tests now pass. --- data/docx/[Content_Types].xml | 2 +- data/docx/word/styles.xml | 15 ---------- src/Text/Pandoc/Writers/Docx.hs | 38 ++++++++++++++----------- src/Text/Pandoc/Writers/Docx/OpenXML.hs | 20 +++++++------ src/Text/Pandoc/Writers/Docx/Types.hs | 13 ++++++++- 5 files changed, 47 insertions(+), 41 deletions(-) diff --git a/data/docx/[Content_Types].xml b/data/docx/[Content_Types].xml index 0219fc66b6e2..0c0118a88b37 100644 --- a/data/docx/[Content_Types].xml +++ b/data/docx/[Content_Types].xml @@ -1,2 +1,2 @@ - + diff --git a/data/docx/word/styles.xml b/data/docx/word/styles.xml index 5d2436ccc166..effc926c4a2e 100644 --- a/data/docx/word/styles.xml +++ b/data/docx/word/styles.xml @@ -583,14 +583,6 @@ - - - - - - - - @@ -694,13 +686,6 @@ - - - - - - - diff --git a/src/Text/Pandoc/Writers/Docx.hs b/src/Text/Pandoc/Writers/Docx.hs index b15010390cdd..6f6a8f87e9a4 100644 --- a/src/Text/Pandoc/Writers/Docx.hs +++ b/src/Text/Pandoc/Writers/Docx.hs @@ -105,7 +105,7 @@ writeDocx opts doc = do } -- Phase 5: Relationship extraction - (baserels, headers, footers, newMaxRelId) <- extractRelationships refArchive distArchive + (baserels, headers, footers, newMaxRelId) <- extractRelationships opts refArchive distArchive let initialSt = defaultWriterState { stStyleMaps = styleMaps @@ -137,7 +137,7 @@ writeDocx opts doc = do -- because Word sometimes changes these files when a reference.docx is modified, -- e.g. deleting the reference to footnotes.xml or removing default entries -- for image content types. - let contentTypesEntry = mkContentTypesEntry epochtime imgs headers footers refArchive + let contentTypesEntry = mkContentTypesEntry epochtime imgs headers footers endnotes refArchive let relEntry = mkDocumentRelsEntry epochtime baserels imgs (stExternalLinks st) let contentEntry = toEntry "word/document.xml" epochtime (BL.fromStrict $ UTF8.fromText contents) @@ -167,11 +167,12 @@ writeDocx opts doc = do let archive = foldr addEntryToArchive emptyArchive $ contentTypesEntry : relsEntry : contentEntry : relEntry : footnoteRelEntry : numEntry : styleEntry : footnotesEntry : - endnoteRelEntry : endnotesEntry : - commentsEntry : - docPropsEntry : customPropsEntry : - settingsEntry : - imageEntries ++ refEntries + commentsEntry : docPropsEntry : customPropsEntry : settingsEntry : + imageEntries + ++ refEntries + ++ if (isEnabled Ext_endnotes opts) + then [endnoteRelEntry, endnotesEntry] + else [] return $ fromArchive archive newParaPropToOpenXml :: ParaStyleName -> Element @@ -528,9 +529,9 @@ extractPageLayout refArchive distArchive = do -- | Parse and augment relationships from reference.docx extractRelationships :: PandocMonad m - => Archive -> Archive + => WriterOptions -> Archive -> Archive -> m ([Element], [Element], [Element], Int) -extractRelationships refArchive distArchive = do +extractRelationships opts refArchive distArchive = do let isImageNode e = findAttr (QName "Type" Nothing Nothing) e == Just "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" let isHeaderNode e = findAttr (QName "Type" Nothing Nothing) e == Just "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" let isFooterNode e = findAttr (QName "Type" Nothing Nothing) e == Just "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" @@ -557,7 +558,7 @@ extractRelationships refArchive distArchive = do ,("Target",target')] () : rels) _ -> (maxId, rels) - let (newMaxRelId, baserels) = foldr addBaseRel (maxRelId, parsedRels) + let (newMaxRelId, baserels) = foldr addBaseRel (maxRelId, parsedRels) $ [("http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering", "numbering.xml") ,("http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles", @@ -572,11 +573,10 @@ extractRelationships refArchive distArchive = do "theme/theme1.xml") ,("http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes", "footnotes.xml") - ,("http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes", - "endnotes.xml") ,("http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments", "comments.xml") - ] + ] ++ [("http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes", + "endnotes.xml") | isEnabled Ext_endnotes opts] return (baserels, headers, footers, newMaxRelId) @@ -652,9 +652,10 @@ mkContentTypesEntry :: Integer -> [(String, String, Maybe MimeType, B.ByteString)] -- imgs -> [Element] -- headers -> [Element] -- footers + -> [Element] -- endnotes -> Archive -- refArchive -> Entry -mkContentTypesEntry epochtime imgs headers footers refArchive = +mkContentTypesEntry epochtime imgs headers footers endnotes refArchive = let mkOverrideNode (part', contentType') = mknode "Override" [("PartName", T.pack part') ,("ContentType", contentType')] () @@ -663,6 +664,11 @@ mkContentTypesEntry epochtime imgs headers footers refArchive = fromMaybe "application/octet-stream" mbMimeType) mkMediaOverride imgpath = mkOverrideNode ("/" <> imgpath, getMimeTypeDef imgpath) + endnotesOverride = if null endnotes + then [("/word/endnotes.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml")] + else [] + overrides = map mkOverrideNode ( [("/word/webSettings.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml") @@ -688,9 +694,8 @@ mkContentTypesEntry epochtime imgs headers footers refArchive = "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml") ,("/word/footnotes.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml") - ,("/word/endnotes.xml", - "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml") ] ++ + endnotesOverride ++ map (\x -> (maybe "" (T.unpack . ("/word/" <>)) (extractTarget x), "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml")) headers ++ map (\x -> (maybe "" (T.unpack . ("/word/" <>)) (extractTarget x), @@ -750,6 +755,7 @@ mkStylesEntry epochtime styledoc styleMaps st opts = (\sty -> not $ hasStyleName sty $ smCharStyle styleMaps) (Set.toList $ stDynamicTextProps st) + -- TODO: add styles for endnotes, when Ext_endnotes is enabled newstyles = map newParaPropToOpenXml newDynamicParaProps ++ map newTextPropToOpenXml newDynamicTextProps ++ (case writerHighlightMethod opts of diff --git a/src/Text/Pandoc/Writers/Docx/OpenXML.hs b/src/Text/Pandoc/Writers/Docx/OpenXML.hs index cbff8cb1999a..63e5029da611 100644 --- a/src/Text/Pandoc/Writers/Docx/OpenXML.hs +++ b/src/Text/Pandoc/Writers/Docx/OpenXML.hs @@ -756,11 +756,13 @@ inlineToOpenXML' opts SoftBreak = inlineToOpenXML opts (Str " ") inlineToOpenXML' opts (Span ("",["mark"],[]) ils) = withTextProp (mknode "w:highlight" [("w:val","yellow")] ()) $ inlinesToOpenXML opts ils -inlineToOpenXML' opts (Span ("",["endnote"],[]) ils) = do - modify $ \s -> s { stInEndnote = True } - endnote <- inlinesToOpenXML opts ils - modify $ \s -> s { stInEndnote = False } - return endnote +inlineToOpenXML' opts (Span ("",["endnote"],[]) ils) = if isEnabled Ext_endnotes opts + then (do + modify $ \s -> s { stInEndnote = isEnabled Ext_endnotes opts } + endnote <- inlinesToOpenXML opts ils + modify $ \s -> s { stInEndnote = False } + return endnote) + else inlinesToOpenXML opts ils inlineToOpenXML' opts (Span ("",["csl-block"],[]) ils) = inlinesToOpenXML opts ils inlineToOpenXML' opts (Span ("",["csl-left-margin"],[]) ils) = @@ -847,11 +849,13 @@ inlineToOpenXML' opts (Span (ident,classes,kvs) ils) = do langmod $ inlinesToOpenXML opts ils wrapBookmark ident contents inlineToOpenXML' opts (Strong lst) = - withTextProp (mknode "w:bCs" [] ()) $ -- needed for LTR, #6911 + withTextProp (mknode "w:bCs" [] ()) $ -- needed for LTR, #6911 -- needed for LTR, #6911 + -- needed for LTR, #6911 withTextProp (mknode "w:b" [] ()) $ inlinesToOpenXML opts lst inlineToOpenXML' opts (Emph lst) = - withTextProp (mknode "w:iCs" [] ()) $ -- needed for LTR, #6911 + withTextProp (mknode "w:iCs" [] ()) $ -- needed for LTR, #6911 -- needed for LTR, #6911 + -- needed for LTR, #6911 withTextProp (mknode "w:i" [] ()) $ inlinesToOpenXML opts lst inlineToOpenXML' opts (Underline lst) = @@ -912,7 +916,7 @@ inlineToOpenXML' opts (Code attrs str) = do _ -> unhighlighted inlineToOpenXML' opts (Note bs) = do isEndnote <- gets stInEndnote - notes <- gets stFootnotes + notes <- gets $ if isEndnote then stEndnotes else stFootnotes notenum <- getUniqueId footnoteStyle <- rStyleM $ if isEndnote then "Endnote Reference" diff --git a/src/Text/Pandoc/Writers/Docx/Types.hs b/src/Text/Pandoc/Writers/Docx/Types.hs index a2a199e8c2b6..d985d98a16a0 100644 --- a/src/Text/Pandoc/Writers/Docx/Types.hs +++ b/src/Text/Pandoc/Writers/Docx/Types.hs @@ -185,8 +185,19 @@ defaultFootnotes = [ mknode "w:footnote" [ mknode "w:continuationSeparator" [] ()]]]] -- TODO: verify whether Word behaves the same with endnotes as it does with footnotes. +-- For now, let's do the same as for footnotes defaultEndnotes :: [Element] -defaultEndnotes = [] +-- defaultEndnotes = [] +defaultEndnotes = [ mknode "w:endnote" + [("w:type", "separator"), ("w:id", "-1")] + [ mknode "w:p" [] + [mknode "w:r" [] + [ mknode "w:separator" [] ()]]] + , mknode "w:endnote" + [("w:type", "continuationSeparator"), ("w:id", "0")] + [ mknode "w:p" [] + [ mknode "w:r" [] + [ mknode "w:continuationSeparator" [] ()]]]] pStyleM :: (PandocMonad m) => ParaStyleName -> WS m XML.Element pStyleM styleName = do From 932d82646e51e59ce327036c515dc7df612972db Mon Sep 17 00:00:00 2001 From: massifrg Date: Wed, 4 Mar 2026 19:04:16 +0100 Subject: [PATCH 04/10] DOCX writer: endnotes ext working The "endnotes" extension for docx is working with the docx Writer. No tests for this feature yet, but old tests now pass. --- src/Text/Pandoc/Writers/Docx.hs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/Text/Pandoc/Writers/Docx.hs b/src/Text/Pandoc/Writers/Docx.hs index 6f6a8f87e9a4..a7aff82325b8 100644 --- a/src/Text/Pandoc/Writers/Docx.hs +++ b/src/Text/Pandoc/Writers/Docx.hs @@ -106,6 +106,7 @@ writeDocx opts doc = do -- Phase 5: Relationship extraction (baserels, headers, footers, newMaxRelId) <- extractRelationships opts refArchive distArchive + (baserels, headers, footers, newMaxRelId) <- extractRelationships opts refArchive distArchive let initialSt = defaultWriterState { stStyleMaps = styleMaps @@ -138,6 +139,7 @@ writeDocx opts doc = do -- e.g. deleting the reference to footnotes.xml or removing default entries -- for image content types. let contentTypesEntry = mkContentTypesEntry epochtime imgs headers footers endnotes refArchive + let contentTypesEntry = mkContentTypesEntry epochtime imgs headers footers endnotes refArchive let relEntry = mkDocumentRelsEntry epochtime baserels imgs (stExternalLinks st) let contentEntry = toEntry "word/document.xml" epochtime (BL.fromStrict $ UTF8.fromText contents) @@ -173,6 +175,12 @@ writeDocx opts doc = do ++ if (isEnabled Ext_endnotes opts) then [endnoteRelEntry, endnotesEntry] else [] + commentsEntry : docPropsEntry : customPropsEntry : settingsEntry : + imageEntries + ++ refEntries + ++ if (isEnabled Ext_endnotes opts) + then [endnoteRelEntry, endnotesEntry] + else [] return $ fromArchive archive newParaPropToOpenXml :: ParaStyleName -> Element @@ -529,8 +537,10 @@ extractPageLayout refArchive distArchive = do -- | Parse and augment relationships from reference.docx extractRelationships :: PandocMonad m + => WriterOptions -> Archive -> Archive => WriterOptions -> Archive -> Archive -> m ([Element], [Element], [Element], Int) +extractRelationships opts refArchive distArchive = do extractRelationships opts refArchive distArchive = do let isImageNode e = findAttr (QName "Type" Nothing Nothing) e == Just "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" let isHeaderNode e = findAttr (QName "Type" Nothing Nothing) e == Just "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" @@ -558,6 +568,7 @@ extractRelationships opts refArchive distArchive = do ,("Target",target')] () : rels) _ -> (maxId, rels) + let (newMaxRelId, baserels) = foldr addBaseRel (maxRelId, parsedRels) $ let (newMaxRelId, baserels) = foldr addBaseRel (maxRelId, parsedRels) $ [("http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering", "numbering.xml") @@ -577,6 +588,8 @@ extractRelationships opts refArchive distArchive = do "comments.xml") ] ++ [("http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes", "endnotes.xml") | isEnabled Ext_endnotes opts] + ] ++ [("http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes", + "endnotes.xml") | isEnabled Ext_endnotes opts] return (baserels, headers, footers, newMaxRelId) @@ -653,8 +666,10 @@ mkContentTypesEntry :: Integer -> [Element] -- headers -> [Element] -- footers -> [Element] -- endnotes + -> [Element] -- endnotes -> Archive -- refArchive -> Entry +mkContentTypesEntry epochtime imgs headers footers endnotes refArchive = mkContentTypesEntry epochtime imgs headers footers endnotes refArchive = let mkOverrideNode (part', contentType') = mknode "Override" [("PartName", T.pack part') @@ -665,8 +680,8 @@ mkContentTypesEntry epochtime imgs headers footers endnotes refArchive = mkMediaOverride imgpath = mkOverrideNode ("/" <> imgpath, getMimeTypeDef imgpath) endnotesOverride = if null endnotes - then [("/word/endnotes.xml", - "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml")] + then map (\x -> (maybe "" (T.unpack . ("/word/" <>)) (extractTarget x), + "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml")) endnotes else [] overrides = map mkOverrideNode ( @@ -696,6 +711,7 @@ mkContentTypesEntry epochtime imgs headers footers endnotes refArchive = "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml") ] ++ endnotesOverride ++ + endnotesOverride ++ map (\x -> (maybe "" (T.unpack . ("/word/" <>)) (extractTarget x), "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml")) headers ++ map (\x -> (maybe "" (T.unpack . ("/word/" <>)) (extractTarget x), @@ -755,6 +771,7 @@ mkStylesEntry epochtime styledoc styleMaps st opts = (\sty -> not $ hasStyleName sty $ smCharStyle styleMaps) (Set.toList $ stDynamicTextProps st) + -- TODO: add styles for endnotes, when Ext_endnotes is enabled -- TODO: add styles for endnotes, when Ext_endnotes is enabled newstyles = map newParaPropToOpenXml newDynamicParaProps ++ map newTextPropToOpenXml newDynamicTextProps ++ From fcaeb6ee5f68a7d3ba9caa7cc1a2858f10e383bb Mon Sep 17 00:00:00 2001 From: massifrg Date: Thu, 5 Mar 2026 11:12:36 +0100 Subject: [PATCH 05/10] DOCX writer: fix prev broken commit --- src/Text/Pandoc/Writers/Docx.hs | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/src/Text/Pandoc/Writers/Docx.hs b/src/Text/Pandoc/Writers/Docx.hs index a7aff82325b8..6f6a8f87e9a4 100644 --- a/src/Text/Pandoc/Writers/Docx.hs +++ b/src/Text/Pandoc/Writers/Docx.hs @@ -106,7 +106,6 @@ writeDocx opts doc = do -- Phase 5: Relationship extraction (baserels, headers, footers, newMaxRelId) <- extractRelationships opts refArchive distArchive - (baserels, headers, footers, newMaxRelId) <- extractRelationships opts refArchive distArchive let initialSt = defaultWriterState { stStyleMaps = styleMaps @@ -139,7 +138,6 @@ writeDocx opts doc = do -- e.g. deleting the reference to footnotes.xml or removing default entries -- for image content types. let contentTypesEntry = mkContentTypesEntry epochtime imgs headers footers endnotes refArchive - let contentTypesEntry = mkContentTypesEntry epochtime imgs headers footers endnotes refArchive let relEntry = mkDocumentRelsEntry epochtime baserels imgs (stExternalLinks st) let contentEntry = toEntry "word/document.xml" epochtime (BL.fromStrict $ UTF8.fromText contents) @@ -175,12 +173,6 @@ writeDocx opts doc = do ++ if (isEnabled Ext_endnotes opts) then [endnoteRelEntry, endnotesEntry] else [] - commentsEntry : docPropsEntry : customPropsEntry : settingsEntry : - imageEntries - ++ refEntries - ++ if (isEnabled Ext_endnotes opts) - then [endnoteRelEntry, endnotesEntry] - else [] return $ fromArchive archive newParaPropToOpenXml :: ParaStyleName -> Element @@ -537,10 +529,8 @@ extractPageLayout refArchive distArchive = do -- | Parse and augment relationships from reference.docx extractRelationships :: PandocMonad m - => WriterOptions -> Archive -> Archive => WriterOptions -> Archive -> Archive -> m ([Element], [Element], [Element], Int) -extractRelationships opts refArchive distArchive = do extractRelationships opts refArchive distArchive = do let isImageNode e = findAttr (QName "Type" Nothing Nothing) e == Just "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" let isHeaderNode e = findAttr (QName "Type" Nothing Nothing) e == Just "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" @@ -568,7 +558,6 @@ extractRelationships opts refArchive distArchive = do ,("Target",target')] () : rels) _ -> (maxId, rels) - let (newMaxRelId, baserels) = foldr addBaseRel (maxRelId, parsedRels) $ let (newMaxRelId, baserels) = foldr addBaseRel (maxRelId, parsedRels) $ [("http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering", "numbering.xml") @@ -588,8 +577,6 @@ extractRelationships opts refArchive distArchive = do "comments.xml") ] ++ [("http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes", "endnotes.xml") | isEnabled Ext_endnotes opts] - ] ++ [("http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes", - "endnotes.xml") | isEnabled Ext_endnotes opts] return (baserels, headers, footers, newMaxRelId) @@ -666,10 +653,8 @@ mkContentTypesEntry :: Integer -> [Element] -- headers -> [Element] -- footers -> [Element] -- endnotes - -> [Element] -- endnotes -> Archive -- refArchive -> Entry -mkContentTypesEntry epochtime imgs headers footers endnotes refArchive = mkContentTypesEntry epochtime imgs headers footers endnotes refArchive = let mkOverrideNode (part', contentType') = mknode "Override" [("PartName", T.pack part') @@ -680,8 +665,8 @@ mkContentTypesEntry epochtime imgs headers footers endnotes refArchive = mkMediaOverride imgpath = mkOverrideNode ("/" <> imgpath, getMimeTypeDef imgpath) endnotesOverride = if null endnotes - then map (\x -> (maybe "" (T.unpack . ("/word/" <>)) (extractTarget x), - "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml")) endnotes + then [("/word/endnotes.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml")] else [] overrides = map mkOverrideNode ( @@ -711,7 +696,6 @@ mkContentTypesEntry epochtime imgs headers footers endnotes refArchive = "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml") ] ++ endnotesOverride ++ - endnotesOverride ++ map (\x -> (maybe "" (T.unpack . ("/word/" <>)) (extractTarget x), "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml")) headers ++ map (\x -> (maybe "" (T.unpack . ("/word/" <>)) (extractTarget x), @@ -771,7 +755,6 @@ mkStylesEntry epochtime styledoc styleMaps st opts = (\sty -> not $ hasStyleName sty $ smCharStyle styleMaps) (Set.toList $ stDynamicTextProps st) - -- TODO: add styles for endnotes, when Ext_endnotes is enabled -- TODO: add styles for endnotes, when Ext_endnotes is enabled newstyles = map newParaPropToOpenXml newDynamicParaProps ++ map newTextPropToOpenXml newDynamicTextProps ++ From b85eb017a13bbb43a62444e4ee7373664141255e Mon Sep 17 00:00:00 2001 From: massifrg Date: Thu, 5 Mar 2026 12:23:08 +0100 Subject: [PATCH 06/10] DOCX writer: endnotes may have id,attrs When "endnotes" extension is enabled, the Span of class "endnote" that embeds a Note may have an id or attributes for the Note to become an endnote. The only condition is that there is ONLY ONE class and that must be "endnote". --- src/Text/Pandoc/Writers/Docx/OpenXML.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Text/Pandoc/Writers/Docx/OpenXML.hs b/src/Text/Pandoc/Writers/Docx/OpenXML.hs index 63e5029da611..1fc28e7ea32b 100644 --- a/src/Text/Pandoc/Writers/Docx/OpenXML.hs +++ b/src/Text/Pandoc/Writers/Docx/OpenXML.hs @@ -756,7 +756,7 @@ inlineToOpenXML' opts SoftBreak = inlineToOpenXML opts (Str " ") inlineToOpenXML' opts (Span ("",["mark"],[]) ils) = withTextProp (mknode "w:highlight" [("w:val","yellow")] ()) $ inlinesToOpenXML opts ils -inlineToOpenXML' opts (Span ("",["endnote"],[]) ils) = if isEnabled Ext_endnotes opts +inlineToOpenXML' opts (Span (_,["endnote"],_) ils) = if isEnabled Ext_endnotes opts then (do modify $ \s -> s { stInEndnote = isEnabled Ext_endnotes opts } endnote <- inlinesToOpenXML opts ils From 3d3d4c19138bd1e3e1e5ed331bbc17268c960313 Mon Sep 17 00:00:00 2001 From: massifrg Date: Thu, 5 Mar 2026 12:24:54 +0100 Subject: [PATCH 07/10] DOCX reader: support for endnotes When the "endotes" extension is enabled, docx endnotes are converted to a Note embedded in a Span of class "endnote". --- src/Text/Pandoc/Readers/Docx.hs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Text/Pandoc/Readers/Docx.hs b/src/Text/Pandoc/Readers/Docx.hs index da0cca1dc506..152c9584eaf9 100644 --- a/src/Text/Pandoc/Readers/Docx.hs +++ b/src/Text/Pandoc/Readers/Docx.hs @@ -52,7 +52,9 @@ implemented, [-] means partially implemented): - [X] Link (links to an arbitrary bookmark create a span with the target as id and "anchor" class) - [X] Image - - [X] Note (Footnotes and Endnotes are silently combined.) + - [X] Note (Footnotes and Endnotes are silently combined, + unless Ext_endnotes is enabled: in that case a Note is embedded + in a Span with class "endnote") -} module Text.Pandoc.Readers.Docx @@ -342,7 +344,12 @@ runToInlines (Run rs runElems) transform <- runStyleToTransform rPr return $ transform ils runToInlines (Footnote bps) = note . smushBlocks <$> mapM bodyPartToBlocks bps -runToInlines (Endnote bps) = note . smushBlocks <$> mapM bodyPartToBlocks bps +runToInlines (Endnote bps) = do + isEndnotesExtEnabled <- asks (isEnabled Ext_endnotes . docxOptions) + noteInlines <- note . smushBlocks <$> mapM bodyPartToBlocks bps + return $ if isEndnotesExtEnabled + then spanWith ("", ["endnote"], []) noteInlines + else noteInlines runToInlines (InlineDrawing fp title alt bs ext) = do (lift . lift) $ P.insertMedia fp Nothing bs return $ imageWith (extentToAttr ext) (T.pack fp) title $ text alt From f29a0d6c469fe0b12390fb66baac26d8ede67f32 Mon Sep 17 00:00:00 2001 From: massifrg Date: Mon, 9 Mar 2026 09:36:15 +0100 Subject: [PATCH 08/10] MANUAL: endnotes support in docx --- MANUAL.txt | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/MANUAL.txt b/MANUAL.txt index 716ffb199a28..88fa81b641f0 100644 --- a/MANUAL.txt +++ b/MANUAL.txt @@ -5923,6 +5923,32 @@ they cannot contain multiple paragraphs). The syntax is as follows: Inline and regular footnotes may be mixed freely. +## Endnotes + +You can use the following convention to specify endnotes: surround a +footnote with a `Span` of class "endnote", like this: + + Here' and endnote[[^1]]{.endnote}. + + [^1]: This is the endnote text. + +When a reader or a writer does not know about this convention, those +notes are just regular footnotes, just with a transparent `Span` wrapper +around them. + +### Extension: `endnotes` ### + +Enabling this extension tells some readers and writers to use that +convention to distinguish endnotes from footnotes. + +This extension is supported by the docx reader: when you convert +with `-f docx+endnotes`, the endnotes of the docx file will become +notes embedded in a span with class "endnote". + +It is also supported by the docx writer: when you convert with +`-t docx+endnotes`, all the notes embedded in a span of class "endnote" +will be endnotes in the docx file. + ## Citation syntax ### Extension: `citations` ### From 4325c01641e4f6aba05b6ae0603a73074b0304c4 Mon Sep 17 00:00:00 2001 From: massifrg Date: Mon, 9 Mar 2026 09:54:26 +0100 Subject: [PATCH 09/10] DOCX reader and writer: endnotes test --- test/command/11501.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 test/command/11501.md diff --git a/test/command/11501.md b/test/command/11501.md new file mode 100644 index 000000000000..7ac324c72651 --- /dev/null +++ b/test/command/11501.md @@ -0,0 +1,26 @@ +``` +% pandoc -f markdown -t docx+endnotes -o - | pandoc -f docx+endnotes -t markdown +First paragraph with an endnote[[^e1]]{.endnote} and a footnote[^1]. + +Second paragraph with a footnote[^2] and an endnote [[^e2]]{.endnote}. + +[^1]: First footnote. + +[^2]: Second footnote. + +[^e1]: First endnote. + +[^e2]: Second endnote. +^D +First paragraph with an endnote[[^1]]{.endnote} and a footnote[^2]. + +Second paragraph with a footnote[^3] and an endnote [[^4]]{.endnote}. + +[^1]: First endnote. + +[^2]: First footnote. + +[^3]: Second footnote. + +[^4]: Second endnote. +``` \ No newline at end of file From 467ed0669dad4d6c589d8221a0f1fb0f1f3a7c8f Mon Sep 17 00:00:00 2001 From: massifrg Date: Mon, 9 Mar 2026 21:23:39 +0100 Subject: [PATCH 10/10] DOCX writer: fix Span.endnote without Note An "endnote" class in a Span does not alter the usual behavior when its contents don't consist only of a Note. --- src/Text/Pandoc/Writers/Docx/OpenXML.hs | 12 +++++------- test/command/11501.md | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/Text/Pandoc/Writers/Docx/OpenXML.hs b/src/Text/Pandoc/Writers/Docx/OpenXML.hs index 1fc28e7ea32b..9d2be545a8ce 100644 --- a/src/Text/Pandoc/Writers/Docx/OpenXML.hs +++ b/src/Text/Pandoc/Writers/Docx/OpenXML.hs @@ -756,13 +756,11 @@ inlineToOpenXML' opts SoftBreak = inlineToOpenXML opts (Str " ") inlineToOpenXML' opts (Span ("",["mark"],[]) ils) = withTextProp (mknode "w:highlight" [("w:val","yellow")] ()) $ inlinesToOpenXML opts ils -inlineToOpenXML' opts (Span (_,["endnote"],_) ils) = if isEnabled Ext_endnotes opts - then (do - modify $ \s -> s { stInEndnote = isEnabled Ext_endnotes opts } - endnote <- inlinesToOpenXML opts ils - modify $ \s -> s { stInEndnote = False } - return endnote) - else inlinesToOpenXML opts ils +inlineToOpenXML' opts (Span (_,["endnote"],_) ils@([Note _])) | isEnabled Ext_endnotes opts = do + modify $ \s -> s { stInEndnote = isEnabled Ext_endnotes opts } + endnote <- inlinesToOpenXML opts ils + modify $ \s -> s { stInEndnote = False } + return endnote inlineToOpenXML' opts (Span ("",["csl-block"],[]) ils) = inlinesToOpenXML opts ils inlineToOpenXML' opts (Span ("",["csl-left-margin"],[]) ils) = diff --git a/test/command/11501.md b/test/command/11501.md index 7ac324c72651..d4cf53298328 100644 --- a/test/command/11501.md +++ b/test/command/11501.md @@ -23,4 +23,20 @@ Second paragraph with a footnote[^3] and an endnote [[^4]]{.endnote}. [^3]: Second footnote. [^4]: Second endnote. +``` +``` +% pandoc -f markdown -t docx+endnotes -o - | pandoc -f docx+styles+endnotes -t markdown --wrap=none +A paragraph with a [character style]{.endnote custom-style=myStyle} with an endnote class. +^D +::: {custom-style="First Paragraph"} +A paragraph with a [character style]{custom-style="myStyle"} with an endnote class. +::: +``` +``` +% pandoc -f markdown -t docx -o - | pandoc -f docx+styles -t markdown --wrap=none +A paragraph with a [character style]{.endnote custom-style=myStyle} with an endnote class. +^D +::: {custom-style="First Paragraph"} +A paragraph with a [character style]{custom-style="myStyle"} with an endnote class. +::: ``` \ No newline at end of file