Skip to content

Footnote reserve cap leaves one line of body on four pages; with an unbreakable anchor page the reserve loop diverges and all four notes are placed on no page #4006

Description

@ZacharyHampton

Versions tested

superdoc 2.13.0 (@superdoc/docx-engine 0.12.0). Bun 1.3.13, happy-dom 20.14.5 for
the DOM.

Repro

  1. Build fieldguide.docx with the Python script at the bottom. It is a short field
    guide — eleven pages once laid out, because of the effect below. One page near the
    front carries a single short footnote (the control). A later
    page carries four footnotes in one paragraph, and each of those four note bodies is
    a numbered list of 44 items at 10 pt — roughly 700 px of note text apiece, so the four
    together demand about three times what one page's note band can hold. Numbering every
    line means "which lines of a note reached a page" is an exact set comparison rather
    than a judgement about pixels.
  2. python build_fieldguide.py --rigid writes a second fixture, fieldguide-rigid.docx,
    identical except that the paragraph holding the four references sits inside a
    single-row table marked w:cantSplit, so the body on the anchor page cannot be moved
    or shrunk to make room for the band.
  3. bun add superdoc@2.13.0 happy-dom @happy-dom/global-registrator
  4. Save probe.ts (below) next to them. It registers happy-dom, restores node's
    File/Blob (happy-dom's are not structured-cloneable), stubs getContext('2d') with
    a measureText whose width scales with the font size, and reports a tall viewport with
    rects derived from inline style — happy-dom has no layout, so every client rect is zero
    and a viewport-windowed painter otherwise only paints the pages it thinks straddle
    y = 0.
  5. It then mounts new SuperDoc({selector, document}), waits for onReady, settles on a
    quiet window of pagination-update, and reports per page: the band reserve from
    editor.pageMetrics.getSnapshot() (base.marginBottomPx minus the section's own 96 px
    bottom margin), the note slices painted on that page, and which of them continue onto
    the next. It also collects every [layout] / [incrementalLayout] console warning and
    checks each note's 44 markers against what was painted anywhere in the document.
  6. bun run probe.ts fieldguide.docx
  7. bun run probe.ts fieldguide-rigid.docx

Observed

1. The cap fires, and the capped band takes essentially the whole body area

bun run probe.ts fieldguide.docx, pages 3-6 (the anchor page is 3):

superdoc 2.13.0 | @superdoc/docx-engine 0.12.0

pages: 11

  page 3
    body fragments painted: 2
    footnote reserve px   : 836
    capped (warning)      : false
    note slices placed    : ["2","3"]
    slice heights px      : [715,98]
    continues to page 4  : ["3"]
  page 4
    body fragments painted: 2
    footnote reserve px   : 838
    capped (warning)      : false
    note slices placed    : ["3","4","5"]
    slice heights px      : [114,683,16]
    continues to page 5  : ["3","5"]
  page 5
    body fragments painted: 2
    footnote reserve px   : 836
    capped (warning)      : true
    note slices placed    : ["3","5"]
    slice heights px      : [471,341]
    continues to page 6  : ["5"]
  page 6
    body fragments painted: 3
    footnote reserve px   : 347
    capped (warning)      : true
    note slices placed    : ["5"]
    slice heights px      : [325]
    continues to page 7  : []

captured [layout] diagnostics:
  warn: [layout] Footnote reserve capped to preserve body area {"pages":[5,6]}
  warn: [layout] Footnote content truncated: extends beyond document pages {"ids":["4","5"]}

The page box is 1056 px with 96 px margins, so the body box is 864 px. Pages 3, 4 and 5
reserve 836, 838 and 836 px of it for notes, leaving 26-28 px of body — about one line
of text. Four consecutive pages of the document are therefore a note band with a single
line of body above it. Word caps the note area well short of this and keeps the body
flowing; a page whose body has shrunk to one line is not a layout a reader can use.

Worth separating from that: on this fixture the note content is not lost. Every line of
every note is painted, in order, across the pages the band spreads over —

marked-line coverage per note (44 items authored in each):
  note 2: painted on pages [3]
    items painted : 44/44
    items missing : 0
  note 3: painted on pages [3,4,5]
    items painted : 44/44
    items missing : 0
  note 4: painted on pages [4]
    items painted : 44/44
    items missing : 0
  note 5: painted on pages [4,5,6]
    items painted : 44/44
    items missing : 0

reading order of each note across its slices:
  note 2: p3:1-44   ascending across pages? true
  note 3: p3:1-5  p4:6-13  p5:14-44   ascending across pages? true
  note 4: p4:1-44   ascending across pages? true
  note 5: p4:lead  p5:1-23  p6:24-44   ascending across pages? true

— so continuation itself works. Note also that the
Footnote content truncated: extends beyond document pages warning names ids 4 and 5,
both of which are fully painted: the warning set is deduplicated for the life of the
process, so it reports an intermediate pagination pass and does not describe the layout
that was finally painted. Pages 7-10 carry footnote reserve px : 0 and no slices, so
there was spare room below throughout.

2. With a body the anchor page cannot yield, the reserve loop diverges and the notes are placed on no page at all

bun run probe.ts fieldguide-rigid.docx — same content, four references inside one
w:cantSplit table row:

superdoc 2.13.0 | @superdoc/docx-engine 0.12.0

pages: 34

  page 0  bodyFrags=6  reserve=0    capped=false slices=[]
  page 1  bodyFrags=7  reserve=38   capped=false slices=["1"]
  page 2  bodyFrags=1  reserve=844  capped=false slices=[]
  page 3  bodyFrags=2  reserve=844  capped=false slices=[]
  page 4  bodyFrags=2  reserve=844  capped=false slices=[]
  page 5  bodyFrags=2  reserve=844  capped=true  slices=[]
  page 6  bodyFrags=2  reserve=844  capped=true  slices=[]
  page 7  bodyFrags=2  reserve=844  capped=false slices=[]
  page 8  bodyFrags=2  reserve=844  capped=false slices=[]
  page 9  bodyFrags=2  reserve=844  capped=false slices=[]
  page 10 bodyFrags=2  reserve=844  capped=true  slices=[]
  page 11 bodyFrags=2  reserve=844  capped=true  slices=[]
  page 12 bodyFrags=2  reserve=844  capped=false slices=[]
  ...   pages 13-29 identical: reserve 844 (347 on 29), no slices, no body fragments
  page 30 bodyFrags=0  reserve=0    capped=false slices=[]
  page 33 bodyFrags=0  reserve=0    capped=false slices=[]

captured [layout] diagnostics:
  warn: [layout] Footnote reserve capped to preserve body area {"pages":[5,6]}
  warn: [layout] Footnote content truncated: extends beyond document pages {"ids":["4","5"]}
  warn: [layout] Footnote reserve capped to preserve body area {"pages":[10,11]}
  warn: [incrementalLayout] Footnote reserve loop did not converge (max 4 passes); layout may have suboptimal footnote placement.
  warn: [layout] Footnote reserve capped to preserve body area {"pages":[15,16]}
  warn: [layout] Footnote reserve capped to preserve body area {"pages":[20,21]}
  warn: [layout] Footnote reserve capped to preserve body area {"pages":[25,26]}
  warn: [layout] Footnote reserve capped to preserve body area {"pages":[28,29,31]}
  warn: [layout] Footnote content truncated: extends beyond document pages {"ids":["5"]}
  warn: [layout] Footnote reserve capped to preserve body area {"pages":[25]}

marked-line coverage per note (44 items authored in each):
  note 2: painted on pages []
    items painted : 0/44
    items missing : 44 -> 1..44
  note 3: painted on pages []
    items painted : 0/44
    items missing : 44 -> 1..44
  note 4: painted on pages []
    items painted : 0/44
    items missing : 44 -> 1..44
  note 5: painted on pages []
    items painted : 0/44
    items missing : 44 -> 1..44

Three things there:

  • The document inflates from 11 pages to 34, of which pages 2 through 29 each reserve
    844 px of the 864 px body box and paint no note slice at all. Pages 2-12 do paint
    body fragments, so those pages were rendered and the engine placed nothing in their
    bands — this is not the harness failing to paint them. (Pages 13-29 paint nothing at
    all, so I can only speak to their reserves, which the public snapshot reports.)
  • Not one line of the four notes is painted anywhere in the document: 0 of 44 markers for
    each of notes 2, 3, 4 and 5. The reader loses all four notes, and the short control
    footnote on page 1 still renders, so notes are not switched off wholesale.
  • [incrementalLayout] Footnote reserve loop did not converge (max 4 passes) fires, and
    the repeated reserve capped warnings at pages 5/6, 10/11, 15/16, 20/21, 25/26 — a
    five-page stride — look like successive passes each pushing the same unplaceable cluster
    one band further down.

3. capped and overflowHeightPx cannot be read from any public surface

FootnotePagePlan.capped and FootnotePagePlan.overflowHeightPx are the two facts that
say a page's band was cut short, and they live only on the internal plan — the bridge does
not copy them onto the Page.footnoteLedger it writes, and that ledger is itself
unreachable:

Page.footnoteLedger reachable from the public surface: no

FootnotePageLedger is declared in the shipped typings
(dist/layout-engine/contracts/src/index.d.ts, and Page.footnoteLedger alongside it) but
nothing exported reaches a Page. SuperDocPaginationUpdatePayload carries only
totalPages. The nearest public signal is
editor.pageMetrics.getSnapshot().pages[i].base.marginBottomPx, which is the section
margin plus the reserve added together and so cannot distinguish a capped band from an
uncapped one. That leaves a host application parsing console.warn to find out that a page
of a user's document was laid out short.

Expected

  1. A capped footnote band leaves the body a usable page. Reserving 836 of 864 px, so one
    line of body survives on each of four consecutive pages, is not "preserving body area";
    Word holds the note area well under the page and lets the body keep flowing. If the
    ceiling is deliberate, it should be a documented option on the SuperDoc config (a
    maximum band fraction, say) rather than an internal constant a host learns about from a
    warning.
  2. Footnote content is never dropped. In the --rigid fixture all four notes are placed on
    no page while 28 pages each hold an 844 px empty band — the content should continue onto
    following pages with the continuation separator (the fixture's word/footnotes.xml
    supplies one), and the engine demonstrably can do this: the non-rigid fixture spreads
    note 3 over pages 3-5 and note 5 over pages 4-6 in correct order. An unbreakable body
    block on the anchor page should not be able to turn that into total loss.
  3. The reserve loop converges, or the document does not grow. 11 pages becoming 34, with
    Footnote reserve loop did not converge (max 4 passes), is a page count no author asked
    for. If the loop gives up, it should fall back to a layout that still paints the notes
    rather than one that paints none of them.
  4. capped and overflowHeightPx are readable. Either copy them onto
    Page.footnoteLedger and export a way to read a Page, or put them on the
    pagination-update payload. A host that renders a user's document needs to know a page
    was laid out short without scraping console.warn, and today there is no such door.
  5. Footnote content truncated: extends beyond document pages should describe the layout
    that was painted. Its dedupe set persists for the life of the process, so on the
    non-rigid fixture it names two notes that are in fact fully painted — which makes it
    useless as a signal for the case where content really is gone.

Notes on the harness

happy-dom has no layout engine, so this needs two stubs beyond the usual: a measureText
whose width scales with font size (a flat width makes every note the same height and the
cap never fires), and getBoundingClientRect / clientHeight reporting a tall viewport,
without which only the pages straddling y = 0 are painted and the note band is invisible
to the probe. Reserves come from editor.pageMetrics.getSnapshot(), which is real public
API and needs no stub, so the page-by-page reserve table above holds regardless of what
the painter did.

Building the document

build_fieldguide.py — pip install python-docx && python build_fieldguide.py [--rigid]
"""Build fieldguide.docx: a tide-pool field guide with one footnote-heavy page.

    pip install python-docx
    python build_fieldguide.py            -> fieldguide.docx
    python build_fieldguide.py --rigid    -> fieldguide-rigid.docx

Page 2 carries one short footnote (the control). Page 3 carries four footnotes
whose bodies are 8-10 lines each at 10 pt, so the note band they demand is
larger than the page can give it. python-docx cannot author footnotes, so the
w:footnoteReference runs go in through the oxml layer and word/footnotes.xml
(with the separator / continuationSeparator pair Word writes) is spliced into
the package afterwards.

--rigid puts the four references inside a single-row table marked w:cantSplit,
so the body on the anchor page cannot be moved or shrunk to make room for the
note band.
"""

import re
import sys
import zipfile
from pathlib import Path

from docx import Document
from docx.enum.style import WD_STYLE_TYPE
from docx.enum.text import WD_BREAK
from docx.oxml import OxmlElement
from docx.oxml.ns import nsmap, qn
from docx.shared import Pt

W = nsmap["w"]
RIGID = "--rigid" in sys.argv
OUT = Path("fieldguide-rigid.docx" if RIGID else "fieldguide.docx")
STAGE = Path("build.stage.docx")

# --- content -----------------------------------------------------------------

BODY = [
    "Tide pools are the shallow basins left behind on a rocky shore when the sea "
    "pulls back. Each one is a small, complete world, and each one is refilled and "
    "emptied twice a day. This guide walks a single stretch of coast from the spray "
    "zone at the top of the rocks down to the kelp fringe that the water never "
    "fully leaves.",
    "Start at the highest band, where the rock is dry to the touch and pale with "
    "salt. Only a handful of animals live here, and all of them are built to wait. "
    "Periwinkles seal themselves to the stone with a ring of dried mucus. Limpets "
    "clamp down over a scar they have ground into the rock over years, fitting it "
    "so closely that a knife blade will not slide under the shell.",
    "The next band down is the barnacle belt, and it is the easiest band to find "
    "because it is the loudest. On a falling tide the barnacles click as their "
    "plates close. Each animal is a crustacean standing on its head inside a cone "
    "of chalk, kicking food out of the water with six pairs of feathery legs.",
    "Below the barnacles the rock turns slick with algae, and the first real pools "
    "appear. Look for the small ones first. A pool the size of a dinner plate warms "
    "quickly in the sun, and by midday its water may be several degrees above the "
    "sea and noticeably saltier.",
    "Mussels arrive in beds rather than as individuals. A mussel bed is a built "
    "thing: each animal is tied to its neighbours by tough threads, and the mat "
    "they make together traps sand, shell and drifting weed until a layer of soil "
    "forms between the shells.",
    "Sea stars are the reason the mussel beds have a lower edge at all. A star can "
    "open a mussel by gripping both halves of the shell and pulling, steadily, for "
    "hours, and it needs only a gap of half a millimetre to push its stomach "
    "through.",
    "Anemones look like flowers and hunt like nets. The column is anchored to the "
    "rock, the crown of tentacles is spread into the current, and every tentacle "
    "carries thousands of coiled stinging threads that fire on contact.",
    "In the lowest band the water rarely leaves, and the crowding is extraordinary. "
    "Coralline algae coat the rock in pink crusts hard enough to scratch glass. "
    "Kelp stipes rise from holdfasts the size of a fist.",
    "Go down with the tide and come up ahead of it. Read the tide table for the day "
    "before you leave, and give yourself an hour of margin at the bottom of the "
    "range.",
    "Put every rock back the way you found it. A turned rock is a roof taken off a "
    "house, and the animals underneath it are the ones least able to move to "
    "another.",
    "Keep your hands in the water when you handle anything, and keep them wet. A "
    "dry palm pulls the mucus off a snail and the film off a fish.",
    "Finally, write down what you see. A pool visited once is a curiosity; a pool "
    "visited monthly for a year is a record, and the shore changes more than a "
    "single visit will ever show you.",
]

# Four long note bodies. Each is a numbered list of observations, so every line
# of every note carries a unique "(n.NN)" marker: the probe can then check which
# lines of a note reached the page and which never appeared anywhere. 44 items
# apiece is roughly 55 lines at 10 pt over a 6.5 in measure, or about 900 px,
# which is more than any one page's note band can hold.
NOTE_TOPICS = [
    ("band heights", [
        "the top of a band is set by how long the animal survives out of water",
        "the bottom of a band is set by what eats the animal",
        "a transplant moved down one foot is cleared off the rock within a season",
        "a transplant moved up one foot dries out during the first long summer low",
        "record the top and bottom of the band against a fixed mark on the rock",
        "pacing the height out from the waterline gives a different answer each visit",
        "a species list records neither edge and cannot be compared between headlands",
        "photograph the band with a levelling staff in the frame for later checking",
        "note the date and the predicted low, because the staff reads against the water",
        "two observers on the same rock differ by less than a hand's width once a mark is fixed",
        "the stripes are regular enough to read tidal range off a photograph",
    ]),
    ("pool water", [
        "a shallow pool loses water to evaporation and its salinity climbs by afternoon",
        "the same pool reads below the open sea after heavy rain",
        "fresh water arriving from the rock above floats on the denser sea water",
        "a pool can be brackish at the surface and fully marine two inches down",
        "an animal on the bottom experiences a different day from one on the rim",
        "sample at a recorded depth or the reading cannot be compared with anyone else's",
        "note the time against the tide, not against the clock alone",
        "note whether it has rained in the previous day",
        "a hand-held refractometer needs rinsing in the pool before each reading",
        "temperature and salinity must be read together or neither means much",
        "two observers disagree most often because they drew water from different depths",
    ]),
    ("crab names", [
        "carapace colour is close to useless for identification",
        "one species runs from near-white through mottled green to deep red-brown",
        "colour depends on diet and on how long ago the animal moulted",
        "count the teeth on the front edge of the carapace between the eye sockets",
        "the last pair of legs is flattened into paddles in the swimming crabs",
        "the last pair of legs is pointed in the shore crabs",
        "a narrow triangular abdomen and a broad domed one separate the sexes",
        "recording two sexes as two species is the commonest error in a beginner's list",
        "hold a clear tub of sea water above the animal and photograph upward",
        "the photograph lets a second observer check the identification later",
        "none of these characters need the animal taken out of the water",
    ]),
    ("star sizes", [
        "a wasting event removed most of the largest individuals within a single year",
        "mussel beds then expanded downward across rock that had been kept clear",
        "recruitment since has been heavy in places and absent in others",
        "there is no obvious pattern along the shore",
        "a raw count of stars per square metre carries almost no information alone",
        "record the arm span of each animal to the nearest centimetre",
        "twenty small stars and three large ones are different points in one recovery",
        "the two pools will do different things to the mussel bed below them",
        "size structure is the measurement and abundance alone is not",
        "measure arm span from the centre of the disc to the tip of the longest arm",
        "a star with a regenerating arm is recorded at its longest intact arm",
    ]),
]


def build_long_note(index, topic, lines, items=44):
    """One note body: a lead sentence plus `items` uniquely marked observations."""
    parts = [
        f"Observations on {topic}, numbered so that a later reader can cite a "
        f"single line of this note rather than the whole of it."
    ]
    for n in range(items):
        parts.append(f"({index}.{n + 1:02d}) {lines[n % len(lines)].capitalize()}.")
    return " ".join(parts)


LONG_NOTES = [
    build_long_note(i + 2, topic, lines) for i, (topic, lines) in enumerate(NOTE_TOPICS)
]

SHORT_NOTE = (
    "Barnacle plate counts are read on the top of the cone, not the side."
)

FIRST_ID = 1  # content footnotes are 1..N; -1 = separator, 0 = continuation


def add_footnote_ref(paragraph, fid):
    """Append a superscript w:footnoteReference run to a paragraph."""
    run = paragraph.add_run()
    rPr = OxmlElement("w:rPr")
    style = OxmlElement("w:rStyle")
    style.set(qn("w:val"), "FootnoteReference")
    rPr.append(style)
    valign = OxmlElement("w:vertAlign")
    valign.set(qn("w:val"), "superscript")
    rPr.append(valign)
    run._r.append(rPr)
    ref = OxmlElement("w:footnoteReference")
    ref.set(qn("w:id"), str(fid))
    run._r.append(ref)


def register_styles(doc):
    ref = doc.styles.add_style("Footnote Reference", WD_STYLE_TYPE.CHARACTER)
    ref.font.superscript = True
    text = doc.styles.add_style("Footnote Text", WD_STYLE_TYPE.PARAGRAPH)
    text.font.size = Pt(10)
    text.paragraph_format.space_after = Pt(0)


def add_footnote_pr(doc):
    sect = doc.sections[0]._sectPr
    pr = OxmlElement("w:footnotePr")
    for tag, val in (("w:pos", "pageBottom"), ("w:numFmt", "decimal"),
                     ("w:numStart", "1"), ("w:numRestart", "continuous")):
        el = OxmlElement(tag)
        el.set(qn("w:val"), val)
        pr.append(el)
    sect.insert(0, pr)


def page_break(doc):
    doc.add_paragraph().add_run().add_break(WD_BREAK.PAGE)


def footnotes_xml(notes):
    """word/footnotes.xml with Word's separator / continuationSeparator pair."""
    def sep(fid, kind, mark):
        return (
            f'<w:footnote w:type="{kind}" w:id="{fid}">'
            '<w:p><w:pPr><w:spacing w:after="0" w:line="240" w:lineRule="auto"/>'
            '</w:pPr><w:r>'
            f"{mark}"
            "</w:r></w:p></w:footnote>"
        )

    parts = [
        '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
        f'<w:footnotes xmlns:w="{W}">',
        sep(-1, "separator", "<w:separator/>"),
        sep(0, "continuationSeparator", "<w:continuationSeparator/>"),
    ]
    for fid, body in notes:
        parts.append(
            f'<w:footnote w:id="{fid}">'
            '<w:p><w:pPr><w:pStyle w:val="FootnoteText"/>'
            '<w:spacing w:after="0" w:line="240" w:lineRule="auto"/></w:pPr>'
            '<w:r><w:rPr><w:rStyle w:val="FootnoteReference"/>'
            '<w:vertAlign w:val="superscript"/><w:sz w:val="20"/></w:rPr>'
            "<w:footnoteRef/></w:r>"
            '<w:r><w:rPr><w:sz w:val="20"/><w:szCs w:val="20"/></w:rPr>'
            f'<w:t xml:space="preserve"> {body}</w:t></w:r>'
            "</w:p></w:footnote>"
        )
    parts.append("</w:footnotes>")
    return "".join(parts)


def splice_footnotes_part(stage, out, xml):
    """Copy the package, adding word/footnotes.xml + its rel + content type."""
    src = zipfile.ZipFile(stage)
    names = src.namelist()
    ct = src.read("[Content_Types].xml").decode("utf8")
    rels = src.read("word/_rels/document.xml.rels").decode("utf8")

    ct = ct.replace(
        "</Types>",
        '<Override PartName="/word/footnotes.xml" ContentType="application/vnd'
        ".openxmlformats-officedocument.wordprocessingml.footnotes+xml\"/></Types>",
    )
    used = [int(n) for n in re.findall(r'Id="rId(\d+)"', rels)]
    rid = "rId%d" % (max(used) + 1)
    rels = rels.replace(
        "</Relationships>",
        f'<Relationship Id="{rid}" Type="http://schemas.openxmlformats.org/'
        'officeDocument/2006/relationships/footnotes" Target="footnotes.xml"/>'
        "</Relationships>",
    )

    with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as dst:
        for name in names:
            if name == "[Content_Types].xml":
                dst.writestr(name, ct)
            elif name == "word/_rels/document.xml.rels":
                dst.writestr(name, rels)
            else:
                dst.writestr(name, src.read(name))
        dst.writestr("word/footnotes.xml", xml)
    src.close()


def main():
    doc = Document()
    register_styles(doc)
    add_footnote_pr(doc)
    notes = []

    doc.add_heading("A Field Guide to Coastal Tide Pools", level=0)
    doc.add_heading("Reading the shore from the spray zone down", level=1)
    for text in BODY[:4]:
        doc.add_paragraph(text)

    # --- control page: exactly one short footnote -----------------------------
    page_break(doc)
    doc.add_heading("The barnacle belt", level=1)
    for text in BODY[4:6]:
        doc.add_paragraph(text)
    control = doc.add_paragraph(
        "Barnacle cover is the quickest measurement on the shore, and a plate "
        "count is enough to separate the two common species."
    )
    add_footnote_ref(control, FIRST_ID)
    notes.append((FIRST_ID, SHORT_NOTE))
    for text in BODY[6:8]:
        doc.add_paragraph(text)

    # --- the dense page: four long footnotes ----------------------------------
    page_break(doc)
    doc.add_heading("Four measurements worth arguing about", level=1)
    # All four references sit in ONE short paragraph at the top of this page.
    # Word's rule is that a note's first line appears on the page carrying its
    # reference, so this page must host all four note bodies and cannot escape
    # the demand by moving an anchor to the next page.
    if RIGID:
        table = doc.add_table(rows=1, cols=1)
        table.style = "Table Grid"
        row = table.rows[0]
        row._tr.get_or_add_trPr().append(OxmlElement("w:cantSplit"))
        container = row.cells[0]
        for text in BODY[:5]:
            container.add_paragraph(text)
    else:
        container = doc

    dense = container.add_paragraph("Four things on this shore are measured badly: ")
    labels = ["band height", "pool water", "crab names", "star sizes"]
    for offset, (label, body) in enumerate(zip(labels, LONG_NOTES)):
        fid = FIRST_ID + 1 + offset
        dense.add_run(label)
        add_footnote_ref(dense, fid)
        dense.add_run(", " if offset < len(labels) - 1 else ".")
        notes.append((fid, body))

    for text in BODY[8:10]:
        doc.add_paragraph(text)

    # --- a page after the dense one, so overflow has somewhere to land --------
    page_break(doc)
    doc.add_heading("Leaving the shore as you found it", level=1)
    for text in BODY[10:]:
        doc.add_paragraph(text)
    # Extra pages, so footnote continuation has somewhere to land.
    for round_index in range(3):
        page_break(doc)
        doc.add_heading(f"Notes from the log, part {round_index + 1}", level=1)
        for text in BODY[:4]:
            doc.add_paragraph(text)

    doc.save(STAGE)
    splice_footnotes_part(STAGE, OUT, footnotes_xml(notes))
    STAGE.unlink()
    print(f"wrote {OUT}{' (rigid body)' if RIGID else ''}{len(notes)} footnotes "
          f"(1 short control, {len(LONG_NOTES)} long on one page)")
    for fid, body in notes:
        print(f"  footnote {fid}: {len(body)} chars")


if __name__ == "__main__":
    main()
probe.ts
// Boot SuperDoc headless and print what pagination did with one page's footnote band.
//   bun add superdoc@2.13.0 happy-dom @happy-dom/global-registrator
//   bun run probe.ts fieldguide.docx
//
// The fixture's four long notes are numbered lists, so every line carries a
// unique "(n.NN)" marker. That makes "which lines of a note reached a page" an
// exact set comparison instead of a judgement about pixels.
import { GlobalRegistrator } from '@happy-dom/global-registrator'
GlobalRegistrator.register()

import { Blob as NodeBlob, File as NodeFile } from 'node:buffer'
import { readFileSync } from 'node:fs'

const LONG_NOTE_IDS = ['2', '3', '4', '5'] // build_fieldguide.py: ids 2-5 …
const ITEMS_PER_NOTE = 44 //                  … with 44 marked items each
const BASE_MARGIN_BOTTOM_PX = 96 // 1 in at 96 dpi, from the fixture's sectPr
const VIEWPORT_PX = 12_000

// 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 layout, so every client rect is zero and a
// viewport-windowed painter only ever paints the pages it thinks straddle y=0.
// Report a tall viewport and derive each element's rect from its inline style,
// which is where this renderer puts the geometry it computed itself.
try {
  ;(window as any).happyDOM?.setViewport?.({ width: 1200, height: VIEWPORT_PX })
} catch {}
const elementProto = (globalThis as any).Element.prototype
elementProto.getBoundingClientRect = function () {
  const style = (this as HTMLElement).style
  const h = Number.parseFloat(style?.height ?? '') || VIEWPORT_PX
  const w = Number.parseFloat(style?.width ?? '') || 1200
  const t = Number.parseFloat(style?.top ?? '') || 0
  return { x: 0, y: t, top: t, left: 0, right: w, bottom: t + h, width: w, height: h, toJSON: () => ({}) }
}
for (const [prop, value] of [
  ['clientHeight', VIEWPORT_PX],
  ['offsetHeight', VIEWPORT_PX],
  ['scrollHeight', VIEWPORT_PX],
  ['clientWidth', 1200],
  ['offsetWidth', 1200],
] as [string, number][]) {
  try {
    Object.defineProperty(elementProto, prop, { configurable: true, get: () => value })
  } catch {}
}

// Shim 3: happy-dom has no 2D canvas context; the layout pass needs measureText.
// Width scales with the font size so 10 pt note text measures narrower than
// 11 pt body text — this defect is height-dependent, so a flat width would not do.
const canvasProto = (globalThis as any).HTMLCanvasElement.prototype
canvasProto.getContext = (kind: string) =>
  kind !== '2d'
    ? null
    : new Proxy(
        {
          font: '16px serif',
          measureText(text: string) {
            const px = Number(/(\d+(?:\.\d+)?)px/.exec((this as any).font ?? '')?.[1] ?? 16)
            const width = text.length * px * 0.5
            return {
              width,
              actualBoundingBoxLeft: 0,
              actualBoundingBoxRight: width,
              actualBoundingBoxAscent: px * 0.8,
              actualBoundingBoxDescent: px * 0.2,
              fontBoundingBoxAscent: px * 0.8,
              fontBoundingBoxDescent: px * 0.2,
              emHeightAscent: px * 0.8,
              emHeightDescent: px * 0.2,
              alphabeticBaseline: 0,
              hangingBaseline: px * 0.8,
              ideographicBaseline: -px * 0.2,
            }
          },
        } as any,
        {
          get: (t, k: string) => (k in t ? (t as any)[k] : () => undefined),
          set: (t, k: string, v) => (((t as any)[k] = v), true),
        },
      )

// The reserve cap is reported nowhere but the console: `capped` and
// `overflowHeightPx` live on the internal FootnotePagePlan and are not copied
// onto the Page.footnoteLedger the bridge writes.
const layoutDiagnostics: { text: string; detail: unknown }[] = []
for (const level of ['warn', 'error'] as const) {
  const original = console[level].bind(console)
  console[level] = (...args: unknown[]) => {
    const head = typeof args[0] === 'string' ? args[0] : ''
    if (head.startsWith('[layout]') || head.startsWith('[incrementalLayout]')) {
      layoutDiagnostics.push({ text: head, detail: args[1] })
      return
    }
    original(...args)
  }
}

const { SuperDoc } = await import('superdoc')
const path = process.argv[2] ?? 'fieldguide.docx'
const bytes = readFileSync(path)
const host = document.createElement('div')
host.style.width = '816px'
host.style.height = `${VIEWPORT_PX}px`
document.body.appendChild(host)

const sd: any = await new Promise((resolve, reject) => {
  const instance = new (SuperDoc as any)({
    selector: host,
    document: new (NodeFile as any)([bytes], path.split('/').pop(), {
      type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    }),
    telemetry: { enabled: false },
    onReady: () => resolve(instance),
    onException: (p: any) => reject(new Error(JSON.stringify(p))),
  })
  setTimeout(() => reject(new Error('onReady never fired within 180s')), 180_000)
})

// Pagination is asynchronous and re-runs; settle on a quiet window.
let lastUpdate = Date.now()
sd.on?.('pagination-update', () => {
  lastUpdate = Date.now()
})
const deadline = Date.now() + 90_000
while (Date.now() < deadline && Date.now() - lastUpdate < 4_000) {
  await new Promise((r) => setTimeout(r, 250))
}

const versionOf = (pkg: string) =>
  JSON.parse(readFileSync(`node_modules/${pkg}/package.json`, 'utf8')).version
console.log(
  `superdoc ${versionOf('superdoc')} | @superdoc/docx-engine ${versionOf('@superdoc/docx-engine')}`,
)

// --- per-page band reserve, from the public page-metrics snapshot ------------
const snapshot = sd.activeEditor.pageMetrics.getSnapshot()

// --- painted note slices ----------------------------------------------------
const pageEls = Array.from(document.querySelectorAll('.superdoc-page'))
const px = (v: string | null | undefined) => (v ? Number.parseFloat(v) : Number.NaN)
type Slice = { noteId: string; top: number; height: number; text: string }
const slicesByPage: Slice[][] = pageEls.map((el) =>
  (Array.from(el.querySelectorAll('[data-layout-story^="footnote:"]')) as HTMLElement[]).map((n) => ({
    noteId: (n.getAttribute('data-layout-story') ?? '').replace('footnote:', ''),
    top: px(n.style.top),
    height: px(n.style.height),
    text: n.textContent ?? '',
  })),
)
// Body fragments per page separate "the engine placed no note here" from "this
// harness painted nothing here".
const bodyFragsByPage = pageEls.map(
  (el) => el.querySelectorAll('[data-layout-story="body"]').length,
)

const cappedPages = new Set<number>()
const truncatedIds = new Set<string>()
for (const d of layoutDiagnostics) {
  const detail = d.detail as any
  if (d.text.includes('reserve capped')) for (const p of detail?.pages ?? []) cappedPages.add(p)
  if (d.text.includes('content truncated')) for (const i of detail?.ids ?? []) truncatedIds.add(String(i))
}

console.log(`\npages: ${pageEls.length}`)
console.log('\nper page:')
for (const [index, page] of snapshot.pages.entries()) {
  const slices = slicesByPage[index] ?? []
  const reserve = page.base.marginBottomPx - BASE_MARGIN_BOTTOM_PX
  const nextIds = new Set((slicesByPage[index + 1] ?? []).map((s) => s.noteId))
  const continues = slices.filter((s) => nextIds.has(s.noteId)).map((s) => s.noteId)
  console.log(`  page ${index}`)
  console.log(`    body fragments painted: ${bodyFragsByPage[index] ?? 0}`)
  console.log(`    footnote reserve px   : ${reserve}`)
  console.log(`    capped (warning)      : ${cappedPages.has(index)}`)
  console.log(`    note slices placed    : ${JSON.stringify(slices.map((s) => s.noteId))}`)
  console.log(`    slice heights px      : ${JSON.stringify(slices.map((s) => Math.round(s.height)))}`)
  console.log(`    continues to page ${String(index + 1).padEnd(2)} : ${JSON.stringify(continues)}`)
}

console.log('\ncaptured [layout] diagnostics:')
if (layoutDiagnostics.length === 0) console.log('  (none)')
for (const d of layoutDiagnostics) console.log(`  warn: ${d.text} ${JSON.stringify(d.detail)}`)

// --- did every line of every note reach a page? -----------------------------
console.log('\nmarked-line coverage per note (44 items authored in each):')
const paintedText = slicesByPage.flat()
for (const noteId of LONG_NOTE_IDS) {
  const text = paintedText.filter((s) => s.noteId === noteId).map((s) => s.text).join(' ')
  const seen = new Set(
    [...text.matchAll(new RegExp(`\\(${noteId}\\.(\\d{2})\\)`, 'g'))].map((m) => Number(m[1])),
  )
  const missing = Array.from({ length: ITEMS_PER_NOTE }, (_, i) => i + 1).filter((n) => !seen.has(n))
  const pagesFor = slicesByPage
    .map((slices, i) => (slices.some((s) => s.noteId === noteId) ? i : -1))
    .filter((i) => i >= 0)
  console.log(`  note ${noteId}: painted on pages ${JSON.stringify(pagesFor)}`)
  console.log(`    items painted : ${seen.size}/${ITEMS_PER_NOTE}`)
  console.log(`    items missing : ${missing.length}${missing.length ? ` -> ${missing[0]}..${missing[missing.length - 1]}` : ''}`)
  console.log(`    in "content truncated" warning: ${truncatedIds.has(noteId)}`)
}

// --- Page.footnoteLedger ----------------------------------------------------
// Documented in the shipped layout-engine typings; no public export reaches it.
const ledgers: number[] = []
const visited = new WeakSet<object>()
const walk = (value: unknown, depth: number) => {
  if (depth > 10 || value === null || typeof value !== 'object') return
  if (value instanceof (globalThis as any).Node || visited.has(value as object)) return
  visited.add(value as object)
  const record = value as Record<string, unknown>
  if (record.footnoteLedger) ledgers.push(Number(record.pageIndex ?? -1))
  let children: unknown[]
  try {
    children = Array.isArray(value) ? value : Object.values(record)
  } catch {
    return
  }
  for (const child of children) walk(child, depth + 1)
}
for (const root of [sd, sd.activeEditor, sd.superdocStore]) {
  try {
    walk(root, 0)
  } catch {}
}
console.log(
  `\nPage.footnoteLedger reachable from the public surface: ${ledgers.length > 0 ? JSON.stringify(ledgers) : 'no'}`,
)

console.log('\nreading order of each note across its slices:')
for (const noteId of LONG_NOTE_IDS) {
  const runs: string[] = []
  let ordered = true
  let previous = 0
  for (const [pageIndex, slices] of slicesByPage.entries()) {
    for (const slice of slices.filter((s) => s.noteId === noteId)) {
      const marks = [...slice.text.matchAll(new RegExp(`\\(${noteId}\\.(\\d{2})\\)`, 'g'))].map((m) => Number(m[1]))
      const first = marks[0]
      const last = marks[marks.length - 1]
      runs.push(`p${pageIndex}:${marks.length ? `${first}-${last}` : 'lead'}`)
      if (marks.length) {
        if (first < previous) ordered = false
        previous = last
      }
    }
  }
  console.log(`  note ${noteId}: ${runs.join('  ')}   ascending across pages? ${ordered}`)
}

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

Labels

status: ready-for-validationAll blockers are done or in pre-release; ready for validation, not necessarily generally released.

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions