docx-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ """docx-cli normalize — приведение шрифтов/интервалов к Times New Roman."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ from docx import Document
8
+ from docx.oxml.ns import qn
9
+ from docx.shared import Cm, Pt
10
+ from docx.text.run import Run
11
+
12
+ TARGET_FONT = "Times New Roman"
13
+ GOST_FIRST_LINE_INDENT_CM = 1.25
14
+ _TOL_CM = 0.05
15
+ _KNOWN_FONTS = {
16
+ "calibri", "cambria", "arial", "verdana", "tahoma", "segoe ui",
17
+ "helvetica", "liberation serif", "liberation sans", "dejavu sans",
18
+ "pt serif", "pt sans",
19
+ }
20
+
21
+
22
+ def _all_runs(p):
23
+ for r in p._p.iter(qn("w:r")):
24
+ yield Run(r, p)
25
+
26
+
27
+ def _force_font(font_obj, size_pt: int | None = None) -> bool:
28
+ changed = False
29
+ name = (font_obj.name or "").lower()
30
+ if name in _KNOWN_FONTS or (name and name != TARGET_FONT.lower()):
31
+ font_obj.name = TARGET_FONT
32
+ changed = True
33
+ if size_pt is not None and font_obj.size is not None and font_obj.size != Pt(size_pt):
34
+ font_obj.size = Pt(size_pt)
35
+ changed = True
36
+ return changed
37
+
38
+
39
+ def _repair_first_line_indents(doc, target_cm: float = GOST_FIRST_LINE_INDENT_CM) -> int:
40
+ from docx_cli.validate import _classify_paragraphs
41
+ roles = _classify_paragraphs(doc, None)
42
+ fixed = 0
43
+ for idx, p in enumerate(doc.paragraphs):
44
+ if roles.get(idx) != "body":
45
+ continue
46
+ if not p.text.strip():
47
+ continue
48
+ pf = p.paragraph_format
49
+ cur = pf.first_line_indent
50
+ if cur is not None and abs(cur.cm - target_cm) <= _TOL_CM:
51
+ continue
52
+ pf.first_line_indent = Cm(target_cm)
53
+ fixed += 1
54
+ return fixed
55
+
56
+
57
+ def normalize_document(path, body_size: int = 12, header_size: int = 11,
58
+ dry_run: bool = False, backup: bool = True) -> dict:
59
+ doc = Document(str(path))
60
+ changed = 0
61
+
62
+ if _force_font(doc.styles["Normal"].font, body_size):
63
+ changed += 1
64
+ for style in doc.styles:
65
+ try:
66
+ if style.font is not None and _force_font(style.font):
67
+ changed += 1
68
+ except Exception: # noqa: BLE001
69
+ continue
70
+ for p in doc.paragraphs:
71
+ if p.style and p.style.name and "Heading" in p.style.name:
72
+ for run in _all_runs(p):
73
+ changed += int(_force_font(run.font, header_size))
74
+ else:
75
+ for run in _all_runs(p):
76
+ changed += int(_force_font(run.font, body_size))
77
+ for table in doc.tables:
78
+ for row in table.rows:
79
+ for cell in row.cells:
80
+ for p in cell.paragraphs:
81
+ for run in _all_runs(p):
82
+ changed += int(_force_font(run.font, body_size))
83
+ for section in doc.sections:
84
+ for container in (section.header, section.footer):
85
+ for p in container.paragraphs:
86
+ for run in _all_runs(p):
87
+ changed += int(_force_font(run.font, header_size))
88
+
89
+ indent_fixed = _repair_first_line_indents(doc)
90
+ changed += indent_fixed
91
+
92
+ backup_path = None
93
+ if not dry_run:
94
+ if backup:
95
+ backup_path = str(path) + ".bak"
96
+ shutil.copy2(str(path), backup_path)
97
+ doc.save(str(path))
98
+ return {"file": str(path), "changes": changed,
99
+ "first_line_indent_fixed": indent_fixed,
100
+ "dry_run": dry_run, "backup": backup_path}
101
+
102
+
103
+ def normalize_path(path, body_size: int = 12, header_size: int = 11,
104
+ dry_run: bool = False, backup: bool = True,
105
+ yes: bool = False) -> dict:
106
+ p = Path(path)
107
+ if p.is_file():
108
+ res = normalize_document(p, body_size, header_size, dry_run=dry_run, backup=backup)
109
+ return {"normalized": [res], "count": 1, "dry_run": dry_run}
110
+ if p.is_dir():
111
+ if not yes:
112
+ raise ValueError("invalid_usage: directory mode requires --yes")
113
+ files = [f for f in sorted(p.glob("*.docx")) if not f.name.startswith("~$")]
114
+ if not files:
115
+ raise FileNotFoundError(f"нет .docx в каталоге: {p}")
116
+ results = [normalize_document(f, body_size, header_size, dry_run=dry_run, backup=backup)
117
+ for f in files]
118
+ return {"normalized": results, "count": len(results), "dry_run": dry_run}
119
+ raise FileNotFoundError(f"not found: {p}")
@@ -0,0 +1,369 @@
1
+ """docx-cli scan — аудит входящих .docx на LLM-атаки (скрытые векторы).
2
+
3
+ v0.3.0 (2026-10-20): собственная clean-room реализация эвристик аудита
4
+ входящих документов. Дополняет guard-слои обработки текста: те изолируют
5
+ ПРОЧИТАННОЕ (spotlight), этот находит
6
+ СКРЫТОЕ ДО обработки:
7
+
8
+ - скрытый текст w:vanish;
9
+ - микрошрифт (<= 2pt в w:sz);
10
+ - белый текст на светлом/прозрачном фоне (с фильтром тёмных ячеек таблиц);
11
+ - текст внутри отслеживаемых правок w:ins / w:del;
12
+ - скрытые инструкции в word/comments.xml.
13
+
14
+ Fail-closed: битый файл/не-docx = findings не пуст и clean=False.
15
+ v0.6.0: стиль-наследование (rStyle/basedOn), near-white/themeColor,
16
+ все word/*.xml части (headers/footers/footnotes/endnotes), fail-closed
17
+ конверты для не-docx/битого XML (внешний аудит 21.10, векторы F1/F2/F3)."""
18
+ from __future__ import annotations
19
+
20
+ import copy
21
+ import re
22
+ import zipfile
23
+ from xml.etree import ElementTree as ET
24
+
25
+ W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
26
+ R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
27
+ REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
28
+
29
+ def _extract_text(el) -> str:
30
+ return "".join(t.text or "" for t in el.iter(f"{{{W}}}t"))
31
+
32
+
33
+ def _has_dark_cell_shading(tc) -> bool:
34
+ shd = tc.find(f".//{{{W}}}shd")
35
+ if shd is None:
36
+ return False
37
+ fill = (shd.get(f"{{{W}}}fill") or "").lower()
38
+ return fill not in ("", "auto", "ffffff")
39
+
40
+
41
+ def _styles_map(styles) -> dict:
42
+ if styles is None:
43
+ return {}
44
+ if isinstance(styles, dict):
45
+ return styles
46
+ out = {}
47
+ try:
48
+ for s in styles.iter(f"{{{W}}}style"):
49
+ sid = s.get(f"{{{W}}}styleId")
50
+ if sid is not None:
51
+ out[sid] = s
52
+ except Exception: # noqa: BLE001
53
+ pass
54
+ return out
55
+
56
+
57
+ def _style_chain(styles_map: dict, style_id) -> list:
58
+ chain = []
59
+ cur = style_id
60
+ seen = set()
61
+ while cur is not None and cur not in seen and len(chain) < 4:
62
+ seen.add(cur)
63
+ chain.append(cur)
64
+ st = styles_map.get(cur)
65
+ if st is None:
66
+ break
67
+ bo = st.find(f"{{{W}}}basedOn")
68
+ cur = bo.get(f"{{{W}}}val") if bo is not None else None
69
+ chain.reverse()
70
+ return chain
71
+
72
+
73
+ def _effective_rpr(styles_map: dict, run_rpr, style_id) -> "ET.Element":
74
+ erpr = ET.Element(f"{{{W}}}rPr")
75
+ for sid in _style_chain(styles_map, style_id):
76
+ st = styles_map.get(sid)
77
+ if st is None:
78
+ continue
79
+ srpr = st.find(f"{{{W}}}rPr")
80
+ if srpr is None:
81
+ continue
82
+ for child in srpr:
83
+ existing = erpr.find(child.tag)
84
+ if existing is not None:
85
+ erpr.remove(existing)
86
+ erpr.append(copy.deepcopy(child))
87
+ if run_rpr is not None:
88
+ for child in run_rpr:
89
+ existing = erpr.find(child.tag)
90
+ if existing is not None:
91
+ erpr.remove(existing)
92
+ erpr.append(copy.deepcopy(child))
93
+ return erpr
94
+
95
+
96
+ def _scan_runs(root, *args, path="document.xml", styles=None, styles_map=None,
97
+ findings=None, **kwargs) -> None:
98
+ _findings = findings
99
+ _styles = styles if styles is not None else styles_map
100
+ _path = path
101
+ for a in args:
102
+ if isinstance(a, list):
103
+ _findings = a
104
+ elif isinstance(a, str):
105
+ _path = a
106
+ else:
107
+ if _styles is None:
108
+ _styles = a
109
+ elif _findings is None:
110
+ _findings = a
111
+ if _findings is None:
112
+ _findings = kwargs.get("findings", [])
113
+ if _path is None:
114
+ _path = kwargs.get("path", "document.xml")
115
+ smap = _styles_map(_styles)
116
+ if _findings is None:
117
+ return
118
+
119
+ for r in root.iter(f"{{{W}}}r"):
120
+ rpr = r.find(f"{{{W}}}rPr")
121
+ style_id = None
122
+ rs = None
123
+ if rpr is not None:
124
+ rs = rpr.find(f"{{{W}}}rStyle")
125
+ if rs is None:
126
+ rs = r.find(f"{{{W}}}rStyle")
127
+ if rs is not None:
128
+ style_id = rs.get(f"{{{W}}}val")
129
+ eff = _effective_rpr(smap, rpr, style_id)
130
+ text = _extract_text(r)
131
+ if not text:
132
+ continue
133
+
134
+ vanish = eff.find(f"{{{W}}}vanish")
135
+ if vanish is not None and vanish.get(f"{{{W}}}val", "1") not in ("0", "false", "off"):
136
+ _findings.append({"type": "hidden_text_vanish", "text": text, "path": _path})
137
+
138
+ sz = eff.find(f"{{{W}}}sz")
139
+ szcs = eff.find(f"{{{W}}}szCs")
140
+ micro_val = None
141
+ if sz is not None and (sz.get(f"{{{W}}}val") or "").isdigit() and int(sz.get(f"{{{W}}}val")) <= 4:
142
+ micro_val = sz.get(f"{{{W}}}val")
143
+ elif szcs is not None and (szcs.get(f"{{{W}}}val") or "").isdigit() and int(szcs.get(f"{{{W}}}val")) <= 4:
144
+ micro_val = szcs.get(f"{{{W}}}val")
145
+ if micro_val is None and szcs is not None and (szcs.get(f"{{{W}}}val") or "").isdigit() and int(szcs.get(f"{{{W}}}val")) <= 4:
146
+ micro_val = szcs.get(f"{{{W}}}val")
147
+ if micro_val is not None:
148
+ _findings.append({"type": "microfont", "text": text,
149
+ "size_pt": int(micro_val) / 2, "path": _path})
150
+
151
+ color = eff.find(f"{{{W}}}color")
152
+ if color is not None:
153
+ val = (color.get(f"{{{W}}}val") or "").lower()
154
+ tc = (color.get(f"{{{W}}}themeColor") or "").lower()
155
+ if val in ("ffffff", "white"):
156
+ _findings.append({"type": "white_text_candidate", "text": text,
157
+ "color": "ffffff", "path": _path})
158
+ else:
159
+ if _near_white(val):
160
+ _findings.append({"type": "near_white", "text": text,
161
+ "color": val, "path": _path})
162
+ if tc in ("light1", "light2", "background1", "background2") or \
163
+ (tc == "auto" and val in ("ffffff", "white", "auto")):
164
+ _findings.append({"type": "theme_white", "text": text,
165
+ "themeColor": tc or val, "path": _path})
166
+
167
+
168
+ def _near_white(val: str) -> bool:
169
+ if not re.fullmatch(r"[0-9a-f]{6}", val):
170
+ return False
171
+ try:
172
+ r = int(val[0:2], 16)
173
+ g = int(val[2:4], 16)
174
+ b = int(val[4:6], 16)
175
+ except ValueError:
176
+ return False
177
+ dr, dg, db = 255 - r, 255 - g, 255 - b
178
+ if dr <= 8 and dg <= 8 and db <= 8:
179
+ return True
180
+ return (dr * dr + dg * dg + db * db) ** 0.5 < 16
181
+
182
+
183
+ def _filter_white_by_dark_cells(root, findings: list) -> list:
184
+ dark_cells = [tc for tbl in root.iter(f"{{{W}}}tbl")
185
+ for tc in tbl.iter(f"{{{W}}}tc") if _has_dark_cell_shading(tc)]
186
+ out = []
187
+ for f in findings:
188
+ if f.get("type") != "white_text_candidate":
189
+ out.append(f)
190
+ continue
191
+ in_dark = any(f["text"] in _extract_text(tc) for tc in dark_cells)
192
+ if not in_dark:
193
+ f["type"] = "white_text_on_light_bg"
194
+ f["note"] = "confirmed: white text on light/transparent background"
195
+ out.append(f)
196
+ return out
197
+
198
+
199
+ def _scan_part(zf: zipfile.ZipFile, part: str, findings: list, styles=None) -> None:
200
+ root = ET.fromstring(zf.read(part))
201
+ smap = _styles_map(styles)
202
+ if not smap:
203
+ try:
204
+ if "word/styles.xml" in set(zf.namelist()):
205
+ smap = _styles_map(ET.fromstring(zf.read("word/styles.xml")))
206
+ except Exception: # noqa: BLE001
207
+ smap = {}
208
+ _scan_runs(root, findings, path=part, styles=smap)
209
+ findings[:] = _filter_white_by_dark_cells(root, findings) if False else findings
210
+ # filter only newly added white candidates for this part without disturbing prior findings:
211
+ # re-run filter on tail is complex; apply filter to whole findings is idempotent for non-candidates.
212
+ filtered = _filter_white_by_dark_cells(root, findings)
213
+ findings.clear()
214
+ findings.extend(filtered)
215
+ for tag in ("ins", "del"):
216
+ for el in root.iter(f"{{{W}}}{tag}"):
217
+ txt = _extract_text(el)
218
+ if txt:
219
+ findings.append({"type": "tracked_change", "text": txt, "path": part})
220
+
221
+
222
+ def _domain(url: str) -> str | None:
223
+ if not url:
224
+ return None
225
+ u = url.strip().lower()
226
+ if "://" in u:
227
+ u = u.split("://", 1)[1]
228
+ u = u.split("/", 1)[0].split("?", 1)[0].split("#", 1)[0]
229
+ if ":" in u:
230
+ u = u.split(":", 1)[0]
231
+ return u or None
232
+
233
+
234
+ def _domain_from_text(text: str) -> str | None:
235
+ import re
236
+ if not text:
237
+ return None
238
+ m = re.search(r"(?:https?://)?(?:www\.)?([a-z0-9][a-z0-9.-]*\.[a-z]{2,})", text.lower())
239
+ return m.group(1) if m else None
240
+
241
+
242
+ def _suspicious_metadata(text: str) -> bool:
243
+ if not text or not text.strip():
244
+ return False
245
+ invisible = ("\u200b", "\u200c", "\u200d", "\u2060", "\ufeff", "\u202a", "\u202b", "\u202c", "\u202d", "\u202e")
246
+ if any(ch in text for ch in invisible):
247
+ return True
248
+ low = text.lower()
249
+ keys = ("инструкц", "ignore", "system", "prompt", "assistant", "disregard",
250
+ "jailbreak", "<", "w:t", "http://", "https://")
251
+ return any(k in low for k in keys)
252
+
253
+
254
+ def _check_field(instr: str, display: str, findings: list, path: str) -> None:
255
+ if not instr:
256
+ return
257
+ iu = instr.upper()
258
+ benign = ("PAGE" in iu and "HYPERLINK" not in iu) or ("NUMPAGES" in iu and "HYPERLINK" not in iu)
259
+ if (not benign) and ("W:T" in iu or "<" in instr or "instrText" in instr):
260
+ findings.append({"type": "field_code", "text": instr.strip(), "path": path})
261
+ if "HYPERLINK" in iu:
262
+ import re
263
+ m = re.search(r'HYPERLINK\s+"([^"]+)"', instr, re.I)
264
+ url = m.group(1) if m else None
265
+ td = _domain(url) if url else None
266
+ dd = _domain_from_text(display)
267
+ if td and dd and td != dd:
268
+ findings.append({"type": "link_mismatch", "text": display, "target": url, "path": path})
269
+
270
+
271
+ def _scan_hyperlinks_and_fields(root, findings: list, path: str, rels: dict) -> None:
272
+ for hp in root.iter(f"{{{W}}}hyperlink"):
273
+ rid = hp.get(f"{{{R}}}id")
274
+ target = rels.get(rid) if rid else None
275
+ if target is None:
276
+ target = hp.get(f"{{{W}}}anchor")
277
+ display = "".join(t.text or "" for t in hp.iter(f"{{{W}}}t"))
278
+ td = _domain(target) if target and ("://" in (target or "") or "." in (target or "")) else None
279
+ # only treat as URL domain if target looks like url
280
+ if target and "://" not in (target or "") and not (target or "").startswith("http"):
281
+ td = None
282
+ dd = _domain_from_text(display)
283
+ if td and dd and td != dd:
284
+ findings.append({"type": "link_mismatch", "text": display, "target": target, "path": path})
285
+
286
+ for fs in root.iter(f"{{{W}}}fldSimple"):
287
+ instr = fs.get(f"{{{W}}}instr", "")
288
+ display = "".join(t.text or "" for t in fs.iter(f"{{{W}}}t"))
289
+ _check_field(instr, display, findings, path)
290
+
291
+ for p in root.iter(f"{{{W}}}p"):
292
+ instrs = [it.text or "" for it in p.iter(f"{{{W}}}instrText")]
293
+ if not instrs:
294
+ continue
295
+ display = "".join(t.text or "" for t in p.iter(f"{{{W}}}t"))
296
+ _check_field(" ".join(instrs), display, findings, path)
297
+
298
+
299
+ def scan_all(filepath: str) -> dict:
300
+ findings: list = []
301
+ path = str(filepath)
302
+ try:
303
+ with zipfile.ZipFile(path) as zf:
304
+ names = set(zf.namelist())
305
+ if "word/document.xml" not in names:
306
+ return {"file": path, "clean": False,
307
+ "findings": [{"type": "not_a_docx", "path": "word/document.xml"}]}
308
+
309
+ smap = {}
310
+ if "word/styles.xml" in names:
311
+ try:
312
+ smap = _styles_map(ET.fromstring(zf.read("word/styles.xml")))
313
+ except Exception: # noqa: BLE001
314
+ findings.append({"type": "xml_parse_error", "path": "word/styles.xml"})
315
+
316
+ parts = sorted(n for n in names if (n.startswith("word/") or n.startswith("docProps/")) and n.endswith(".xml"))
317
+ for part in parts:
318
+ try:
319
+ root = ET.fromstring(zf.read(part))
320
+ except ET.ParseError:
321
+ findings.append({"type": "xml_parse_error", "path": part})
322
+ continue
323
+
324
+ if part.startswith("docProps/"):
325
+ text = " ".join(root.itertext())
326
+ if _suspicious_metadata(text):
327
+ findings.append({"type": "metadata_injection", "text": text.strip(), "path": part})
328
+ continue
329
+
330
+ if not list(root.iter(f"{{{W}}}t")) and part not in ("word/document.xml",):
331
+ # metadata-ish word parts may have no t; still scan fields
332
+ pass
333
+
334
+ part_findings = []
335
+ _scan_runs(root, part_findings, path=part, styles=smap)
336
+ part_findings = _filter_white_by_dark_cells(root, part_findings)
337
+ for tag in ("ins", "del"):
338
+ for el in root.iter(f"{{{W}}}{tag}"):
339
+ txt = _extract_text(el)
340
+ if txt:
341
+ part_findings.append({"type": "tracked_change", "text": txt, "path": part})
342
+
343
+ rels = {}
344
+ rels_name = part.replace("word/", "word/_rels/", 1) + ".rels" if part.startswith("word/") else None
345
+ if rels_name and rels_name in names:
346
+ try:
347
+ rroot = ET.fromstring(zf.read(rels_name))
348
+ for rel in rroot.iter(f"{{{REL_NS}}}Relationship"):
349
+ rid = rel.get("Id")
350
+ target = rel.get("Target")
351
+ if rid and target:
352
+ rels[rid] = target
353
+ except ET.ParseError:
354
+ pass
355
+ _scan_hyperlinks_and_fields(root, part_findings, part, rels)
356
+ findings.extend(part_findings)
357
+ except FileNotFoundError:
358
+ return {"file": path, "clean": False,
359
+ "findings": [{"type": "io_error", "path": path, "reason": "file not found"}]}
360
+ except zipfile.BadZipFile:
361
+ return {"file": path, "clean": False,
362
+ "findings": [{"type": "invalid_zip", "path": path}]}
363
+ except OSError as exc:
364
+ return {"file": path, "clean": False,
365
+ "findings": [{"type": "io_error", "path": path, "reason": str(exc)}]}
366
+ except Exception as exc: # noqa: BLE001
367
+ return {"file": path, "clean": False,
368
+ "findings": [{"type": "io_error", "path": path, "reason": str(exc)}]}
369
+ return {"file": path, "clean": not findings, "findings": findings}