Summary
On the v2 engine, a text-box shape (wps:wsp with txBox="1" → wps:txbx → w:txbxContent) is reported as a single image block with empty text. The paragraphs inside the text box never appear in doc.extract() and are not findable by doc.query.match(). The legacy super-editor engine returns them as ordinary content blocks.
Ordinary body paragraphs are unaffected — only content inside the shape is lost.
Two side effects made this hard to recognize at first, and both are visible in doc.info():
counts.lists drops. In our real template the section headings are the only paragraphs referencing one of the two numbering definitions, and they live inside these text boxes. With the text boxes unread, that definition has no referencing items and lists goes from 2 to 1. This looked like a numbering-parser bug until we found the real cause.
counts.images over-counts. Five text-box shapes are reported as images: 30.
Versions
|
|
| Works |
@superdoc-dev/sdk@1.21.1 (engine super-editor) |
| Fails |
@superdoc/sdk@2.8.0 (+ @superdoc/sdk-windows-x64@2.8.0) |
| Node |
v24.14.1 |
| Platform |
win32-x64 |
Not measured: the browser engine (superdoc / @superdoc/docx-engine). See the open question at the end.
Reproduction
The repro file is generated by the script below, so there is nothing to download and no third-party content involved. Save it as make-repro.py and run python3 make-repro.py.
It produces a .docx with five paragraphs inside text-box shapes, interleaved with ordinary body paragraphs, plus two numbering definitions (the text-box paragraphs use one, three ordinary body items use the other).
make-repro.py
#!/usr/bin/env python3
"""Build a minimal .docx where five paragraphs live INSIDE text-box shapes
(wps:wsp with txBox="1" -> wps:txbx -> w:txbxContent), interleaved with ordinary
body paragraphs. Every string is generic.
Run: python3 make-repro.py -> writes ./repro.docx
"""
import zipfile
NS = ' '.join([
'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"',
'xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"',
'xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"',
'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"',
'xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing"',
'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"',
'xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml"',
'mc:Ignorable="w14 wp14"',
])
def levels(fmt, text, indent):
out = []
for i in range(9):
f = fmt if i == 0 else 'decimal'
t = text if i == 0 else '%' + str(i + 1) + '.'
out.append(
'<w:lvl w:ilvl="' + str(i) + '"><w:start w:val="1"/>'
'<w:numFmt w:val="' + f + '"/>'
'<w:lvlText w:val="' + t + '"/><w:lvlJc w:val="left"/>'
'<w:pPr><w:ind w:left="' + str(indent + i * 360) + '" w:hanging="360"/></w:pPr>'
'</w:lvl>')
return ''.join(out)
def abstract_num(aid, nsid, tmpl, fmt, text, indent):
return ('<w:abstractNum w:abstractNumId="' + str(aid) + '">'
'<w:nsid w:val="' + nsid + '"/>'
'<w:multiLevelType w:val="hybridMultilevel"/>'
'<w:tmpl w:val="' + tmpl + '"/>'
+ levels(fmt, text, indent) +
'</w:abstractNum>')
def textbox_paragraph(shape_id, text):
"""A floating text box whose txbxContent holds one numbered paragraph."""
inner = ('<w:p><w:pPr><w:keepNext/>'
'<w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr>'
'<w:ind w:left="2268" w:hanging="708"/>'
'<w:rPr><w:b/><w:smallCaps/><w:color w:val="FFFFFF"/><w:sz w:val="24"/></w:rPr>'
'</w:pPr>'
'<w:r><w:rPr><w:b/><w:smallCaps/><w:color w:val="FFFFFF"/><w:sz w:val="24"/></w:rPr>'
'<w:t xml:space="preserve">' + text + '</w:t></w:r></w:p>')
return ('<w:p><w:r><mc:AlternateContent><mc:Choice Requires="wps"><w:drawing>'
'<wp:anchor distT="45720" distB="45720" distL="114300" distR="114300"'
' simplePos="0" relativeHeight="' + str(251658240 + shape_id) + '"'
' behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1">'
'<wp:simplePos x="0" y="0"/>'
'<wp:positionH relativeFrom="column"><wp:posOffset>0</wp:posOffset></wp:positionH>'
'<wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV>'
'<wp:extent cx="4210050" cy="257175"/>'
'<wp:effectExtent l="0" t="0" r="0" b="9525"/>'
'<wp:wrapTopAndBottom/>'
'<wp:docPr id="' + str(shape_id) + '" name="Text Box ' + str(shape_id) + '"/>'
'<wp:cNvGraphicFramePr><a:graphicFrameLocks/></wp:cNvGraphicFramePr>'
'<a:graphic><a:graphicData'
' uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">'
'<wps:wsp><wps:cNvSpPr txBox="1"><a:spLocks noChangeArrowheads="1"/></wps:cNvSpPr>'
'<wps:spPr bwMode="auto">'
'<a:xfrm><a:off x="0" y="0"/><a:ext cx="4210050" cy="257175"/></a:xfrm>'
'<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
'<a:solidFill><a:srgbClr val="404040"/></a:solidFill>'
'<a:ln w="9525"><a:noFill/><a:miter lim="800000"/><a:headEnd/><a:tailEnd/></a:ln>'
'</wps:spPr>'
'<wps:txbx><w:txbxContent>' + inner + '</w:txbxContent></wps:txbx>'
'<wps:bodyPr rot="0" vert="horz" wrap="square" lIns="91440" tIns="45720"'
' rIns="91440" bIns="45720" anchor="t" anchorCtr="0"><a:noAutofit/></wps:bodyPr>'
'</wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing>'
'</mc:Choice></mc:AlternateContent></w:r></w:p>')
def paragraph(text, num_id=None):
ppr = ''
if num_id is not None:
ppr = ('<w:pPr><w:numPr><w:ilvl w:val="0"/>'
'<w:numId w:val="' + str(num_id) + '"/></w:numPr></w:pPr>')
return '<w:p>' + ppr + '<w:r><w:t xml:space="preserve">' + text + '</w:t></w:r></w:p>'
SECTIONS = ['First Section', 'Second Section', 'Third Section',
'Fourth Section', 'Fifth Section']
body = [paragraph('Sample Document Title'),
paragraph('Plain introductory paragraph. This one is ordinary body text.')]
for offset, name in enumerate(SECTIONS):
body.append(textbox_paragraph(15 + offset, name))
body.append(paragraph('Ordinary body paragraph that follows ' + name + '.'))
for word in ('Alpha', 'Bravo', 'Charlie'):
body.append(paragraph('Numbered body item ' + word + '.', num_id=2))
DOCUMENT = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<w:document ' + NS + '><w:body>' + ''.join(body) +
'<w:sectPr><w:pgSz w:w="11906" w:h="16838"/>'
'<w:pgMar w:top="1417" w:right="1701" w:bottom="1417" w:left="1701"/>'
'</w:sectPr></w:body></w:document>')
NUMBERING = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<w:numbering ' + NS + '>'
+ abstract_num(0, '37730874', '75EC6C18', 'upperLetter', '(%1)', 1635)
+ abstract_num(1, '53650AA3', '47D881B8', 'decimal', '%1.', 720)
+ '<w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>'
+ '<w:num w:numId="2"><w:abstractNumId w:val="1"/></w:num>'
+ '</w:numbering>')
CONTENT_TYPES = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels"'
' ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-'
'officedocument.wordprocessingml.document.main+xml"/>'
'<Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-'
'officedocument.wordprocessingml.numbering+xml"/>'
'<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-'
'officedocument.wordprocessingml.styles+xml"/>'
'</Types>')
RELS = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/'
'relationships/officeDocument" Target="word/document.xml"/></Relationships>')
DOC_RELS = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/'
'relationships/numbering" Target="numbering.xml"/>'
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/'
'relationships/styles" Target="styles.xml"/></Relationships>')
STYLES = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<w:styles ' + NS + '><w:docDefaults><w:rPrDefault><w:rPr>'
'<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/><w:sz w:val="22"/>'
'</w:rPr></w:rPrDefault></w:docDefaults>'
'<w:style w:type="paragraph" w:default="1" w:styleId="Normal">'
'<w:name w:val="Normal"/></w:style></w:styles>')
PARTS = {
'[Content_Types].xml': CONTENT_TYPES,
'_rels/.rels': RELS,
'word/_rels/document.xml.rels': DOC_RELS,
'word/styles.xml': STYLES,
'word/numbering.xml': NUMBERING,
'word/document.xml': DOCUMENT,
}
with zipfile.ZipFile('repro.docx', 'w', zipfile.ZIP_DEFLATED) as archive:
for part_name, part_xml in PARTS.items():
archive.writestr(part_name, part_xml)
print('repro.docx written -', sum(len(v) for v in PARTS.values()), 'chars of XML')
Then run this against each SDK (change the import to compare):
import { createSuperDocClient } from '@superdoc/sdk' // or '@superdoc-dev/sdk'
const client = createSuperDocClient({ user: { name: 'repro' } })
await client.connect()
const doc = await client.open({ doc: 'repro.docx' })
const info = await doc.info({})
const { blocks } = await doc.extract({})
const HEADINGS = ['First Section', 'Second Section', 'Third Section', 'Fourth Section', 'Fifth Section']
// exact equality: only the paragraph INSIDE the text box has the heading as its whole text
const insideTextBox = HEADINGS.filter((h) => blocks.some((b) => (b.text ?? '').trim() === h))
const byType = {}
for (const b of blocks) byType[b.type] = (byType[b.type] ?? 0) + 1
console.log('lists =', info.counts.lists)
console.log('images =', info.counts.images)
console.log('chars =', info.counts.characters)
console.log('blocks =', JSON.stringify(byType))
console.log('text-box text found in extract:', insideTextBox.length + '/5')
const m = await doc.query.match({ select: { type: 'text', pattern: 'Fifth Section', mode: 'contains' } })
console.log('query.match "Fifth Section":', m.items.length) // 2 expected: text box + following paragraph
await doc.close()
await client.dispose()
Measured output
|
@superdoc-dev/sdk@1.21.1 |
@superdoc/sdk@2.8.0 |
info.counts.lists |
2 |
1 |
info.counts.images |
0 |
30 |
info.counts.characters |
507 |
416 |
extract() blocks by type |
paragraph: 7, listItem: 8 |
paragraph: 7, image: 5, listItem: 3 |
text-box text present in extract() |
5/5 |
0/5 |
| ordinary body paragraphs present |
5/5 |
5/5 |
| numbered body items present |
3/3 |
3/3 |
query.match("Fifth Section") |
2 |
1 |
The five image blocks in the v2 output each carry text: "".
On our real-world template (a document with ten such shapes) the same thing happens: lists reports 2 vs 1, images reports 1 vs 107, and the five section headings are absent from extract(), query.match(), getText(), getHtml(), getMarkdown(), get(), blocks.list(), lists.list(), sections.list() and contentControls.list(). Opening with { runtime: 'v1' } is rejected (runtime must be one of: v2), so there is no way to fall back.
Expected
Paragraphs inside w:txbxContent are returned as content blocks, as the super-editor engine does — or, at minimum, the image block that represents the shape carries the shape's text rather than an empty string.
Actual
The shape becomes one image block with text: "". Its inner paragraphs are unreachable through every read path we tried.
What is NOT affected (measured)
Writing is clean, which is worth stating because it bounds the problem to the read surface:
open() → save() with no edits, and open() → replace() on an ordinary paragraph → save(), both preserve word/numbering.xml intact (all w:abstractNum and w:num entries), all w:txbxContent parts, all text-box text in word/document.xml, and all w:numId references.
- Verified both on
repro.docx (2 abstractNum / 2 num / 5 txbxContent / 5 headings in, same out) and on our real template (33 abstractNum / 33 num / 17 numId references in, same out).
So the content is preserved on disk; it is only invisible to the reading API. Output is ~5–19% larger in bytes after a round-trip, which we have not investigated.
Impact
We build a numbered-line projection of the whole document and send it to an LLM to answer structural questions over it ("is there an assumption with no matching scope?"). The section headings are what tell the model where one section ends and the next begins. With the text boxes unread, every body paragraph is still there but the structure that organizes them is gone.
Banner-style headings in text boxes are common in law-firm templates, so this is not an exotic layout for our domain.
Open question
Does the browser engine (superdoc → @superdoc/docx-engine) behave the same way? We measured the Node SDK only. It matters to us because the same projection is built in the browser via editor.doc.extract(), and because a document saved from the editor goes through export() rather than the SDK's save().
Summary
On the v2 engine, a text-box shape (
wps:wspwithtxBox="1"→wps:txbx→w:txbxContent) is reported as a singleimageblock with empty text. The paragraphs inside the text box never appear indoc.extract()and are not findable bydoc.query.match(). The legacysuper-editorengine returns them as ordinary content blocks.Ordinary body paragraphs are unaffected — only content inside the shape is lost.
Two side effects made this hard to recognize at first, and both are visible in
doc.info():counts.listsdrops. In our real template the section headings are the only paragraphs referencing one of the two numbering definitions, and they live inside these text boxes. With the text boxes unread, that definition has no referencing items andlistsgoes from2to1. This looked like a numbering-parser bug until we found the real cause.counts.imagesover-counts. Five text-box shapes are reported asimages: 30.Versions
@superdoc-dev/sdk@1.21.1(enginesuper-editor)@superdoc/sdk@2.8.0(+@superdoc/sdk-windows-x64@2.8.0)Not measured: the browser engine (
superdoc/@superdoc/docx-engine). See the open question at the end.Reproduction
The repro file is generated by the script below, so there is nothing to download and no third-party content involved. Save it as
make-repro.pyand runpython3 make-repro.py.It produces a
.docxwith five paragraphs inside text-box shapes, interleaved with ordinary body paragraphs, plus two numbering definitions (the text-box paragraphs use one, three ordinary body items use the other).make-repro.pyThen run this against each SDK (change the import to compare):
Measured output
@superdoc-dev/sdk@1.21.1@superdoc/sdk@2.8.0info.counts.listsinfo.counts.imagesinfo.counts.charactersextract()blocks by typeparagraph: 7, listItem: 8paragraph: 7, image: 5, listItem: 3extract()query.match("Fifth Section")The five
imageblocks in the v2 output each carrytext: "".On our real-world template (a document with ten such shapes) the same thing happens:
listsreports2vs1,imagesreports1vs107, and the five section headings are absent fromextract(),query.match(),getText(),getHtml(),getMarkdown(),get(),blocks.list(),lists.list(),sections.list()andcontentControls.list(). Opening with{ runtime: 'v1' }is rejected (runtime must be one of: v2), so there is no way to fall back.Expected
Paragraphs inside
w:txbxContentare returned as content blocks, as thesuper-editorengine does — or, at minimum, theimageblock that represents the shape carries the shape's text rather than an empty string.Actual
The shape becomes one
imageblock withtext: "". Its inner paragraphs are unreachable through every read path we tried.What is NOT affected (measured)
Writing is clean, which is worth stating because it bounds the problem to the read surface:
open()→save()with no edits, andopen()→replace()on an ordinary paragraph →save(), both preserveword/numbering.xmlintact (allw:abstractNumandw:numentries), allw:txbxContentparts, all text-box text inword/document.xml, and allw:numIdreferences.repro.docx(2abstractNum/ 2num/ 5txbxContent/ 5 headings in, same out) and on our real template (33abstractNum/ 33num/ 17numIdreferences in, same out).So the content is preserved on disk; it is only invisible to the reading API. Output is ~5–19% larger in bytes after a round-trip, which we have not investigated.
Impact
We build a numbered-line projection of the whole document and send it to an LLM to answer structural questions over it ("is there an assumption with no matching scope?"). The section headings are what tell the model where one section ends and the next begins. With the text boxes unread, every body paragraph is still there but the structure that organizes them is gone.
Banner-style headings in text boxes are common in law-firm templates, so this is not an exotic layout for our domain.
Open question
Does the browser engine (
superdoc→@superdoc/docx-engine) behave the same way? We measured the Node SDK only. It matters to us because the same projection is built in the browser viaeditor.doc.extract(), and because a document saved from the editor goes throughexport()rather than the SDK'ssave().