Files
Umbraformat/tests/make_bisect_files.py
bm 284ebad6af Round 2 Word bisection: isolate the field-run co-factor
bisect_03 (fields removed) was the only round-1 file Word opened, and
document.xml becomes byte-identical between the full book and bisect_03
once field runs are removed — but the mini book opens with identical
PAGEREF fields, so the fields interact with a second factor. Add six
candidates that keep the 28 PAGEREF fields while peeling other layers:

- 07 drop deep-copied chapter prose (keep titles/bookmarks/fields)
- 08 drop numbering properties
- 09 inline hyperlink wrappers
- 10 remove the whole TOC section (cleaner no-fields control)
- 11 rewrite all PAGEREF targets to a single bookmark
- 12 keep fields only for the first 3 TOC entries
2026-09-23 07:57:21 -07:00

235 lines
7.5 KiB
Python

#!/usr/bin/env python
"""Generate Word-diagnostic bisection files from the real full-book output.
Word refuses the full-book output with a hard "Word experienced an error
trying to open the file" dialog while the mini-book fixture opens fine, and
every structural OOXML audit on the full file passes. LibreOffice renders all
of these files, so Word itself is the only oracle that can tell them apart.
Each candidate starts from the final full-book DOCX and removes exactly one
feature family:
01 no_running - every sectPr headerReference/footerReference removed
02 no_oddpage - sectPr w:type -> nextPage, titlePg dropped, evenAndOddHeaders off
03 no_fields - all fldChar/instrText runs removed (TOC PAGEREF machinery)
04 no_styles - all w:pStyle / w:rStyle removed (style-system interaction)
05 first8 - body truncated after the 8th section boundary
06 no_images - drawing paragraphs removed
Open each in Word; whichever show the error dialog pinpoint the feature
family at fault.
"""
import os
import sys
from docx import Document
from docx.oxml.ns import qn
from lxml import etree # noqa: F401 (kept for parity with the other makers)
W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_BASE = os.path.normpath(os.path.join(
HERE, '..', 'umbraformat', 'formatted',
'3a7d086e-c5ab-47bc-a3c5-0ad6352bd9ae_formatted.docx'))
OUTDIR = os.path.join(HERE, 'word_bisect')
NAMES = [
'bisect_01_no_running.docx',
'bisect_02_no_oddpage.docx',
'bisect_03_no_fields.docx',
'bisect_04_no_styles.docx',
'bisect_05_first8.docx',
'bisect_06_no_images.docx',
'bisect_07_no_prose.docx',
'bisect_08_no_numbering.docx',
'bisect_09_no_hyperlinks.docx',
'bisect_10_no_toc_section.docx',
'bisect_11_same_target_fields.docx',
'bisect_12_first3_toc.docx',
]
def _save(doc, name):
os.makedirs(OUTDIR, exist_ok=True)
path = os.path.join(OUTDIR, name)
doc.save(path)
return path
def make_no_running(doc):
"""Remove header/footer references from every section (incl. first-page)."""
for sectPr in doc.element.body.iter(qn('w:sectPr')):
for tag in ('w:headerReference', 'w:footerReference'):
for el in sectPr.findall(qn(tag)):
sectPr.remove(el)
def make_no_oddpage(doc):
"""Neutralise the oddPage section-start / odd-even running machinery."""
for sectPr in doc.element.body.iter(qn('w:sectPr')):
t = sectPr.find(qn('w:type'))
if t is not None:
t.set(qn('w:val'), 'nextPage')
tp = sectPr.find(qn('w:titlePg'))
if tp is not None:
sectPr.remove(tp)
settings = doc.settings.element
for el in settings.findall(qn('w:evenAndOddHeaders')):
settings.remove(el)
def make_no_fields(doc):
"""Drop every run carrying field code/result machinery."""
for p in doc.element.body.iter(qn('w:p')):
for r in list(p.findall(qn('w:r'))):
if r.find(qn('w:fldChar')) is not None or r.find(qn('w:instrText')) is not None:
p.remove(r)
def make_no_styles(doc):
"""Remove style references from all paragraphs/runs (renders as Normal)."""
for el in list(doc.element.body.iter()):
if el.tag in (qn('w:pStyle'), qn('w:rStyle')):
el.getparent().remove(el)
def make_first8(doc):
"""Truncate the body after the 8th section boundary (move its sectPr to
body level and drop every later sibling, incl. all later sections)."""
body = doc.element.body
markers = []
for s in body.iter(qn('w:sectPr')):
parent = s.getparent()
if parent is not None and parent.tag == qn('w:pPr'):
markers.append((s, parent.getparent()))
sect, para = markers[7] # 8th boundary (0-based)
sect.getparent().remove(sect)
nxt = para.getnext()
while nxt is not None:
nxt2 = nxt.getnext()
body.remove(nxt)
nxt = nxt2
for fin in body.findall(qn('w:sectPr')):
body.remove(fin)
body.append(sect)
def make_no_images(doc):
"""Remove paragraphs containing inline drawings."""
for p in list(doc.element.body.iter(qn('w:p'))):
if p.find(qn('w:drawing')) is not None:
p.getparent().remove(p)
def _sections(doc):
"""Return list of [start, end) kid-slices, one per body section,
using the in-paragraph sectPr holder paragraphs as boundaries."""
body = doc.element.body
holders = []
for s in body.iter(qn('w:sectPr')):
par = s.getparent().getparent()
if par is not None and par.getparent() is body:
holders.append(par)
kids = [c for c in body if c.tag == qn('w:p')]
secs, start = [], 0
for h in holders:
hi = kids.index(h)
secs.append((start, hi + 1))
start = hi + 1
if start < len(kids):
secs.append((start, len(kids)))
return kids, secs
def make_no_prose(doc):
"""Drop the deep-copied chapter prose (sections 5..32) while keeping each
chapter's title paragraph (with its bookmark) and section holder."""
kids, secs = _sections(doc)
body = doc.element.body
drop = []
for ci, (a, b) in enumerate(secs):
if 5 <= ci <= 32: # chapters 1..28
drop.extend(kids[a + 1:b - 1]) # keep title [a], holder [b-1]
for k in drop:
body.remove(k)
def make_no_numbering(doc):
"""Remove all list-numbering properties (keeps fields)."""
for np_ in list(doc.element.body.iter(qn('w:numPr'))):
np_.getparent().remove(np_)
def make_no_hyperlinks(doc):
"""Remove w:hyperlink wrappers, inlining their runs (keeps fields)."""
for hl in list(doc.element.body.iter(qn('w:hyperlink'))):
par = hl.getparent()
anchor = hl
for r in list(hl):
par.addnext(r)
par.remove(hl)
def make_no_toc_section(doc):
"""Remove the whole TOC section (heading + entries + its section holder)."""
kids, secs = _sections(doc)
body = doc.element.body
a, b = secs[4] # TOC section
for k in kids[a:b]:
body.remove(k)
def make_same_target_fields(doc):
"""Rewrite every PAGEREF target to _UF_ch_0 (uniform targets, fields kept)."""
for it in doc.element.body.iter(qn('w:instrText')):
if (it.text or '').strip().startswith('PAGEREF'):
it.text = ' PAGEREF _UF_ch_0 '
def make_first3_toc(doc):
"""Keep only the first 3 TOC entries' fields; plain-text the rest."""
seen = 0
for p in doc.element.body.iter(qn('w:p')):
if p.find(".//" + qn('w:instrText')) is None:
continue
if seen < 3:
seen += 1
continue
for r in list(p.findall(qn('w:r'))):
if r.find(qn('w:fldChar')) is not None or r.find(qn('w:instrText')) is not None:
p.remove(r)
MAKERS = [
make_no_running,
make_no_oddpage,
make_no_fields,
make_no_styles,
make_first8,
make_no_images,
make_no_prose,
make_no_numbering,
make_no_hyperlinks,
make_no_toc_section,
make_same_target_fields,
make_first3_toc,
]
def main():
base = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_BASE
if not os.path.exists(base):
sys.exit(f"base output not found: {base}\n"
"Regenerate it via the API first, or pass its path as argv[1].")
for maker, name in zip(MAKERS, NAMES):
doc = Document(base)
maker(doc)
path = _save(doc, name)
# sanity: it must still open via python-docx
Document(path)
print(f"wrote {path}")
if __name__ == '__main__':
main()