498 lines
17 KiB
Python
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()
|