Docx reader: preserve non-textbox content when unwrapping textboxes.

Previously, when a w:p paragraph contained runs with textboxes,
the entire paragraph was replaced by just the textbox content,
discarding all other runs (including image-bearing runs).
Now we walk the paragraph's children in order, grouping
non-textbox content into copies of the original w:p and splicing
unwrapped textbox content in place, preserving the original order.

We also treat text inside a textbox containing an image as a
figure caption.  (One often finds captioned images of this
kind in docx files.)

Closes #11510.
Closes #6893.
Closes #11412.
Closes #5394.
Closes #9633.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
John MacFarlane
2026-03-14 20:12:34 +00:00
co-authored by Claude
parent 8aa7a08574
commit c8ee9dc545
4 changed files with 108 additions and 5 deletions
+57 -5
View File
@@ -160,8 +160,10 @@ unwrapElement ns element
| isElem ns "w" "smartTag" element
= concatMap (unwrapElement ns) (elChildren element)
| isElem ns "w" "p" element
, textboxes@(_:_) <- findChildrenByName ns "w" "r" element >>= findTextboxes
= concatMap (unwrapElement ns) (concatMap elChildren textboxes) -- handle #9214, #11053
, _:_ <- findChildrenByName ns "w" "r" element >>= findTextboxes
= let result = splitP (elContent element) [] -- handle #9214, #11053
in [element{ elName = QName "textbox-image" Nothing Nothing
, elContent = map Elem result }]
| isElem ns "w" "r" element
, Just fallback <- findChildByName ns "mc" "AlternateContent" element >>=
findChildByName ns "mc" "Fallback"
@@ -169,6 +171,26 @@ unwrapElement ns element
| otherwise
= [element{ elContent = concatMap (unwrapContent ns) (elContent element) }]
where
-- Split a w:p's children around textbox-bearing runs, preserving order.
-- Non-textbox content is grouped into a copy of the original w:p;
-- textbox content is unwrapped into sibling elements in place.
splitP [] acc = [wrapP acc | hasContent acc]
splitP (c:cs) acc
| Elem el <- c
, isElem ns "w" "r" el
, tbs@(_:_) <- findTextboxes el
= [wrapP acc | hasContent acc]
++ concatMap (unwrapElement ns) (concatMap elChildren tbs)
++ splitP cs []
| otherwise
= splitP cs (acc ++ [c])
wrapP cs = element{ elContent = concatMap (unwrapContent ns) cs }
hasContent = any (\case Elem el -> isElem ns "w" "r" el
&& any (isContentElem . elName)
(elChildren el)
_ -> False)
isContentElem n = qName n `elem`
["t", "drawing", "pict", "br", "tab", "sym", "fldChar", "instrText"]
-- Search textbox content in the run's effective children.
-- If AlternateContent is present, use only the fallback branch,
-- matching the w:r unwrapping logic and avoiding duplicate textbox
@@ -475,9 +497,17 @@ archiveToDocument zf = do
return $ Document namespaces body
elemToBody :: NameSpaces -> Element -> D Body
elemToBody ns element | isElem ns "w" "body" element =
Body . addCaptioned <$> mapD (elemToBodyPart ns) (elChildren element)
elemToBody _ _ = throwError WrongElem
elemToBody ns element
| isElem ns "w" "body" element =
Body . addCaptioned <$> concatMapM (elemToBodyParts ns) (elChildren element)
| otherwise = throwError WrongElem
where
elemToBodyParts ns' el
| qName (elName el) == "textbox-image"
= addSplitGroupCaptioned . splitMixedParagraphs
<$> mapD (elemToBodyPart ns') (elChildren el)
| otherwise
= ((:[]) <$> elemToBodyPart ns' el) `catchError` (\_ -> return [])
archiveToStyles :: Archive -> (CharStyleMap, ParStyleMap)
archiveToStyles = archiveToStyles' getStyleId getStyleId
@@ -803,6 +833,28 @@ addCaptioned (x : Paragraph parstyle parparts : xs)
= Captioned parstyle parparts x : addCaptioned xs
addCaptioned (x:xs) = x : addCaptioned xs
-- Split paragraphs that contain both a Drawing and text into two body parts,
-- so that the captioning logic can combine them.
splitMixedParagraphs :: [BodyPart] -> [BodyPart]
splitMixedParagraphs [] = []
splitMixedParagraphs (Paragraph ps parts : rest)
| (drawings@(_:_), texts@(_:_)) <- partition isDrawingPart parts
= Paragraph ps drawings : Paragraph ps texts : splitMixedParagraphs rest
where isDrawingPart Drawing{} = True
isDrawingPart _ = False
splitMixedParagraphs (x:xs) = x : splitMixedParagraphs xs
-- For textbox-image groups: combine image + any adjacent text into Captioned
addSplitGroupCaptioned :: [BodyPart] -> [BodyPart]
addSplitGroupCaptioned [] = []
addSplitGroupCaptioned (Paragraph parstyle parparts : x : xs)
| isCaptionable x
= Captioned parstyle parparts x : addSplitGroupCaptioned xs
addSplitGroupCaptioned (x : Paragraph parstyle parparts : xs)
| isCaptionable x
= Captioned parstyle parparts x : addSplitGroupCaptioned xs
addSplitGroupCaptioned (x:xs) = x : addSplitGroupCaptioned xs
isCaptionable :: BodyPart -> Bool
isCaptionable (Paragraph _ [Drawing{}]) = True
isCaptionable (Tbl{}) = True
+7
View File
@@ -223,6 +223,10 @@ tests = [ testGroup "document"
"image inside textbox content with duplicate encoding"
"docx/textbox_image_duplicate_encoding.docx"
"docx/textbox_image_duplicate_encoding.native"
, testCompare
"image with textbox caption in same paragraph"
"docx/image_with_textbox_caption.docx"
"docx/image_with_textbox_caption.native"
]
, testGroup "blocks"
[ testCompare
@@ -487,6 +491,9 @@ tests = [ testGroup "document"
, testMediaBag
"image inside textbox content with duplicate encoding populates media bag"
"docx/textbox_image_duplicate_encoding.docx"
, testMediaBag
"image with textbox caption in same paragraph populates media bag"
"docx/image_with_textbox_caption.docx"
]
, testGroup "custom styles"
[ testCompare
Binary file not shown.
@@ -0,0 +1,44 @@
[ Figure
( "" , [] , [] )
(Caption
Nothing
[ Para
[ Str "1"
, Space
, Str "Daniel"
, Space
, Str "Schliebner:"
, Space
, Str "Cantor'sches"
, Space
, Str "Diagonalverfahren."
, Space
, Str "Von"
, Space
, Str "Mengen,"
, Space
, Str "Unendlichkeiten"
, Space
, Str "und"
, Space
, Str "Wahnsinn,"
, Space
, Str "Pdf:"
, Space
, Str
"https://www2.informatik.hu-berlin.de/~kossahl/Uni/Ma1/Cantor.pdf"
]
])
[ Plain
[ Image
( ""
, []
, [ ( "width" , "2.3680555555555554in" )
, ( "height" , "0.9340277777777778in" )
]
)
[]
( "media/image1.emf" , "" )
]
]
]