Files
Umbraformat/tests/make_bisect_files.py
2026-10-18 19:01:06 -07:00

498 lines
17 KiB
Python

#!/usr/bin/env python
# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""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
19 no_validate - +w:doNotValidateAgainstSchema in settings.xml
20 shared_hf_parts - every section points at section 1's 6 header/footer parts
21 mini_28fields - the MINI book with the TOC entry replicated to 28 fields
22 updatefields_nodirty - w:dirty off + <w:updateFields w:val="true"/> in settings
23 mini_28distinct - mini with 28 fields at 28 DISTINCT bookmarks
24 full_1field - full book with exactly 1 dirty TOC field
Open each in Word; whichever show the error dialog pinpoint the feature
family at fault.
"""
from copy import deepcopy
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',
'bisect_13_no_scenes.docx',
'bisect_14_no_bookmarks.docx',
'bisect_15_mini_toc_in_full.docx',
'bisect_16_no_titles.docx',
'bisect_17_page_fields.docx',
'bisect_18_no_dirty.docx',
'bisect_19_no_validate.docx',
'bisect_20_shared_hf_parts.docx',
'bisect_21_mini_28fields.docx',
'bisect_22_updatefields_nodirty.docx',
'bisect_23_mini_28distinct.docx',
'bisect_24_full_1field.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)
def make_no_scenes(doc):
"""Drop the scene-break paragraphs (keep fields)."""
for p in list(doc.element.body.iter(qn('w:p'))):
txt = ''.join((t.text or '') for t in p.findall('.//' + qn('w:t')))
if '>>>' in txt:
p.getparent().remove(p)
def make_no_bookmarks(doc):
"""Remove every bookmarkStart/bookmarkEnd (fields stay, targets vanish)."""
for tag in ('w:bookmarkStart', 'w:bookmarkEnd'):
for b in list(doc.element.body.iter(qn(tag))):
b.getparent().remove(b)
def make_mini_toc_in_full(doc):
"""Full shell WITHOUT the TOC section, but with the mini book's own
3-entry TOC (heading + entries + fields) spliced in verbatim."""
make_no_toc_section(doc)
mini = Document(os.path.join(HERE, 'word_test_output.docx'))
paras = [c for c in mini.element.body if c.tag == qn('w:p')]
start = None
for i, p in enumerate(paras):
txt = ''.join((t.text or '') for t in p.findall('.//' + qn('w:t')))
if txt.strip() == 'Table of Contents':
start = i
break
end = len(paras)
for i in range(start + 1, len(paras)):
ppr = paras[i].find(qn('w:pPr'))
if ppr is not None and ppr.find(qn('w:sectPr')) is not None:
end = i
break
# anchor: first WAChapter paragraph (chapter 1 title) of the full shell
body = doc.element.body
anchor = None
for p in body.iter(qn('w:p')):
ppr = p.find(qn('w:pPr'))
if ppr is None:
continue
st = ppr.find(qn('w:pStyle'))
if st is not None and st.get(qn('w:val')) == 'WAChapter':
anchor = p
break
for pi in range(start, end):
body.insert(list(body).index(anchor), deepcopy(paras[pi]))
def make_no_titles(doc):
"""Drop the chapter title paragraphs (WAChapter) incl. their bookmarks."""
for p in list(doc.element.body.iter(qn('w:p'))):
ppr = p.find(qn('w:pPr'))
if ppr is None:
continue
st = ppr.find(qn('w:pStyle'))
if st is not None and st.get(qn('w:val')) == 'WAChapter':
p.getparent().remove(p)
def make_page_fields(doc):
"""Replace every PAGEREF instruction with PAGE (structure kept)."""
for it in doc.element.body.iter(qn('w:instrText')):
if (it.text or '').strip().startswith('PAGEREF'):
it.text = ' PAGE '
def make_no_dirty(doc):
"""Remove w:dirty from field runs (no auto field update on open)."""
for fc in doc.element.body.iter(qn('w:fldChar')):
r = fc.getparent()
rpr = r.find(qn('w:rPr'))
if rpr is not None:
d = rpr.find(qn('w:dirty'))
if d is not None:
rpr.remove(d)
def make_no_validate(doc):
"""Add <w:doNotValidateAgainstSchema/> to settings: Word skips schema
validation on open. If the full book then opens, the failure is a
schema-level violation hit during the dirty-field update/repagination
pass, not a content issue."""
settings = doc.settings.element
el = etree.Element(qn('w:doNotValidateAgainstSchema'))
# canonical CT_Settings order: ... savePreviewPicture,
# doNotValidateAgainstSchema, saveInvalidXml, ... compat, rsids, mathPr
anchor = None
for tag in ('w:compat', 'w:rsids', 'w:mathPr'):
anchor = settings.find(qn(tag))
if anchor is not None:
break
if anchor is not None:
anchor.addprevious(el)
else:
settings.append(el)
def make_shared_hf_parts(doc):
"""Point every section at the first section's 6 header/footer parts, so
the 216 part *package* collapses to 6 referenced parts while keeping the
36-section body structure and dirty fields."""
first = None
for sectPr in doc.element.body.iter(qn('w:sectPr')):
if sectPr.getparent().tag == qn('w:pPr'):
first = sectPr
break
if first is None:
return
mapping = {}
for ref in first:
if ref.tag in (qn('w:headerReference'), qn('w:footerReference')):
mapping[(ref.tag, ref.get(qn('w:type')))] = ref.get(qn('r:id'))
for sectPr in doc.element.body.iter(qn('w:sectPr')):
if sectPr is first:
continue
for ref in list(sectPr):
if ref.tag in (qn('w:headerReference'), qn('w:footerReference')):
key = (ref.tag, ref.get(qn('w:type')))
if key in mapping:
ref.set(qn('r:id'), mapping[key])
def make_mini_28fields(doc):
"""Return a copy of the MINI book (which opens in Word with 3 dirty
PAGEREF fields) with the first TOC entry paragraph replicated 25 more
times -> 28 dirty fields in the mini shell. Tests pure field density:
if this fails while the 3-field mini opens, the trigger is field count
interacting with something, not the full shell."""
mini = Document(os.path.join(HERE, 'word_test_output.docx'))
body = mini.element.body
first_entry = None
for p in body.iter(qn('w:p')):
if p.find(".//" + qn('w:instrText')) is not None:
first_entry = p
break
if first_entry is None:
return mini
idx = list(body).index(first_entry)
for k in range(25):
body.insert(idx + 1 + k, deepcopy(first_entry))
return mini
def make_updatefields_nodirty(doc):
"""Full book: remove w:dirty from every field run AND set
<w:updateFields w:val="true"/> in settings (Word updates all fields on
open through the settings path instead of per-field dirty flags)."""
for fc in doc.element.body.iter(qn('w:fldChar')):
r = fc.getparent()
rpr = r.find(qn('w:rPr'))
if rpr is not None:
d = rpr.find(qn('w:dirty'))
if d is not None:
rpr.remove(d)
settings = doc.settings.element
el = etree.Element(qn('w:updateFields'))
el.set(qn('w:val'), 'true')
# canonical CT_Settings order: ... alwaysMergeEmptyNamespace, updateFields,
# hdrShapeDefaults, footnotePr, endnotePr, compat, rsids, mathPr
anchor = None
for tag in ('w:hdrShapeDefaults', 'w:footnotePr', 'w:endnotePr',
'w:compat', 'w:rsids', 'w:mathPr'):
anchor = settings.find(qn(tag))
if anchor is not None:
break
if anchor is not None:
anchor.addprevious(el)
else:
settings.append(el)
def make_mini_28_distinct(doc):
"""Return a copy of the MINI book with 28 dirty PAGEREF fields pointing at
28 DISTINCT bookmarks: replicate the first TOC entry 25 more times with
rewritten targets, plus 25 copies of the chapter-1 title paragraph each
carrying a renumbered, renamed bookmark (_UF_ch_3..27). Un-confounds the
same-target multiplicity in bisect_21."""
mini = Document(os.path.join(HERE, 'word_test_output.docx'))
body = mini.element.body
paras = [c for c in body if c.tag == qn('w:p')]
first_entry = None
for p in paras:
if p.find(".//" + qn('w:instrText')) is not None:
first_entry = p
break
title_p = None
for p in paras:
ppr = p.find(qn('w:pPr'))
st = ppr.find(qn('w:pStyle')) if ppr is not None else None
if st is not None and st.get(qn('w:val')) == 'WAChapter':
title_p = p
break
if first_entry is None or title_p is None:
return mini
used = {int(b.get(qn('w:id'))) for b in body.iter(qn('w:bookmarkStart'))}
nid = (max(used) + 1) if used else 0
for k in range(3, 28):
ec = deepcopy(first_entry)
ec.find(".//" + qn('w:instrText')).text = f" PAGEREF _UF_ch_{k} "
body.insert(list(body).index(first_entry) + 1, ec)
tc = deepcopy(title_p)
for bs in tc.iter(qn('w:bookmarkStart')):
bs.set(qn('w:name'), f"_UF_ch_{k}")
bs.set(qn('w:id'), str(nid))
nid += 1
for be in tc.iter(qn('w:bookmarkEnd')):
be.set(qn('w:id'), str(nid))
nid += 1
body.insert(list(body).index(title_p) + 1, tc)
return mini
def make_first1_toc(doc):
"""Full book: keep only the FIRST TOC entry's field; plain-text the rest
(count threshold probe: does full + exactly 1 dirty field open?)."""
seen = 0
for p in doc.element.body.iter(qn('w:p')):
if p.find(".//" + qn('w:instrText')) is None:
continue
if seen < 1:
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,
make_no_scenes,
make_no_bookmarks,
make_mini_toc_in_full,
make_no_titles,
make_page_fields,
make_no_dirty,
make_no_validate,
make_shared_hf_parts,
make_mini_28fields,
make_updatefields_nodirty,
make_mini_28_distinct,
make_first1_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)
doc = maker(doc) or doc
path = _save(doc, name)
# sanity: it must still open via python-docx
Document(path)
print(f"wrote {path}")
if __name__ == '__main__':
main()