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
235 lines
7.5 KiB
Python
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() |