Versions tested
superdoc 2.7.1 (@superdoc/docx-engine 0.6.1) and superdoc 2.9.0
(@superdoc/docx-engine 0.8.0). Bun 1.3, happy-dom 20.11.6 for the DOM.
Repro
- Build
cookbook.docx with the Python script at the bottom. It has a
bookmark recipe_pancakes whose bookmarkStart is in the first paragraph of the method and
whose bookmarkEnd is in the third, plus a bookmark note_tip whose start and end both sit
in one cell of the ingredients table.
bun add superdoc@2.9.0 happy-dom @happy-dom/global-registrator
- Save
probe.ts (below) next to it. It registers happy-dom, restores node's
File/Blob (happy-dom's are not structured-cloneable) and stubs getContext('2d') with a
measureText, then mounts new SuperDoc({selector, document}) and waits for onReady.
- It then calls
doc.blocks.list({includeText:true}),
doc.bookmarks.get({target:{kind:'entity',entityType:'bookmark',name}}) for both names, and
doc.find({select:{type:'node',nodeType:'bookmark'}}).
bun run probe.ts cookbook.docx
- Repeat with
superdoc@2.7.1 installed in a separate directory.
Observed
superdoc 2.9.0 | @superdoc/docx-engine 0.8.0
blocks.list() ids: ["00000001","00000002","00000003","00000004","00000005","00000006","tbl:00000007","0000000D"]
recipe_pancakes
range.from = 00000003 @ 0
range.to = 00000003 @ 63
from.blockId === to.blockId ? true
to.blockId in blocks.list() ? true
note_tip
range.from = 0000000C @ 0
range.to = 0000000C @ 11
from.blockId === to.blockId ? true
to.blockId in blocks.list() ? false
find({nodeType:"bookmark"}).total = 0 | bookmarks.list().total = 2
blocks.list({includeText:true}) reports 00000003 = "Whisk the flour, …" (63 chars),
00000004 = "Beat the eggs …", 00000005 = "Cook each pancake …" — so recipe_pancakes ends
exactly at the end of its first paragraph, and the two paragraphs it also covers, including
the one holding bookmarkEnd, fall outside the reported range.
Expected
range.to is the position of w:bookmarkEnd. For recipe_pancakes that is inside block
00000005, so from.blockId === to.blockId should be false here. Today range.to is the
end of the block holding bookmarkStart for every bookmark, which makes a multi-block range
indistinguishable from a single-paragraph one.
find({select:{type:'node',nodeType:'bookmark'}}) returns the bookmark nodes. nodeType: 'bookmark' type-checks and the document has 2 bookmarks that bookmarks.list() finds, but
find returns total: 0 with an empty items.
- A bookmark inside a table cell reports a
blockId (0000000C) that blocks.list() never
returns — the table appears as one block, tbl:00000007. Either cell paragraphs should be
addressable through blocks.list() (an option to descend into tables would do), or the docs
should say a blockId from bookmarks.get() may not be listable, so callers know they
cannot order such a bookmark against the rest of the document.
All three behaviours reproduce identically on 2.7.1 / engine 0.6.1 and on 2.9.0 / engine 0.8.0:
the output above is identical between the two apart from the version line.
Building the document
build_cookbook.py — pip install python-docx && python build_cookbook.py
from docx import Document
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
def bookmark_start(paragraph, bid, name):
el = OxmlElement("w:bookmarkStart")
el.set(qn("w:id"), str(bid)); el.set(qn("w:name"), name)
paragraph._p.insert(0, el)
def bookmark_end(paragraph, bid):
el = OxmlElement("w:bookmarkEnd")
el.set(qn("w:id"), str(bid))
paragraph._p.append(el)
doc = Document()
doc.add_heading("Grandma's Cookbook", level=0)
doc.add_heading("Buttermilk Pancakes", level=1)
p1 = doc.add_paragraph("Whisk the flour, sugar, baking powder and salt in a large bowl.")
p2 = doc.add_paragraph("Beat the eggs into the buttermilk, then pour that into the dry bowl.")
p3 = doc.add_paragraph("Cook each pancake until bubbles show, flip once, and serve warm.")
bookmark_start(p1, 1, "recipe_pancakes") # start in paragraph 1 …
bookmark_end(p3, 1) # … end in paragraph 3
doc.add_heading("Ingredients", level=1)
table = doc.add_table(rows=3, cols=2); table.style = "Table Grid"
rows = [("Ingredient", "Amount"), ("Buttermilk", "2 cups"), ("Baking powder", "2 teaspoons")]
for row, (a, b) in zip(table.rows, rows):
row.cells[0].paragraphs[0].add_run(a)
row.cells[1].paragraphs[0].add_run(b)
tip = table.rows[2].cells[1].paragraphs[0] # both delimiters inside one w:tc
bookmark_start(tip, 2, "note_tip")
bookmark_end(tip, 2)
doc.add_paragraph("Leftover batter keeps in the fridge for one day.")
doc.save("cookbook.docx")
probe.ts
// Boot SuperDoc headless and print what the document API says about two bookmarks.
// bun add superdoc happy-dom @happy-dom/global-registrator
// bun run probe.ts ../cookbook.docx
import { GlobalRegistrator } from '@happy-dom/global-registrator'
GlobalRegistrator.register()
import { Blob as NodeBlob, File as NodeFile } from 'node:buffer'
import { readFileSync } from 'node:fs'
// Shim 1: keep node's structured-cloneable File/Blob so the worker `postMessage` works.
;(globalThis as any).File = NodeFile
;(globalThis as any).Blob = NodeBlob
// Shim 2: happy-dom has no 2D canvas context; the layout pass needs measureText.
const proto = (globalThis as any).HTMLCanvasElement.prototype
proto.getContext = (kind: string) =>
kind !== '2d'
? null
: new Proxy(
{ font: '12px serif', measureText: (t: string) => ({ width: t.length * 6 }) } as any,
{ get: (t, k: string) => (k in t ? t[k] : () => undefined), set: (t, k: string, v) => ((t[k] = v), true) },
)
const { SuperDoc } = await import('superdoc')
const bytes = readFileSync(process.argv[2] ?? 'cookbook.docx')
const host = document.createElement('div')
host.style.height = '800px'
document.body.appendChild(host)
const sd: any = await new Promise((resolve, reject) => {
const i = new (SuperDoc as any)({
selector: host,
document: new (NodeFile as any)([bytes], 'cookbook.docx', {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
}),
telemetry: { enabled: false },
onReady: () => resolve(i),
onException: (p: any) => reject(new Error(JSON.stringify(p))),
})
})
const doc = sd.activeEditor.doc
const blocks = await doc.blocks.list({ includeText: true, limit: 500 })
const ids = blocks.blocks.map((b: any) => b.nodeId)
console.log('superdoc', require('superdoc/package.json').version,
'| @superdoc/docx-engine', require('@superdoc/docx-engine/package.json').version)
console.log('blocks.list() ids:', JSON.stringify(ids))
for (const name of ['recipe_pancakes', 'note_tip']) {
const b = await doc.bookmarks.get({ target: { kind: 'entity', entityType: 'bookmark', name } })
console.log(`\n${name}`)
console.log(' range.from =', b.range.from.blockId, '@', b.range.from.offset)
console.log(' range.to =', b.range.to.blockId, '@', b.range.to.offset)
console.log(' from.blockId === to.blockId ?', b.range.from.blockId === b.range.to.blockId)
console.log(' to.blockId in blocks.list() ?', ids.includes(b.range.to.blockId))
}
const found = await doc.find({ select: { type: 'node', nodeType: 'bookmark' } })
const listed = await doc.bookmarks.list({})
console.log('\nfind({nodeType:"bookmark"}).total =', found.total, '| bookmarks.list().total =', listed.total)
sd.destroy()
process.exit(0)
Versions tested
superdoc2.7.1 (@superdoc/docx-engine0.6.1) andsuperdoc2.9.0(
@superdoc/docx-engine0.8.0). Bun 1.3,happy-dom20.11.6 for the DOM.Repro
cookbook.docxwith the Python script at the bottom. It has abookmark
recipe_pancakeswhosebookmarkStartis in the first paragraph of the method andwhose
bookmarkEndis in the third, plus a bookmarknote_tipwhose start and end both sitin one cell of the ingredients table.
bun add superdoc@2.9.0 happy-dom @happy-dom/global-registratorprobe.ts(below) next to it. It registers happy-dom, restores node'sFile/Blob(happy-dom's are not structured-cloneable) and stubsgetContext('2d')with ameasureText, then mountsnew SuperDoc({selector, document})and waits foronReady.doc.blocks.list({includeText:true}),doc.bookmarks.get({target:{kind:'entity',entityType:'bookmark',name}})for both names, anddoc.find({select:{type:'node',nodeType:'bookmark'}}).bun run probe.ts cookbook.docxsuperdoc@2.7.1installed in a separate directory.Observed
blocks.list({includeText:true})reports00000003= "Whisk the flour, …" (63 chars),00000004= "Beat the eggs …",00000005= "Cook each pancake …" — sorecipe_pancakesendsexactly at the end of its first paragraph, and the two paragraphs it also covers, including
the one holding
bookmarkEnd, fall outside the reported range.Expected
range.tois the position ofw:bookmarkEnd. Forrecipe_pancakesthat is inside block00000005, sofrom.blockId === to.blockIdshould befalsehere. Todayrange.tois theend of the block holding
bookmarkStartfor every bookmark, which makes a multi-block rangeindistinguishable from a single-paragraph one.
find({select:{type:'node',nodeType:'bookmark'}})returns the bookmark nodes.nodeType: 'bookmark'type-checks and the document has 2 bookmarks thatbookmarks.list()finds, butfindreturnstotal: 0with an emptyitems.blockId(0000000C) thatblocks.list()neverreturns — the table appears as one block,
tbl:00000007. Either cell paragraphs should beaddressable through
blocks.list()(an option to descend into tables would do), or the docsshould say a
blockIdfrombookmarks.get()may not be listable, so callers know theycannot order such a bookmark against the rest of the document.
All three behaviours reproduce identically on 2.7.1 / engine 0.6.1 and on 2.9.0 / engine 0.8.0:
the output above is identical between the two apart from the version line.
Building the document
build_cookbook.py —
pip install python-docx && python build_cookbook.pyprobe.ts