"""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()
Versions tested
superdoc2.13.0 (@superdoc/docx-engine0.12.0). Bun 1.3.13,happy-dom20.14.5 forthe DOM.
Repro
fieldguide.docxwith the Python script at the bottom. It is a short fieldguide — 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.
python build_fieldguide.py --rigidwrites 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 movedor shrunk to make room for the band.
bun add superdoc@2.13.0 happy-dom @happy-dom/global-registratorprobe.ts(below) next to them. It registers happy-dom, restores node'sFile/Blob(happy-dom's are not structured-cloneable), stubsgetContext('2d')witha
measureTextwhose width scales with the font size, and reports a tall viewport withrects 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.new SuperDoc({selector, document}), waits foronReady, settles on aquiet window of
pagination-update, and reports per page: the band reserve fromeditor.pageMetrics.getSnapshot()(base.marginBottomPxminus the section's own 96 pxbottom margin), the note slices painted on that page, and which of them continue onto
the next. It also collects every
[layout]/[incrementalLayout]console warning andchecks each note's 44 markers against what was painted anywhere in the document.
bun run probe.ts fieldguide.docxbun run probe.ts fieldguide-rigid.docxObserved
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):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 —
— so continuation itself works. Note also that the
Footnote content truncated: extends beyond document pageswarning 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 : 0and no slices, sothere 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 onew:cantSplittable row:Three things there:
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.)
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, andthe repeated
reserve cappedwarnings at pages 5/6, 10/11, 15/16, 20/21, 25/26 — afive-page stride — look like successive passes each pushing the same unplaceable cluster
one band further down.
3.
cappedandoverflowHeightPxcannot be read from any public surfaceFootnotePagePlan.cappedandFootnotePagePlan.overflowHeightPxare the two facts thatsay a page's band was cut short, and they live only on the internal plan — the bridge does
not copy them onto the
Page.footnoteLedgerit writes, and that ledger is itselfunreachable:
FootnotePageLedgeris declared in the shipped typings(
dist/layout-engine/contracts/src/index.d.ts, andPage.footnoteLedgeralongside it) butnothing exported reaches a
Page.SuperDocPaginationUpdatePayloadcarries onlytotalPages. The nearest public signal iseditor.pageMetrics.getSnapshot().pages[i].base.marginBottomPx, which is the sectionmargin plus the reserve added together and so cannot distinguish a capped band from an
uncapped one. That leaves a host application parsing
console.warnto find out that a pageof a user's document was laid out short.
Expected
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.
--rigidfixture all four notes are placed onno 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.xmlsupplies 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.
Footnote reserve loop did not converge (max 4 passes), is a page count no author askedfor. 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.
cappedandoverflowHeightPxare readable. Either copy them ontoPage.footnoteLedgerand export a way to read aPage, or put them on thepagination-updatepayload. A host that renders a user's document needs to know a pagewas laid out short without scraping
console.warn, and today there is no such door.Footnote content truncated: extends beyond document pagesshould describe the layoutthat 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
measureTextwhose width scales with font size (a flat width makes every note the same height and the
cap never fires), and
getBoundingClientRect/clientHeightreporting a tall viewport,without which only the pages straddling
y = 0are painted and the note band is invisibleto the probe. Reserves come from
editor.pageMetrics.getSnapshot(), which is real publicAPI 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]probe.ts