Skip to content

Bookmark reads: range.to stops at the start block, find() returns no bookmark nodes, and a table-cell blockId is not in blocks.list() #3973

Description

@ZacharyHampton

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

  1. 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.
  2. bun add superdoc@2.9.0 happy-dom @happy-dom/global-registrator
  3. 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.
  4. 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'}}).
  5. bun run probe.ts cookbook.docx
  6. 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

  1. 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.
  2. 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.
  3. 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)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions