python-hwpx 2.18.0__py3-none-any.whl → 2.19.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.
- hwpx/tools/read_fidelity.py +281 -0
- hwpx/visual/_hancom_open_rate.ps1 +374 -0
- hwpx/visual/oracle.py +185 -0
- {python_hwpx-2.18.0.dist-info → python_hwpx-2.19.0.dist-info}/METADATA +1 -1
- {python_hwpx-2.18.0.dist-info → python_hwpx-2.19.0.dist-info}/RECORD +10 -8
- {python_hwpx-2.18.0.dist-info → python_hwpx-2.19.0.dist-info}/WHEEL +0 -0
- {python_hwpx-2.18.0.dist-info → python_hwpx-2.19.0.dist-info}/entry_points.txt +0 -0
- {python_hwpx-2.18.0.dist-info → python_hwpx-2.19.0.dist-info}/licenses/LICENSE +0 -0
- {python_hwpx-2.18.0.dist-info → python_hwpx-2.19.0.dist-info}/licenses/NOTICE +0 -0
- {python_hwpx-2.18.0.dist-info → python_hwpx-2.19.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Content-level read-fidelity harness for HWPX (M6 / S-060).
|
|
3
|
+
|
|
4
|
+
Where :mod:`hwpx.tools.roundtrip_diff` measures *element-count* preservation,
|
|
5
|
+
this module measures *content* fidelity:
|
|
6
|
+
|
|
7
|
+
* :func:`resolve_run_spans` — the canonical per-run resolved formatting
|
|
8
|
+
(bold / italic / underline / strikeout / color / size / font). This is the
|
|
9
|
+
single source of truth the installed MCP surface is verified against.
|
|
10
|
+
* :func:`collect_notes` — footnote / endnote instances with their body text and
|
|
11
|
+
body run formatting (the reading surfaces drop these today).
|
|
12
|
+
* :func:`roundtrip_fidelity` / :func:`corpus_fidelity` — open->save->reopen
|
|
13
|
+
agreement, the lossless guard.
|
|
14
|
+
* :func:`spans_fidelity` / :func:`notes_fidelity` — general comparators reused
|
|
15
|
+
to score a candidate extraction (e.g. an MCP tool payload) against the
|
|
16
|
+
canonical one.
|
|
17
|
+
|
|
18
|
+
Purely structural — no Hancom oracle required.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Sequence
|
|
25
|
+
|
|
26
|
+
from hwpx.document import HwpxDocument
|
|
27
|
+
|
|
28
|
+
_HH = "{http://www.hancom.co.kr/hwpml/2011/head}"
|
|
29
|
+
#: Values that mean "attribute is present but inactive".
|
|
30
|
+
_OFF = {None, "", "NONE"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class RunSpan:
|
|
35
|
+
"""Resolved inline formatting of a single ``<hp:run>``."""
|
|
36
|
+
|
|
37
|
+
text: str
|
|
38
|
+
bold: bool = False
|
|
39
|
+
italic: bool = False
|
|
40
|
+
underline: str | None = None # underline TYPE (BOTTOM/CENTER/TOP...) or None when off
|
|
41
|
+
strikeout: bool = False
|
|
42
|
+
color: str | None = None
|
|
43
|
+
size_pt: float | None = None
|
|
44
|
+
font: str | None = None
|
|
45
|
+
superscript: bool = False
|
|
46
|
+
subscript: bool = False
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict[str, Any]:
|
|
49
|
+
return {
|
|
50
|
+
"text": self.text,
|
|
51
|
+
"bold": self.bold,
|
|
52
|
+
"italic": self.italic,
|
|
53
|
+
"underline": self.underline,
|
|
54
|
+
"strikeout": self.strikeout,
|
|
55
|
+
"color": self.color,
|
|
56
|
+
"sizePt": self.size_pt,
|
|
57
|
+
"font": self.font,
|
|
58
|
+
"superscript": self.superscript,
|
|
59
|
+
"subscript": self.subscript,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class NoteSpan:
|
|
65
|
+
"""A footnote / endnote instance and its resolved body."""
|
|
66
|
+
|
|
67
|
+
kind: str # "footNote" | "endNote"
|
|
68
|
+
inst_id: str | None
|
|
69
|
+
anchor_para_index: int
|
|
70
|
+
body_text: str
|
|
71
|
+
body_spans: tuple[RunSpan, ...] = field(default_factory=tuple)
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> dict[str, Any]:
|
|
74
|
+
return {
|
|
75
|
+
"kind": self.kind,
|
|
76
|
+
"instId": self.inst_id,
|
|
77
|
+
"anchorParaIndex": self.anchor_para_index,
|
|
78
|
+
"bodyText": self.body_text,
|
|
79
|
+
"bodySpans": [s.to_dict() for s in self.body_spans],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ── resolution ───────────────────────────────────────────────────────
|
|
84
|
+
def _fontface_maps(doc: HwpxDocument) -> dict[str, dict[str, str]]:
|
|
85
|
+
"""Return ``{lang: {font_id: face_name}}`` from every header's fontfaces."""
|
|
86
|
+
maps: dict[str, dict[str, str]] = {}
|
|
87
|
+
for header in getattr(doc.oxml, "headers", []) or []:
|
|
88
|
+
element = getattr(header, "element", None)
|
|
89
|
+
if element is None:
|
|
90
|
+
continue
|
|
91
|
+
for fontface in element.iter(f"{_HH}fontface"):
|
|
92
|
+
lang = (fontface.get("lang") or "").lower()
|
|
93
|
+
bucket = maps.setdefault(lang, {})
|
|
94
|
+
for font in fontface.findall(f"{_HH}font"):
|
|
95
|
+
fid, face = font.get("id"), font.get("face")
|
|
96
|
+
if fid is not None and face is not None:
|
|
97
|
+
bucket.setdefault(fid, face)
|
|
98
|
+
return maps
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _resolve_font(style: Any, fontfaces: dict[str, dict[str, str]]) -> str | None:
|
|
102
|
+
font_ref = (style.child_attributes.get("fontRef") if style else None) or {}
|
|
103
|
+
fid = font_ref.get("hangul")
|
|
104
|
+
if fid is None:
|
|
105
|
+
fid = next(iter(font_ref.values()), None)
|
|
106
|
+
if fid is None:
|
|
107
|
+
return None
|
|
108
|
+
return fontfaces.get("hangul", {}).get(fid) or None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _int(value: Any, default: int = 0) -> int:
|
|
112
|
+
try:
|
|
113
|
+
return int(str(value))
|
|
114
|
+
except (TypeError, ValueError):
|
|
115
|
+
return default
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _style_to_span(text: str, style: Any, fontfaces: dict[str, dict[str, str]]) -> RunSpan:
|
|
119
|
+
if style is None:
|
|
120
|
+
return RunSpan(text=text)
|
|
121
|
+
child = style.child_attributes or {}
|
|
122
|
+
|
|
123
|
+
underline_type = (child.get("underline") or {}).get("type")
|
|
124
|
+
if underline_type in _OFF:
|
|
125
|
+
underline_type = None
|
|
126
|
+
|
|
127
|
+
strike_shape = (child.get("strikeout") or {}).get("shape")
|
|
128
|
+
strikeout = strike_shape not in _OFF
|
|
129
|
+
|
|
130
|
+
height = style.attributes.get("height")
|
|
131
|
+
size_pt = round(_int(height) / 100.0, 2) if height and _int(height) > 0 else None
|
|
132
|
+
|
|
133
|
+
offset_h = _int((child.get("offset") or {}).get("hangul"))
|
|
134
|
+
|
|
135
|
+
return RunSpan(
|
|
136
|
+
text=text,
|
|
137
|
+
bold="bold" in child,
|
|
138
|
+
italic="italic" in child,
|
|
139
|
+
underline=underline_type,
|
|
140
|
+
strikeout=strikeout,
|
|
141
|
+
color=style.attributes.get("textColor"),
|
|
142
|
+
size_pt=size_pt,
|
|
143
|
+
font=_resolve_font(style, fontfaces),
|
|
144
|
+
superscript=offset_h > 0,
|
|
145
|
+
subscript=offset_h < 0,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def fontface_maps(doc: HwpxDocument) -> dict[str, dict[str, str]]:
|
|
150
|
+
"""Public: ``{lang: {font_id: face_name}}`` for surface reuse (MCP)."""
|
|
151
|
+
return _fontface_maps(doc)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def run_span(text: str, style: Any, fontfaces: dict[str, dict[str, str]] | None = None) -> RunSpan:
|
|
155
|
+
"""Public: resolve one run's :class:`RunSpan` (fontfaces from :func:`fontface_maps`)."""
|
|
156
|
+
return _style_to_span(text, style, fontfaces or {})
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def resolve_run_spans(doc: HwpxDocument) -> list[RunSpan]:
|
|
160
|
+
"""Return the resolved inline formatting for every body run, in order."""
|
|
161
|
+
fontfaces = _fontface_maps(doc)
|
|
162
|
+
spans: list[RunSpan] = []
|
|
163
|
+
for section in doc.sections:
|
|
164
|
+
for paragraph in section.paragraphs:
|
|
165
|
+
for run in paragraph.runs:
|
|
166
|
+
spans.append(_style_to_span(run.text or "", run.style, fontfaces))
|
|
167
|
+
return spans
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def collect_notes(doc: HwpxDocument) -> list[NoteSpan]:
|
|
171
|
+
"""Return every footnote / endnote with body text and body formatting."""
|
|
172
|
+
fontfaces = _fontface_maps(doc)
|
|
173
|
+
notes: list[NoteSpan] = []
|
|
174
|
+
para_index = 0
|
|
175
|
+
for section in doc.sections:
|
|
176
|
+
for paragraph in section.paragraphs:
|
|
177
|
+
for note in list(paragraph.footnotes) + list(paragraph.endnotes):
|
|
178
|
+
body_spans: tuple[RunSpan, ...] = ()
|
|
179
|
+
try:
|
|
180
|
+
body = note.body_paragraph
|
|
181
|
+
body_spans = tuple(
|
|
182
|
+
_style_to_span(r.text or "", r.style, fontfaces) for r in body.runs
|
|
183
|
+
)
|
|
184
|
+
except Exception: # pragma: no cover - defensive
|
|
185
|
+
body_spans = ()
|
|
186
|
+
notes.append(
|
|
187
|
+
NoteSpan(
|
|
188
|
+
kind=note.kind,
|
|
189
|
+
inst_id=note.inst_id,
|
|
190
|
+
anchor_para_index=para_index,
|
|
191
|
+
body_text=note.text,
|
|
192
|
+
body_spans=body_spans,
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
para_index += 1
|
|
196
|
+
return notes
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ── comparators ──────────────────────────────────────────────────────
|
|
200
|
+
def _first_mismatch(ref: Sequence[Any], cand: Sequence[Any]) -> dict[str, Any] | None:
|
|
201
|
+
for i, a in enumerate(ref):
|
|
202
|
+
b = cand[i] if i < len(cand) else None
|
|
203
|
+
if a != b:
|
|
204
|
+
return {"index": i, "ref": a.to_dict() if a is not None else None,
|
|
205
|
+
"cand": b.to_dict() if b is not None else None}
|
|
206
|
+
if len(cand) > len(ref):
|
|
207
|
+
return {"index": len(ref), "ref": None, "cand": cand[len(ref)].to_dict()}
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def spans_fidelity(ref: Sequence[RunSpan], cand: Sequence[RunSpan]) -> dict[str, Any]:
|
|
212
|
+
"""Fraction of reference run-spans reproduced identically by ``cand``."""
|
|
213
|
+
denom = max(len(ref), 1)
|
|
214
|
+
same = sum(1 for i, a in enumerate(ref) if i < len(cand) and a == cand[i])
|
|
215
|
+
return {
|
|
216
|
+
"count_ref": len(ref),
|
|
217
|
+
"count_cand": len(cand),
|
|
218
|
+
"same": same,
|
|
219
|
+
"fidelity": same / denom,
|
|
220
|
+
"count_match": len(ref) == len(cand),
|
|
221
|
+
"first_mismatch": _first_mismatch(ref, cand),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def notes_fidelity(ref: Sequence[NoteSpan], cand: Sequence[NoteSpan]) -> dict[str, Any]:
|
|
226
|
+
"""Fraction of reference notes reproduced identically by ``cand``."""
|
|
227
|
+
denom = max(len(ref), 1)
|
|
228
|
+
same = sum(1 for i, a in enumerate(ref) if i < len(cand) and a == cand[i])
|
|
229
|
+
return {
|
|
230
|
+
"count_ref": len(ref),
|
|
231
|
+
"count_cand": len(cand),
|
|
232
|
+
"same": same,
|
|
233
|
+
"fidelity": same / denom,
|
|
234
|
+
"count_match": len(ref) == len(cand),
|
|
235
|
+
"first_mismatch": _first_mismatch(ref, cand),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ── round-trip ───────────────────────────────────────────────────────
|
|
240
|
+
def roundtrip_fidelity(source: str | Path | bytes) -> dict[str, Any]:
|
|
241
|
+
"""Open->serialize->reopen and score run-format + note preservation."""
|
|
242
|
+
before = HwpxDocument.open(source)
|
|
243
|
+
runs_before = resolve_run_spans(before)
|
|
244
|
+
notes_before = collect_notes(before)
|
|
245
|
+
|
|
246
|
+
after = HwpxDocument.open(before.to_bytes())
|
|
247
|
+
runs_after = resolve_run_spans(after)
|
|
248
|
+
notes_after = collect_notes(after)
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
"run_format": spans_fidelity(runs_before, runs_after),
|
|
252
|
+
"notes": notes_fidelity(notes_before, notes_after),
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def corpus_fidelity(paths: Sequence[str | Path]) -> dict[str, Any]:
|
|
257
|
+
"""Aggregate run-format round-trip fidelity across a corpus."""
|
|
258
|
+
per_file: dict[str, Any] = {}
|
|
259
|
+
total_runs = same_runs = 0
|
|
260
|
+
below: list[dict[str, Any]] = []
|
|
261
|
+
errors: dict[str, str] = {}
|
|
262
|
+
for path in paths:
|
|
263
|
+
key = str(path)
|
|
264
|
+
try:
|
|
265
|
+
report = roundtrip_fidelity(path)["run_format"]
|
|
266
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
267
|
+
errors[key] = repr(exc)[:200]
|
|
268
|
+
continue
|
|
269
|
+
per_file[key] = report
|
|
270
|
+
total_runs += report["count_ref"]
|
|
271
|
+
same_runs += report["same"]
|
|
272
|
+
if report["fidelity"] < 1.0 or not report["count_match"]:
|
|
273
|
+
below.append({"file": key, "fidelity": report["fidelity"], "runs": report["count_ref"]})
|
|
274
|
+
return {
|
|
275
|
+
"files": len(per_file),
|
|
276
|
+
"total_runs": total_runs,
|
|
277
|
+
"run_format_fidelity": (same_runs / total_runs) if total_runs else 1.0,
|
|
278
|
+
"files_below_100pct": below,
|
|
279
|
+
"errors": errors,
|
|
280
|
+
"per_file": per_file,
|
|
281
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Measure the real-Hancom (한글) OPEN rate of a set of .hwpx files via COM.
|
|
4
|
+
|
|
5
|
+
.DESCRIPTION
|
|
6
|
+
The hardened open-only oracle primitive for the M9 open-rate apparatus
|
|
7
|
+
(specs/007-open-rate). Cloned from scripts/hancom_com_open_verify.ps1 and
|
|
8
|
+
hardened per FR-002:
|
|
9
|
+
|
|
10
|
+
(a) SetMessageBoxMode(0x00020000) so corrupt-file / repair / save modals
|
|
11
|
+
do NOT block a babysat batch.
|
|
12
|
+
(b) Per-file checkpoint: each verdict record is APPENDED to --OutJsonl
|
|
13
|
+
IMMEDIATELY after Open(), so a mid-batch crash loses at most one file.
|
|
14
|
+
(c) RESUME (FR-002, this build): a re-run does NOT truncate the JSONL. On
|
|
15
|
+
startup it reads the existing checkpoint and SKIPS every file with a
|
|
16
|
+
FINAL verdict (a clean load, a clean refusal, or a completed retry). A
|
|
17
|
+
not-yet-retried COM-EXCEPTION error is NOT final and is re-attempted, so a
|
|
18
|
+
run killed mid-retry never freezes a clean file as a permanent failure.
|
|
19
|
+
After a hang/kill the owner re-runs; already-cleared clean popups are not
|
|
20
|
+
shown twice. To force a clean re-measure, delete --OutJsonl first.
|
|
21
|
+
(d) A single retry pass over files that errored on THIS run's pass 1; the
|
|
22
|
+
retried record (retried=$true) is appended and wins the basename join, so
|
|
23
|
+
the aggregator treats a retry-only open as NON-clean (never inflates the
|
|
24
|
+
opens-clean headline).
|
|
25
|
+
(e) Text-scan watchdog: the GetPageText loop (the free "parsed" tier signal)
|
|
26
|
+
is bounded by -OpenTimeoutSec so a pathological page scan cannot stall the
|
|
27
|
+
batch.
|
|
28
|
+
|
|
29
|
+
The script reuses ONE COM session for the common path (Hancom startup
|
|
30
|
+
dominates) but re-creates the session after any COM exception.
|
|
31
|
+
|
|
32
|
+
Open uses the fixed Hancom 2022 (v12) 3-arg signature Open(path,"","")
|
|
33
|
+
(auto-detect format), matching src/hwpx/visual/_render_hwpx.ps1.
|
|
34
|
+
|
|
35
|
+
*** LIMIT — a HUNG Open() ***
|
|
36
|
+
Open() is a synchronous STA COM call; it CANNOT be interrupted in-process
|
|
37
|
+
without killing the Hangul process (which would also kill an unrelated Hangul
|
|
38
|
+
the owner may have open). This script does NOT auto-kill on an Open() hang.
|
|
39
|
+
Mitigation relies on RESUME (c): if an Open() hangs, the babysitter kills the
|
|
40
|
+
run (Ctrl-C / close Hangul), re-runs, and RESUME skips every already-judged
|
|
41
|
+
file. A file that hangs Open() *persistently* is moved aside by the operator
|
|
42
|
+
(it stays unjudged → the aggregator reports it as unverified, coverage-visible).
|
|
43
|
+
An automatic per-file process-kill watchdog is deferred to the TARGET (unattended
|
|
44
|
+
CI) build and must be validated on the box before it is trusted.
|
|
45
|
+
|
|
46
|
+
*** BOX-VERIFICATION REQUIRED (FR-002, opens-clean tier) ***
|
|
47
|
+
SetMessageBoxMode(0x00020000) suppresses the modal, but the SUPPRESSED DEFAULT
|
|
48
|
+
ACTION must NOT be silent auto-repair. If Hancom's default answer to the
|
|
49
|
+
"손상된 파일을 복구하시겠습니까?" dialog is "복구"(repair), a corrupt input would be
|
|
50
|
+
auto-repaired and Open() would return $true — miscounting a broken file as
|
|
51
|
+
opens-clean. This is a Windows/Hancom-build behaviour that CANNOT be checked on
|
|
52
|
+
a Mac. The negative controls (FR-005) are the real check: the must_refuse
|
|
53
|
+
``synthetic_corrupt_section`` canary is a structurally valid package whose body
|
|
54
|
+
is garbage — if it reports opened=$true, the default action is auto-repair and
|
|
55
|
+
the harness is INVALID (corpus_open_rate.py fails closed). Open the negatives
|
|
56
|
+
FIRST (spike gate) so a leak surfaces before the full sitting.
|
|
57
|
+
|
|
58
|
+
Per-file signal: after a successful Open() the script records ``isModifiedProbe``
|
|
59
|
+
(Hwp.IsModified). This is a BOX-UNVERIFIED auto-repair hint — a clean load
|
|
60
|
+
should read $false. It is recorded for audit only; it is NOT mapped to the
|
|
61
|
+
aggregator's ``repaired`` (non-clean) field until the box run confirms it reads
|
|
62
|
+
$false for known-clean files (measure-first: an unverified property must never
|
|
63
|
+
silently collapse the headline).
|
|
64
|
+
|
|
65
|
+
Windows-only PowerShell (5.1-compatible). Syntactically validated off-box; the
|
|
66
|
+
real run happens on the .161 box.
|
|
67
|
+
|
|
68
|
+
.PARAMETER Path
|
|
69
|
+
One or more .hwpx file paths to open-check.
|
|
70
|
+
|
|
71
|
+
.PARAMETER MaxPages
|
|
72
|
+
Max pages to scan with GetPageText for the free "parsed" (textLength>0) signal.
|
|
73
|
+
|
|
74
|
+
.PARAMETER OutJsonl
|
|
75
|
+
Per-file checkpoint JSONL. One JSON object per line is APPENDED after each
|
|
76
|
+
Open(); a re-run resumes from it (skips already-judged files). Strongly
|
|
77
|
+
recommended for real (babysat) batches.
|
|
78
|
+
|
|
79
|
+
.PARAMETER OutJson
|
|
80
|
+
Optional path to write the final consolidated JSON array (prior + this run).
|
|
81
|
+
|
|
82
|
+
.PARAMETER OpenTimeoutSec
|
|
83
|
+
Bounds the per-file GetPageText scan loop (seconds). Does NOT bound a hung
|
|
84
|
+
Open() (see the LIMIT note above). Default 120.
|
|
85
|
+
|
|
86
|
+
.PARAMETER ProbeRepairMode
|
|
87
|
+
When set (and starting fresh), records the queried SetMessageBoxMode value in a
|
|
88
|
+
leading {"_meta":...} JSONL record for the receipt.
|
|
89
|
+
#>
|
|
90
|
+
param(
|
|
91
|
+
[Parameter(Mandatory = $true)]
|
|
92
|
+
[string[]] $Path,
|
|
93
|
+
|
|
94
|
+
[int] $MaxPages = 20,
|
|
95
|
+
|
|
96
|
+
[string] $OutJsonl = "",
|
|
97
|
+
|
|
98
|
+
[string] $OutJson = "",
|
|
99
|
+
|
|
100
|
+
[int] $OpenTimeoutSec = 120,
|
|
101
|
+
|
|
102
|
+
[switch] $ProbeRepairMode
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
$ErrorActionPreference = "Stop"
|
|
106
|
+
try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}
|
|
107
|
+
|
|
108
|
+
# 0x00020000 = HWP_MESSAGE_BOX_MODE: auto-answer message boxes (suppress modals).
|
|
109
|
+
$MESSAGE_BOX_MODE = 0x00020000
|
|
110
|
+
|
|
111
|
+
function Resolve-InputPath {
|
|
112
|
+
param([string] $InputPath)
|
|
113
|
+
$resolved = Resolve-Path -LiteralPath $InputPath
|
|
114
|
+
return $resolved.ProviderPath
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function Copy-ToTrustedTemp {
|
|
118
|
+
param([string] $InputPath)
|
|
119
|
+
$name = [System.IO.Path]::GetFileName($InputPath)
|
|
120
|
+
$dir = Join-Path $env:TEMP ("hwpx-open-rate-" + [System.Guid]::NewGuid().ToString("N"))
|
|
121
|
+
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
|
122
|
+
$target = Join-Path $dir $name
|
|
123
|
+
Copy-Item -LiteralPath $InputPath -Destination $target -Force
|
|
124
|
+
return @{ Directory = $dir; Path = $target }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function New-HwpObject {
|
|
128
|
+
$hwp = New-Object -ComObject "HWPFrame.HwpObject"
|
|
129
|
+
try {
|
|
130
|
+
$null = $hwp.RegisterModule("FilePathCheckerModule", "FilePathCheckerModuleExample")
|
|
131
|
+
} catch {
|
|
132
|
+
Write-Warning ("RegisterModule FilePathCheckerModule failed: " + $_.Exception.Message)
|
|
133
|
+
}
|
|
134
|
+
# FR-002a: suppress modal dialogs. The negative controls (FR-005) prove the
|
|
135
|
+
# suppressed default action is NOT silent auto-repair.
|
|
136
|
+
try { $null = $hwp.SetMessageBoxMode($MESSAGE_BOX_MODE) } catch {
|
|
137
|
+
Write-Warning ("SetMessageBoxMode failed: " + $_.Exception.Message)
|
|
138
|
+
}
|
|
139
|
+
return $hwp
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function Close-HwpObject {
|
|
143
|
+
param([object] $Hwp)
|
|
144
|
+
if ($null -ne $Hwp) {
|
|
145
|
+
try { $Hwp.Quit() | Out-Null } catch {}
|
|
146
|
+
try { [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($Hwp) | Out-Null } catch {}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function Read-HwpText {
|
|
151
|
+
param(
|
|
152
|
+
[object] $Hwp,
|
|
153
|
+
[int] $PageLimit,
|
|
154
|
+
[int] $TimeoutSec
|
|
155
|
+
)
|
|
156
|
+
# Primary: GetTextFile("TEXT","") — the canonical whole-document text extractor.
|
|
157
|
+
# Box finding 2026-07-01: GetPageText(n) returned 0 chars for ALL real produced
|
|
158
|
+
# docs on the .161 Hancom build (it depends on page-render state), which would
|
|
159
|
+
# collapse the parsed headline to 0%. GetTextFile does not depend on rendering.
|
|
160
|
+
try {
|
|
161
|
+
$whole = $Hwp.GetTextFile("TEXT", "")
|
|
162
|
+
if ($null -ne $whole -and -not [string]::IsNullOrWhiteSpace([string]$whole)) {
|
|
163
|
+
return [string]$whole
|
|
164
|
+
}
|
|
165
|
+
} catch {}
|
|
166
|
+
# Fallback: per-page scan (older builds where GetTextFile is unavailable).
|
|
167
|
+
$parts = New-Object System.Collections.Generic.List[string]
|
|
168
|
+
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
|
169
|
+
for ($page = 1; $page -le $PageLimit; $page++) {
|
|
170
|
+
# (e) text-scan watchdog: stop scanning if the page loop overruns.
|
|
171
|
+
if ($TimeoutSec -gt 0 -and $sw.Elapsed.TotalSeconds -gt $TimeoutSec) { break }
|
|
172
|
+
try {
|
|
173
|
+
$text = $Hwp.GetPageText($page)
|
|
174
|
+
} catch {
|
|
175
|
+
break
|
|
176
|
+
}
|
|
177
|
+
if ($null -eq $text -or [string]::IsNullOrWhiteSpace([string]$text)) {
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
$parts.Add([string]$text)
|
|
181
|
+
}
|
|
182
|
+
return ($parts -join "`n")
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# Append one record as a single JSON line to the checkpoint file (FR-002b).
|
|
186
|
+
function Write-Checkpoint {
|
|
187
|
+
param(
|
|
188
|
+
[string] $JsonlPath,
|
|
189
|
+
[object] $Record
|
|
190
|
+
)
|
|
191
|
+
if (-not $JsonlPath) { return }
|
|
192
|
+
$line = ($Record | ConvertTo-Json -Depth 4 -Compress)
|
|
193
|
+
Add-Content -LiteralPath $JsonlPath -Value $line -Encoding UTF8
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
# RESUME (FR-002c): basenames with a FINAL verdict in an existing checkpoint.
|
|
197
|
+
# FINAL = a clean load OR a clean refusal (error is null, opened true or false),
|
|
198
|
+
# OR a record from the single retry pass (retried=true). A COM-EXCEPTION error
|
|
199
|
+
# (error != null) with retried=false is NOT final — it is a to-be-retried state,
|
|
200
|
+
# so a resume RE-ATTEMPTS it. (Without this, a run killed mid-pass-2 would freeze a
|
|
201
|
+
# pass-1 exception as a permanent open_failure and silently DEFLATE the headline —
|
|
202
|
+
# a wrongly-low number is exactly as damaging as a wrongly-high one.) _meta probe
|
|
203
|
+
# lines have no sourcePath and are skipped.
|
|
204
|
+
function Get-JudgedBasenames {
|
|
205
|
+
param([string] $JsonlPath)
|
|
206
|
+
$set = @{}
|
|
207
|
+
if (-not $JsonlPath -or -not (Test-Path -LiteralPath $JsonlPath)) { return $set }
|
|
208
|
+
foreach ($line in (Get-Content -LiteralPath $JsonlPath -Encoding UTF8)) {
|
|
209
|
+
$line = $line.Trim()
|
|
210
|
+
if (-not $line) { continue }
|
|
211
|
+
try { $rec = $line | ConvertFrom-Json } catch { continue }
|
|
212
|
+
if ($rec.PSObject.Properties.Name -contains '_meta') { continue }
|
|
213
|
+
if (-not $rec.sourcePath) { continue }
|
|
214
|
+
$hasError = ($null -ne $rec.error) -and ([string]$rec.error -ne "")
|
|
215
|
+
$retried = [bool]$rec.retried
|
|
216
|
+
if ($hasError -and -not $retried) { continue } # unretried exception -> re-attempt on resume
|
|
217
|
+
$set[[System.IO.Path]::GetFileName([string]$rec.sourcePath)] = $true
|
|
218
|
+
}
|
|
219
|
+
return $set
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
# Prior verdicts (for the consolidated OutJson array).
|
|
223
|
+
function Read-ExistingRecords {
|
|
224
|
+
param([string] $JsonlPath)
|
|
225
|
+
$list = New-Object System.Collections.Generic.List[object]
|
|
226
|
+
if (-not $JsonlPath -or -not (Test-Path -LiteralPath $JsonlPath)) { return $list }
|
|
227
|
+
foreach ($line in (Get-Content -LiteralPath $JsonlPath -Encoding UTF8)) {
|
|
228
|
+
$line = $line.Trim()
|
|
229
|
+
if (-not $line) { continue }
|
|
230
|
+
try { $rec = $line | ConvertFrom-Json } catch { continue }
|
|
231
|
+
if ($rec.PSObject.Properties.Name -contains '_meta') { continue }
|
|
232
|
+
$list.Add($rec)
|
|
233
|
+
}
|
|
234
|
+
return $list
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
# Open one file and return the verdict record. A COM exception is the watchdog
|
|
238
|
+
# trip: the caller re-creates the HwpObject when $record.error is non-null.
|
|
239
|
+
function Invoke-OpenCheck {
|
|
240
|
+
param(
|
|
241
|
+
[object] $Hwp,
|
|
242
|
+
[string] $InputPath,
|
|
243
|
+
[int] $PageLimit,
|
|
244
|
+
[int] $TimeoutSec,
|
|
245
|
+
[bool] $Retried
|
|
246
|
+
)
|
|
247
|
+
$trusted = Copy-ToTrustedTemp $InputPath
|
|
248
|
+
$opened = $false
|
|
249
|
+
$text = ""
|
|
250
|
+
$errorMessage = $null
|
|
251
|
+
$isModifiedProbe = $null
|
|
252
|
+
$pageCount = $null
|
|
253
|
+
try {
|
|
254
|
+
# Hancom 2022 (v12): Open(path, format, arg); ("","") = auto-detect.
|
|
255
|
+
# NOTE (box finding 2026-07-01): Open() returns $true even for container-
|
|
256
|
+
# garbage (not-a-zip/empty/truncated), loading a BLANK doc with textLength=0.
|
|
257
|
+
# So 'opened' alone is NOT the headline — the aggregator uses PARSED
|
|
258
|
+
# (opened AND textLength>0). pageCount is a second "content really loaded"
|
|
259
|
+
# probe captured here so the headline bar can be set from real data.
|
|
260
|
+
$opened = [bool]$Hwp.Open($trusted.Path, "", "")
|
|
261
|
+
if ($opened) {
|
|
262
|
+
# BOX-UNVERIFIED auto-repair hint (recorded for audit; not headline).
|
|
263
|
+
try { $isModifiedProbe = [bool]$Hwp.IsModified } catch { $isModifiedProbe = $null }
|
|
264
|
+
try { $pageCount = [int]$Hwp.PageCount } catch { $pageCount = $null }
|
|
265
|
+
$text = Read-HwpText -Hwp $Hwp -PageLimit $PageLimit -TimeoutSec $TimeoutSec
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
$errorMessage = $_.Exception.Message
|
|
269
|
+
} finally {
|
|
270
|
+
try { $Hwp.Clear(1) | Out-Null } catch {}
|
|
271
|
+
Remove-Item -LiteralPath $trusted.Directory -Recurse -Force -ErrorAction SilentlyContinue
|
|
272
|
+
}
|
|
273
|
+
return [ordered]@{
|
|
274
|
+
sourcePath = $InputPath
|
|
275
|
+
opened = $opened
|
|
276
|
+
textLength = $text.Length
|
|
277
|
+
pageCount = $pageCount
|
|
278
|
+
textPreview = if ($text.Length -gt 500) { $text.Substring(0, 500) } else { $text }
|
|
279
|
+
error = $errorMessage
|
|
280
|
+
retried = $Retried
|
|
281
|
+
isModifiedProbe = $isModifiedProbe
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
# RESUME: load prior verdicts + the already-judged basename set. Do NOT truncate.
|
|
286
|
+
$judged = Get-JudgedBasenames $OutJsonl
|
|
287
|
+
$records = New-Object System.Collections.Generic.List[object]
|
|
288
|
+
foreach ($r in (Read-ExistingRecords $OutJsonl)) { $records.Add($r) }
|
|
289
|
+
$resuming = ($judged.Count -gt 0)
|
|
290
|
+
if ($resuming) {
|
|
291
|
+
Write-Host ("RESUME: " + $judged.Count + " file(s) already judged in " + $OutJsonl + " — skipping those.")
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if ($ProbeRepairMode -and -not $resuming) {
|
|
295
|
+
# Record the queried message-box mode for the receipt (fresh run only).
|
|
296
|
+
$probeHwp = $null
|
|
297
|
+
$modeValue = $null
|
|
298
|
+
$modeError = $null
|
|
299
|
+
try {
|
|
300
|
+
$probeHwp = New-Object -ComObject "HWPFrame.HwpObject"
|
|
301
|
+
try { $modeValue = $probeHwp.SetMessageBoxMode($MESSAGE_BOX_MODE) } catch { $modeError = $_.Exception.Message }
|
|
302
|
+
} catch {
|
|
303
|
+
$modeError = $_.Exception.Message
|
|
304
|
+
} finally {
|
|
305
|
+
Close-HwpObject $probeHwp
|
|
306
|
+
}
|
|
307
|
+
Write-Checkpoint -JsonlPath $OutJsonl -Record ([ordered]@{
|
|
308
|
+
_meta = "repair-mode-probe"
|
|
309
|
+
requestedMode = $MESSAGE_BOX_MODE
|
|
310
|
+
previousModeReturned = $modeValue
|
|
311
|
+
error = $modeError
|
|
312
|
+
note = "Box must confirm suppressed default action is NOT auto-repair; the must_refuse negative controls (FR-005) are the real check."
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
# Pass 1: open every not-yet-judged file, checkpoint immediately, re-create the
|
|
317
|
+
# session after any COM exception.
|
|
318
|
+
$thisRunErrorPaths = New-Object System.Collections.Generic.List[string]
|
|
319
|
+
$hwp = $null
|
|
320
|
+
try {
|
|
321
|
+
$hwp = New-HwpObject
|
|
322
|
+
foreach ($item in $Path) {
|
|
323
|
+
$inputPath = Resolve-InputPath $item
|
|
324
|
+
$base = [System.IO.Path]::GetFileName($inputPath)
|
|
325
|
+
if ($judged.ContainsKey($base)) {
|
|
326
|
+
Write-Host ("skip (already judged): " + $base)
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
$record = Invoke-OpenCheck -Hwp $hwp -InputPath $inputPath -PageLimit $MaxPages -TimeoutSec $OpenTimeoutSec -Retried $false
|
|
330
|
+
Write-Checkpoint -JsonlPath $OutJsonl -Record $record
|
|
331
|
+
$records.Add($record)
|
|
332
|
+
$judged[$base] = $true
|
|
333
|
+
if ($null -ne $record.error) {
|
|
334
|
+
$thisRunErrorPaths.Add($inputPath)
|
|
335
|
+
# Watchdog: a COM error may have poisoned the session — re-create it.
|
|
336
|
+
Close-HwpObject $hwp
|
|
337
|
+
$hwp = New-HwpObject
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
} finally {
|
|
341
|
+
Close-HwpObject $hwp
|
|
342
|
+
$hwp = $null
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
# Pass 2 (FR-002d): single retry over files that errored on THIS run's pass 1. A
|
|
346
|
+
# prior-run *unretried* exception is not treated as final — RESUME re-attempts it
|
|
347
|
+
# in pass 1 above (see Get-JudgedBasenames), so it flows here again if it re-errors.
|
|
348
|
+
# The retried record is appended and wins the aggregator's last-line basename join.
|
|
349
|
+
if ($thisRunErrorPaths.Count -gt 0) {
|
|
350
|
+
$hwp = $null
|
|
351
|
+
try {
|
|
352
|
+
$hwp = New-HwpObject
|
|
353
|
+
foreach ($inputPath in $thisRunErrorPaths) {
|
|
354
|
+
$record = Invoke-OpenCheck -Hwp $hwp -InputPath $inputPath -PageLimit $MaxPages -TimeoutSec $OpenTimeoutSec -Retried $true
|
|
355
|
+
Write-Checkpoint -JsonlPath $OutJsonl -Record $record
|
|
356
|
+
$records.Add($record)
|
|
357
|
+
if ($null -ne $record.error) {
|
|
358
|
+
Close-HwpObject $hwp
|
|
359
|
+
$hwp = New-HwpObject
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
} finally {
|
|
363
|
+
Close-HwpObject $hwp
|
|
364
|
+
$hwp = $null
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
# Final consolidated array (prior + this run), mirroring the legacy shape.
|
|
369
|
+
$json = $records | ConvertTo-Json -Depth 4
|
|
370
|
+
if ($OutJson) {
|
|
371
|
+
Set-Content -LiteralPath $OutJson -Value $json -Encoding UTF8
|
|
372
|
+
} else {
|
|
373
|
+
$json
|
|
374
|
+
}
|
hwpx/visual/oracle.py
CHANGED
|
@@ -50,6 +50,7 @@ from .report import VisualReport
|
|
|
50
50
|
from hwpx.form_fit.wordbox import WordBox
|
|
51
51
|
|
|
52
52
|
_BACKEND_SCRIPT = "_render_hwpx.ps1"
|
|
53
|
+
_OPEN_RATE_SCRIPT = "_hancom_open_rate.ps1"
|
|
53
54
|
_MAC_BACKEND_SCRIPT = "_render_hwpx_mac.applescript"
|
|
54
55
|
_COM_REGISTRY_KEYS = (
|
|
55
56
|
r"HWPFrame.HwpObject\CLSID",
|
|
@@ -193,6 +194,190 @@ class WindowsComOracle(RenderBackend):
|
|
|
193
194
|
os.close(handle)
|
|
194
195
|
return self.render_many([(hwpx_path, out_pdf)]).get(hwpx_path)
|
|
195
196
|
|
|
197
|
+
def open_check_many(self, paths: list[str]) -> list[dict[str, object]]:
|
|
198
|
+
"""OPEN-check ``paths`` through Hancom COM and return per-file verdicts.
|
|
199
|
+
|
|
200
|
+
This is the M9 open-rate primitive (specs/007-open-rate FR-001). It is
|
|
201
|
+
deliberately DISTINCT from :meth:`render_many`: it surfaces the real
|
|
202
|
+
Hancom ``opened`` boolean as its own signal — never conflated with the
|
|
203
|
+
``saved``/render verdict that :meth:`render_many` (and ``visual_check``
|
|
204
|
+
at oracle.py:402) report. ``opened`` answers "did real Hancom load this
|
|
205
|
+
generated file without a corruption modal", which is the published
|
|
206
|
+
open-rate; ``saved`` answers a different question (did it render to PDF).
|
|
207
|
+
|
|
208
|
+
Each entry is::
|
|
209
|
+
|
|
210
|
+
{
|
|
211
|
+
"path": str, # the input path as requested
|
|
212
|
+
"opened": bool | None, # True/False from Hancom; None = unverified
|
|
213
|
+
"parsed": bool | None, # opened and GetPageText(1..) textLength>0
|
|
214
|
+
"text_length": int | None,
|
|
215
|
+
"error": str | None,
|
|
216
|
+
"retried": bool, # opened only on the single retry pass
|
|
217
|
+
"status": str, # "ok" | "open_failed" | "unverified"
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
Honest degrade (constitution V/VI): off-Windows or where Hancom is not
|
|
221
|
+
reachable, EVERY entry is returned with ``opened=None`` and
|
|
222
|
+
``status="unverified"`` — NEVER ``False`` (that would slander a file we
|
|
223
|
+
never tested) and NEVER a silent ``True``. The aggregator maps
|
|
224
|
+
``unverified`` to the unverified bucket, not the numerator or denominator
|
|
225
|
+
success count.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
if not paths:
|
|
229
|
+
return []
|
|
230
|
+
if not self.available():
|
|
231
|
+
return [self._unverified_entry(p) for p in paths]
|
|
232
|
+
|
|
233
|
+
abs_paths = [os.path.abspath(p) for p in paths]
|
|
234
|
+
# path -> requested (original) string, for surfacing the caller's path.
|
|
235
|
+
requested = {os.path.abspath(p): p for p in paths}
|
|
236
|
+
|
|
237
|
+
tmp = tempfile.mkdtemp(prefix="hwpx-open-rate-")
|
|
238
|
+
try:
|
|
239
|
+
jsonl_path = os.path.join(tmp, "checkpoint.jsonl")
|
|
240
|
+
res_path = os.path.join(tmp, "result.json")
|
|
241
|
+
with resources.as_file(
|
|
242
|
+
resources.files("hwpx.visual").joinpath(_OPEN_RATE_SCRIPT)
|
|
243
|
+
) as ps1:
|
|
244
|
+
cmd = [
|
|
245
|
+
self._powershell, "-NoProfile", "-NonInteractive",
|
|
246
|
+
"-ExecutionPolicy", "Bypass", "-File", str(ps1),
|
|
247
|
+
"-OutJsonl", jsonl_path, "-OutJson", res_path,
|
|
248
|
+
"-Path", *abs_paths,
|
|
249
|
+
]
|
|
250
|
+
try:
|
|
251
|
+
subprocess.run(
|
|
252
|
+
cmd, capture_output=True,
|
|
253
|
+
timeout=self.timeout + 60.0 * len(paths), check=False,
|
|
254
|
+
)
|
|
255
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
256
|
+
# Subprocess never finished: prefer the crash-safe checkpoint
|
|
257
|
+
# (records written per-file) before degrading the rest.
|
|
258
|
+
return self._merge_checkpoint(abs_paths, requested, jsonl_path)
|
|
259
|
+
|
|
260
|
+
entries = self._read_open_result(res_path)
|
|
261
|
+
if entries is None:
|
|
262
|
+
# No parseable consolidated result: fall back to the checkpoint.
|
|
263
|
+
return self._merge_checkpoint(abs_paths, requested, jsonl_path)
|
|
264
|
+
return self._entries_from_records(abs_paths, requested, entries)
|
|
265
|
+
finally:
|
|
266
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def _unverified_entry(path: str) -> dict[str, object]:
|
|
270
|
+
return {
|
|
271
|
+
"path": path,
|
|
272
|
+
"opened": None,
|
|
273
|
+
"parsed": None,
|
|
274
|
+
"text_length": None,
|
|
275
|
+
"error": "OPEN_ORACLE_UNAVAILABLE: no Hancom reachable on this platform",
|
|
276
|
+
"retried": False,
|
|
277
|
+
"status": "unverified",
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _normalise_record(record: dict[str, object]) -> dict[str, object]:
|
|
282
|
+
"""Map one PS1 ``{sourcePath,opened,textLength,error,retried}`` record to
|
|
283
|
+
the ``open_check_many`` entry shape (open/render distinction preserved)."""
|
|
284
|
+
|
|
285
|
+
opened_raw = record.get("opened")
|
|
286
|
+
opened = bool(opened_raw) if opened_raw is not None else None
|
|
287
|
+
text_length = record.get("textLength")
|
|
288
|
+
try:
|
|
289
|
+
text_length = int(text_length) if text_length is not None else None
|
|
290
|
+
except (TypeError, ValueError):
|
|
291
|
+
text_length = None
|
|
292
|
+
error = record.get("error")
|
|
293
|
+
parsed: bool | None
|
|
294
|
+
if opened is None:
|
|
295
|
+
parsed = None
|
|
296
|
+
else:
|
|
297
|
+
parsed = bool(opened and (text_length or 0) > 0)
|
|
298
|
+
status = "ok" if opened else ("unverified" if opened is None else "open_failed")
|
|
299
|
+
return {
|
|
300
|
+
"path": record.get("sourcePath"),
|
|
301
|
+
"opened": opened,
|
|
302
|
+
"parsed": parsed,
|
|
303
|
+
"text_length": text_length,
|
|
304
|
+
"error": error,
|
|
305
|
+
"retried": bool(record.get("retried", False)),
|
|
306
|
+
"status": status,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _read_open_result(res_path: str) -> list[dict[str, object]] | None:
|
|
311
|
+
if not os.path.exists(res_path):
|
|
312
|
+
return None
|
|
313
|
+
try:
|
|
314
|
+
# PowerShell Set-Content -Encoding UTF8 prepends a BOM; utf-8-sig
|
|
315
|
+
# strips it (and reads BOM-less output fine too).
|
|
316
|
+
with open(res_path, encoding="utf-8-sig") as handle:
|
|
317
|
+
data = json.load(handle)
|
|
318
|
+
except (json.JSONDecodeError, ValueError, OSError):
|
|
319
|
+
return None
|
|
320
|
+
if isinstance(data, dict): # single file -> ConvertTo-Json emits an object
|
|
321
|
+
data = [data]
|
|
322
|
+
if not isinstance(data, list):
|
|
323
|
+
return None
|
|
324
|
+
return data
|
|
325
|
+
|
|
326
|
+
def _entries_from_records(
|
|
327
|
+
self,
|
|
328
|
+
abs_paths: list[str],
|
|
329
|
+
requested: dict[str, str],
|
|
330
|
+
records: list[dict[str, object]],
|
|
331
|
+
) -> list[dict[str, object]]:
|
|
332
|
+
"""Align PS1 records back to the requested order, degrading any missing
|
|
333
|
+
file to ``unverified`` (never silently dropped)."""
|
|
334
|
+
|
|
335
|
+
by_path: dict[str, dict[str, object]] = {}
|
|
336
|
+
for record in records:
|
|
337
|
+
if not isinstance(record, dict):
|
|
338
|
+
continue
|
|
339
|
+
if record.get("_meta"): # repair-mode-probe meta line, not a verdict
|
|
340
|
+
continue
|
|
341
|
+
norm = self._normalise_record(record)
|
|
342
|
+
src = norm.get("path")
|
|
343
|
+
if isinstance(src, str):
|
|
344
|
+
by_path[os.path.abspath(src)] = norm
|
|
345
|
+
out: list[dict[str, object]] = []
|
|
346
|
+
for abs_path in abs_paths:
|
|
347
|
+
norm = by_path.get(abs_path)
|
|
348
|
+
if norm is None:
|
|
349
|
+
out.append(self._unverified_entry(requested.get(abs_path, abs_path)))
|
|
350
|
+
else:
|
|
351
|
+
# Surface the caller's original path string.
|
|
352
|
+
norm["path"] = requested.get(abs_path, norm.get("path"))
|
|
353
|
+
out.append(norm)
|
|
354
|
+
return out
|
|
355
|
+
|
|
356
|
+
def _merge_checkpoint(
|
|
357
|
+
self,
|
|
358
|
+
abs_paths: list[str],
|
|
359
|
+
requested: dict[str, str],
|
|
360
|
+
jsonl_path: str,
|
|
361
|
+
) -> list[dict[str, object]]:
|
|
362
|
+
"""Recover verdicts from the per-file JSONL checkpoint after a crash or
|
|
363
|
+
timeout; files with no checkpoint record degrade to ``unverified``."""
|
|
364
|
+
|
|
365
|
+
records: list[dict[str, object]] = []
|
|
366
|
+
if os.path.exists(jsonl_path):
|
|
367
|
+
try:
|
|
368
|
+
with open(jsonl_path, encoding="utf-8-sig") as handle:
|
|
369
|
+
for line in handle:
|
|
370
|
+
line = line.strip()
|
|
371
|
+
if not line:
|
|
372
|
+
continue
|
|
373
|
+
try:
|
|
374
|
+
records.append(json.loads(line))
|
|
375
|
+
except (json.JSONDecodeError, ValueError):
|
|
376
|
+
continue
|
|
377
|
+
except OSError:
|
|
378
|
+
records = []
|
|
379
|
+
return self._entries_from_records(abs_paths, requested, records)
|
|
380
|
+
|
|
196
381
|
|
|
197
382
|
class MacHancomOracle(RenderBackend):
|
|
198
383
|
"""Adapter that renders ``.hwpx`` → PDF through ``Hancom Office HWP.app``.
|
|
@@ -117,6 +117,7 @@ hwpx/tools/package_reconcile.py,sha256=y1Hl7hbPh4YaV59LTdDLzQwgn4g1qEnFmSjmajnrE
|
|
|
117
117
|
hwpx/tools/package_validator.py,sha256=AA5wy6YgwlU6BTq1p2qCbCVCM8lmIBLhPANKCfaPb-s,29369
|
|
118
118
|
hwpx/tools/page_guard.py,sha256=nDAVPcvrnuyDxVTA_j22wiYD7CXAD6XlzsMzaz3h_q8,9701
|
|
119
119
|
hwpx/tools/pii.py,sha256=N3c36eqblaVQ7o6jiT1BV0WrJK_G3gKhQh6MQGAbvCs,12617
|
|
120
|
+
hwpx/tools/read_fidelity.py,sha256=g4r2GNVExEtL0C-6JMkPIjmH6CtJqNSS_-KsSPTnBbw,10541
|
|
120
121
|
hwpx/tools/recover.py,sha256=EOVAzMFAqR9YAT3sinZKCdjSkKygo4dKrs6T6SbGA7o,4963
|
|
121
122
|
hwpx/tools/redline.py,sha256=p6aMVDBOrkqywlrlxqhB-5ZjCEtgrCNa8cYeRMJlprk,6478
|
|
122
123
|
hwpx/tools/repair.py,sha256=wYO4Zd8ZMwkbYy2_EPz0eyqvTCrzcAyvbEsVE1P87Zk,11197
|
|
@@ -140,17 +141,18 @@ hwpx/tools/fuzz/generator.py,sha256=G2tGmo4-i-i0v_-rqHsNKZjfpD5wAJZRMsLTPu_KZfg,
|
|
|
140
141
|
hwpx/tools/fuzz/minimize.py,sha256=gv9fEU1PsmpXR-Z6wiejMC0umHV90GYhtO2Ht8x2__Y,1055
|
|
141
142
|
hwpx/tools/fuzz/runner.py,sha256=NkSrlWUf813SqEwkhi05LmusMgM9r2j2JiPCqzzvPN8,17906
|
|
142
143
|
hwpx/visual/__init__.py,sha256=KCmFghTLZjWA1reS-dHhdrn2i0FBUQMUv3qghZb7Fqo,2347
|
|
144
|
+
hwpx/visual/_hancom_open_rate.ps1,sha256=stgE7ID2DuIpHWZHi414hmPuvPKnbecPO-oLrFWYBSg,16016
|
|
143
145
|
hwpx/visual/_render_hwpx.ps1,sha256=r7bUPQvMMwVhz5YiUIEZPSAO18GbPPfnaK8iZb67d3w,2412
|
|
144
146
|
hwpx/visual/_render_hwpx_mac.applescript,sha256=ghyvFNwtJ3-0cfy8jQ6HyuJVnFFfXt_Uy5tHjccd5os,7620
|
|
145
147
|
hwpx/visual/detectors.py,sha256=WySbPnhyp4vTZWtkHoAiNY9G9AoZbVNgVgwaEZzt1R0,4853
|
|
146
148
|
hwpx/visual/diff.py,sha256=0X5T9IgwRZU3td-7vnPrlowovtGud7P_ymq0KVehlKk,5677
|
|
147
149
|
hwpx/visual/masks.py,sha256=oXhgynAb4uKjJtZ2BGHHdAjyvWGqSFlZFQ-iJxzHiuo,1832
|
|
148
|
-
hwpx/visual/oracle.py,sha256=
|
|
150
|
+
hwpx/visual/oracle.py,sha256=aON9dPbpbYnUoQ7b1aD5bez2d8BevYeZgWbxdIhP_sA,29746
|
|
149
151
|
hwpx/visual/report.py,sha256=2RhXN1KBYOZTim9FNpeUhaaDHR7oFxI6Z2DLUkDIiwE,1717
|
|
150
|
-
python_hwpx-2.
|
|
151
|
-
python_hwpx-2.
|
|
152
|
-
python_hwpx-2.
|
|
153
|
-
python_hwpx-2.
|
|
154
|
-
python_hwpx-2.
|
|
155
|
-
python_hwpx-2.
|
|
156
|
-
python_hwpx-2.
|
|
152
|
+
python_hwpx-2.19.0.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
|
|
153
|
+
python_hwpx-2.19.0.dist-info/licenses/NOTICE,sha256=k48h6EaGQE8Y1c0dS9sIOOcz4YqkbcImWClF7pBOgsg,2473
|
|
154
|
+
python_hwpx-2.19.0.dist-info/METADATA,sha256=R1wYmaa1RlVZuuDuGl4CyQsdl-56YSBbB1_IjZu1WGs,19982
|
|
155
|
+
python_hwpx-2.19.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
156
|
+
python_hwpx-2.19.0.dist-info/entry_points.txt,sha256=4U6WXYWHxEiWp2VRHo97fvOYNh7ebu6roonk7chxKcY,453
|
|
157
|
+
python_hwpx-2.19.0.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
|
|
158
|
+
python_hwpx-2.19.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|