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