Spaces:
Sleeping
Sleeping
| #!/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 | |