Spaces:
Sleeping
Sleeping
File size: 7,216 Bytes
5ecf925 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | #!/usr/bin/env python3
"""
verify_applied.py β Independent content-verification pass for applied CRs.
Two checks, both operating on data already produced by the existing
pipeline (cr_parser.parse_cr / ts_applicator.apply_manifest). Neither
changes parsing or applying behaviour β this is purely additive.
Check A β scan_revision_marks(): classifies every OOXML tracked-change
element in a CR DOCX (w:ins/w:del content marks, plus any other
CT_TrackChange-family element such as w:moveFrom/w:moveTo or
w:*PrChange/w:cellMerge that this pipeline has no apply-path
for), used to flag "CR has tracked changes but parser extracted
0" and "CR has a revision-mark type nothing here understands".
Check B β verify_manifest_applied(): confirms each parsed change's new
text is actually present in the saved output TS.
"""
import sys
from pathlib import Path
import docx
from docx.oxml.ns import qn
from lxml import etree
sys.path.insert(0, str(Path(__file__).parent))
from cr_parser import (
_is_rpr_ins,
_is_inserted_para,
_is_fully_inserted_tbl,
_is_skip_marker,
)
from ts_applicator import _norm, _norm_ws, _section_range
_MIN_SNIPPET_LEN = 4 # after whitespace-stripped normalisation
# ββ Check A β generalized revision-mark scan βββββββββββββββββββββββββββββββββββ
def scan_revision_marks(cr_docx_path):
"""
Classify every OOXML tracked-change element in a CR DOCX.
Every element that extends CT_TrackChange (the OOXML base type for
revision marks) carries a w:author attribute β not just w:ins/w:del, but
also w:moveFrom/w:moveTo, w:rPrChange/w:pPrChange/w:tblPrChange/
w:tblGridChange/w:trPrChange/w:tcPrChange/w:sectPrChange, and
w:cellIns/w:cellDel/w:cellMerge. Walking generically by that attribute
(rather than hardcoding a w:ins/w:del tag lookup) means no revision-mark
type β including ones not named here β goes silently unnoticed.
Returns {'handled': int, 'unhandled': {tag_localname: count, ...}}.
- handled: w:ins/w:del content marks (excludes rPr-nested paragraph-
mark markers, same filter cr_parser._is_rpr_ins applies).
- unhandled: any other tracked-change element found. cr_parser.py /
ts_applicator.py have no code path for these β they will
NOT be reflected in the output TS.
"""
doc = docx.Document(str(cr_docx_path))
body = doc.element.body
handled = 0
unhandled = {}
for elem in body.iter():
if elem.get(qn('w:author')) is None:
continue
local = etree.QName(elem).localname
if local in ('ins', 'del'):
if not _is_rpr_ins(elem):
handled += 1
else:
unhandled[local] = unhandled.get(local, 0) + 1
return {'handled': handled, 'unhandled': unhandled}
# ββ Check B β content-presence check ββββββββββββββββββββββββββββββββββββββββββ
def _section_replace_ins_snippets(elements_xml):
"""From a section_replace's serialized elements, collect w:t text of the
ins-block elements only (skip del-block and separator elements)."""
snippets = []
for xml_str in elements_xml:
elem = etree.fromstring(xml_str)
tag = elem.tag.split('}')[-1] if '}' in elem.tag else elem.tag
if tag == 'p':
is_ins = _is_inserted_para(elem)
elif tag == 'tbl':
is_ins = _is_fully_inserted_tbl(elem)
else:
is_ins = False
if is_ins:
snippets.append(''.join(t.text or '' for t in elem.iter(qn('w:t'))))
return snippets
def _extract_snippets(change):
"""Return the expected inserted-text snippet(s) for one manifest change."""
ctype = change.get('type')
if ctype == 'text_replace':
new = change.get('new', '')
return [new] if new else []
if ctype == 'text_insert':
text = change.get('text', '')
return [text] if text else []
if ctype == 'para_insert':
return [p.get('text', '') for p in change.get('paragraphs', [])]
if ctype == 'row_insert':
return [c.get('text', '') for c in change.get('cells', [])]
if ctype == 'section_replace':
return _section_replace_ins_snippets(change.get('elements_xml', []))
return []
def _section_scope_text(doc, section_number):
"""Full w:t text (paragraphs + tables) of a section, or None if not found."""
start, end = _section_range(doc, section_number)
if start is None:
return None
paras = doc.paragraphs
start_elem = paras[start]._element
end_elem = paras[end]._element if end < len(paras) else None
parts = []
in_range = False
for child in doc.element.body:
if child is start_elem:
in_range = True
if in_range:
if end_elem is not None and child is end_elem:
break
parts.append(''.join(t.text or '' for t in child.iter(qn('w:t'))))
return ''.join(parts)
def _snippet_present(snippet, corpus):
if snippet in corpus:
return True
if _norm(snippet) in _norm(corpus):
return True
if _norm_ws(snippet) in _norm_ws(corpus):
return True
return False
def verify_manifest_applied(ts_doc, manifest):
"""Confirm each change's expected new-text snippet(s) are present in ts_doc.
A declared section_number is a hint, not a hard boundary: the real
applicator (ts_applicator.py) falls back to a global search whenever an
anchor/table isn't found in the declared section (see the "using global
match" WARN lines it emits), so content can legitimately land outside
its declared section. To avoid flagging those legitimate placements,
a snippet is only reported missing if it's absent from BOTH the
section-scoped corpus (when available) and the whole document.
Returns a list of 'ERROR verify: ...' lines, one per missing snippet."""
errors = []
whole_doc_corpus = None
for change in manifest:
ctype = change.get('type')
cr_uid = change.get('_cr_uid', '?')
section_number = (change.get('location') or {}).get('section_number', '')
scope_corpus = _section_scope_text(ts_doc, section_number) if section_number else None
for snippet in _extract_snippets(change):
if not snippet or _is_skip_marker(snippet):
continue
if len(_norm_ws(snippet)) < _MIN_SNIPPET_LEN:
continue
if scope_corpus is not None and _snippet_present(snippet, scope_corpus):
continue
if whole_doc_corpus is None:
whole_doc_corpus = ''.join(t.text or '' for t in ts_doc.element.body.iter(qn('w:t')))
if not _snippet_present(snippet, whole_doc_corpus):
errors.append(
f'ERROR verify: [{cr_uid}] {ctype} β expected text not found '
f'in output TS: {snippet[:80]!r}'
)
return errors
|