python-hwpx 2.19.0__py3-none-any.whl → 2.20.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/toc_author.py +291 -0
- hwpx/tools/toc_fidelity.py +410 -0
- hwpx/visual/_refresh_hwpx_mac.applescript +162 -0
- hwpx/visual/_render_hwpx_mac.applescript +5 -1
- hwpx/visual/oracle.py +37 -0
- {python_hwpx-2.19.0.dist-info → python_hwpx-2.20.0.dist-info}/METADATA +1 -1
- {python_hwpx-2.19.0.dist-info → python_hwpx-2.20.0.dist-info}/RECORD +12 -9
- {python_hwpx-2.19.0.dist-info → python_hwpx-2.20.0.dist-info}/WHEEL +0 -0
- {python_hwpx-2.19.0.dist-info → python_hwpx-2.20.0.dist-info}/entry_points.txt +0 -0
- {python_hwpx-2.19.0.dist-info → python_hwpx-2.20.0.dist-info}/licenses/LICENSE +0 -0
- {python_hwpx-2.19.0.dist-info → python_hwpx-2.20.0.dist-info}/licenses/NOTICE +0 -0
- {python_hwpx-2.19.0.dist-info → python_hwpx-2.20.0.dist-info}/top_level.txt +0 -0
hwpx/tools/toc_author.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Author Hancom-native TOC and page cross-reference fields (M7 / S-062 P2).
|
|
3
|
+
|
|
4
|
+
Emits the exact field contract reverse-engineered from an owner-authored
|
|
5
|
+
Hancom document (``specs/009-native-toc-xrefs/evidence/
|
|
6
|
+
p0-native-toc-xml-contract.md``; gold pair vendored in
|
|
7
|
+
``tests/fixtures/m7_toc_gold/``):
|
|
8
|
+
|
|
9
|
+
* ``<hp:fieldBegin type="TABLEOFCONTENTS">`` + ``TableOfContents:set`` Command
|
|
10
|
+
DSL wrapping generated entry paragraphs (HYPERLINK fields whose single
|
|
11
|
+
``<hp:t>`` nests the dot-leader ``hp:tab``; page number in the tab's tail),
|
|
12
|
+
* ``<hp:fieldBegin type="CROSSREF">`` page references whose cached result is
|
|
13
|
+
the plain ``hp:t`` run between fieldBegin and fieldEnd (Hancom recomputes
|
|
14
|
+
these automatically on edit/save — P0 measured),
|
|
15
|
+
* anchors that are plain paragraph ``id`` attributes; TOC-targeted headings
|
|
16
|
+
therefore need document-unique ids (:func:`ensure_paragraph_anchor_id`).
|
|
17
|
+
|
|
18
|
+
Honest semantics (measured): entry page numbers emitted here are ESTIMATES —
|
|
19
|
+
Hancom only recomputes the TOC block on an explicit 차례 새로 고침, which the
|
|
20
|
+
P3 oracle triggers and verifies. CROSSREF caches self-heal on edit/save.
|
|
21
|
+
The parameter spelling ``Fiexde`` replicates Hancom's own output verbatim.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from typing import Any, Sequence
|
|
27
|
+
from uuid import uuid4
|
|
28
|
+
|
|
29
|
+
# live section paragraphs are lxml elements — new nodes must match
|
|
30
|
+
from lxml import etree as ET
|
|
31
|
+
|
|
32
|
+
from hwpx.document import HwpxDocument
|
|
33
|
+
|
|
34
|
+
_HP = "{http://www.hancom.co.kr/hwpml/2011/paragraph}"
|
|
35
|
+
_NON_UNIQUE_PARA_ID = "2147483648"
|
|
36
|
+
_OUTLINE_NAME_RE = re.compile(r"^(?:개요|Outline)\s*(\d+)$")
|
|
37
|
+
#: Measured TOC Command (gold contract). ContentsMake:uint:31 = the bitmask the
|
|
38
|
+
#: Hancom dialog emitted (title+table+figure+equation blocks); other values are
|
|
39
|
+
#: unmeasured, so v1 replicates the captured value verbatim.
|
|
40
|
+
_TOC_COMMAND = (
|
|
41
|
+
"TableOfContents:set:140:ContentsMake:uint:31 ContentsStyles:wstring:0: "
|
|
42
|
+
"ContentsLevel:int:{level} ContentsAutoTabRight:int:0 "
|
|
43
|
+
"ContentsLeader:int:{leader} ContentsHyperlink:bool:{hyperlink} "
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _rand_id() -> str:
|
|
48
|
+
return str(uuid4().int & 0x7FFFFFFF)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _existing_paragraph_ids(doc: HwpxDocument) -> set[str]:
|
|
52
|
+
ids: set[str] = set()
|
|
53
|
+
for section in doc.oxml.sections:
|
|
54
|
+
for p_el in section.element.iter(f"{_HP}p"):
|
|
55
|
+
pid = p_el.get("id")
|
|
56
|
+
if pid:
|
|
57
|
+
ids.add(pid)
|
|
58
|
+
return ids
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def ensure_paragraph_anchor_id(doc: HwpxDocument, paragraph: Any) -> str:
|
|
62
|
+
"""Make ``paragraph`` addressable as a field anchor (``?#<id>``).
|
|
63
|
+
|
|
64
|
+
Hancom resolves TOC/CROSSREF targets by the paragraph ``id`` attribute, so
|
|
65
|
+
an anchor id must be unique in the document (generated paragraphs may all
|
|
66
|
+
carry the shared ``2147483648`` constant)."""
|
|
67
|
+
element = paragraph.element
|
|
68
|
+
pid = element.get("id")
|
|
69
|
+
ids = _existing_paragraph_ids(doc)
|
|
70
|
+
duplicated = pid and sum(1 for section in doc.oxml.sections
|
|
71
|
+
for p in section.element.iter(f"{_HP}p")
|
|
72
|
+
if p.get("id") == pid) > 1
|
|
73
|
+
if not pid or pid == _NON_UNIQUE_PARA_ID or duplicated:
|
|
74
|
+
new_id = _rand_id()
|
|
75
|
+
while new_id in ids:
|
|
76
|
+
new_id = _rand_id()
|
|
77
|
+
element.set("id", new_id)
|
|
78
|
+
pid = new_id
|
|
79
|
+
return pid
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def outline_heading_paragraphs(doc: HwpxDocument) -> list[tuple[Any, int, str]]:
|
|
83
|
+
"""Return ``(paragraph, level, text)`` for 개요/Outline-styled paragraphs."""
|
|
84
|
+
levels: dict[str, int] = {}
|
|
85
|
+
for style_id, style in (doc.styles or {}).items():
|
|
86
|
+
name = getattr(style, "name", "") or ""
|
|
87
|
+
match = _OUTLINE_NAME_RE.match(name.strip())
|
|
88
|
+
if match:
|
|
89
|
+
levels[str(style_id)] = int(match.group(1))
|
|
90
|
+
result: list[tuple[Any, int, str]] = []
|
|
91
|
+
for section in doc.sections:
|
|
92
|
+
for paragraph in section.paragraphs:
|
|
93
|
+
style_ref = paragraph.element.get("styleIDRef")
|
|
94
|
+
if style_ref in levels and (paragraph.text or "").strip():
|
|
95
|
+
result.append((paragraph, levels[style_ref], paragraph.text.strip()))
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── low-level field emission ─────────────────────────────────────────
|
|
100
|
+
def _param(parent: ET.Element, kind: str, name: str, value: str, *, preserve: bool = False) -> None:
|
|
101
|
+
el = ET.SubElement(parent, f"{_HP}{kind}", {"name": name})
|
|
102
|
+
if preserve:
|
|
103
|
+
el.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
|
|
104
|
+
el.text = value
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _field_begin(run: ET.Element, *, ftype: str, field_id: str, editable: str, dirty: str) -> ET.Element:
|
|
108
|
+
ctrl = ET.SubElement(run, f"{_HP}ctrl")
|
|
109
|
+
begin = ET.SubElement(
|
|
110
|
+
ctrl,
|
|
111
|
+
f"{_HP}fieldBegin",
|
|
112
|
+
{
|
|
113
|
+
"id": field_id,
|
|
114
|
+
"type": ftype,
|
|
115
|
+
"name": "",
|
|
116
|
+
"editable": editable,
|
|
117
|
+
"dirty": dirty,
|
|
118
|
+
"zorder": "-1",
|
|
119
|
+
"fieldid": _rand_id(),
|
|
120
|
+
"metaTag": "",
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
return begin
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _field_end(run: ET.Element, field_id: str) -> None:
|
|
127
|
+
ctrl = ET.SubElement(run, f"{_HP}ctrl")
|
|
128
|
+
ET.SubElement(ctrl, f"{_HP}fieldEnd", {"beginIDRef": field_id})
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _entry_paragraph(numbered_title: str, page: int, target_id: str, char_pr: str = "0") -> ET.Element:
|
|
132
|
+
"""One generated TOC entry: HYPERLINK field wrapping ``title<tab/>page``."""
|
|
133
|
+
p = ET.Element(f"{_HP}p", {
|
|
134
|
+
"id": _rand_id(), "paraPrIDRef": "0", "styleIDRef": "0",
|
|
135
|
+
"pageBreak": "0", "columnBreak": "0", "merged": "0",
|
|
136
|
+
})
|
|
137
|
+
field_id = _rand_id()
|
|
138
|
+
run1 = ET.SubElement(p, f"{_HP}run", {"charPrIDRef": char_pr})
|
|
139
|
+
begin = _field_begin(run1, ftype="HYPERLINK", field_id=field_id, editable="0", dirty="1")
|
|
140
|
+
params = ET.SubElement(begin, f"{_HP}parameters", {"cnt": "5", "name": ""})
|
|
141
|
+
_param(params, "integerParam", "Prop", "0")
|
|
142
|
+
_param(params, "stringParam", "Command", f"?#{target_id};0;1;0;")
|
|
143
|
+
_param(params, "stringParam", "Category", "HWPHYPERLINK_TYPE_HWP")
|
|
144
|
+
_param(params, "stringParam", "TargetType", "HWPHYPERLINK_TARGET_OUTLINE")
|
|
145
|
+
_param(params, "stringParam", "DocOpenType", "HWPHYPERLINK_JUMP_CURRENTTAB")
|
|
146
|
+
|
|
147
|
+
run2 = ET.SubElement(p, f"{_HP}run", {"charPrIDRef": char_pr})
|
|
148
|
+
t = ET.SubElement(run2, f"{_HP}t")
|
|
149
|
+
t.text = numbered_title
|
|
150
|
+
tab = ET.SubElement(t, f"{_HP}tab", {"width": "34032", "leader": "3", "type": "2"})
|
|
151
|
+
tab.tail = str(page)
|
|
152
|
+
|
|
153
|
+
run3 = ET.SubElement(p, f"{_HP}run", {"charPrIDRef": char_pr})
|
|
154
|
+
_field_end(run3, field_id)
|
|
155
|
+
return p
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def mark_toc_dirty(doc: HwpxDocument) -> int:
|
|
159
|
+
"""Set ``dirty="1"`` on every TABLEOFCONTENTS field — the measured
|
|
160
|
+
re-number trigger: Hancom regenerates a dirty TOC (entries, styles, page
|
|
161
|
+
numbers) when it next opens the document. Call after edits that shift
|
|
162
|
+
pagination. Returns the number of fields marked."""
|
|
163
|
+
count = 0
|
|
164
|
+
for section in doc.oxml.sections:
|
|
165
|
+
for begin in section.element.iter(f"{_HP}fieldBegin"):
|
|
166
|
+
if begin.get("type") == "TABLEOFCONTENTS":
|
|
167
|
+
begin.set("dirty", "1")
|
|
168
|
+
count += 1
|
|
169
|
+
if count:
|
|
170
|
+
section.mark_dirty()
|
|
171
|
+
return count
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def add_native_toc(
|
|
175
|
+
doc: HwpxDocument,
|
|
176
|
+
*,
|
|
177
|
+
at_index: int = 0,
|
|
178
|
+
title: str = "<제목 차례>",
|
|
179
|
+
level: int = 2,
|
|
180
|
+
leader: int = 3,
|
|
181
|
+
hyperlink: bool = True,
|
|
182
|
+
dirty: bool = True,
|
|
183
|
+
headings: Sequence[Any] | None = None,
|
|
184
|
+
) -> dict[str, Any]:
|
|
185
|
+
"""Insert a Hancom-native TABLEOFCONTENTS field region at ``at_index``.
|
|
186
|
+
|
|
187
|
+
Entries are generated from ``headings`` (paragraph wrappers) or, when
|
|
188
|
+
omitted, auto-detected 개요/Outline-styled paragraphs. Emitted entry page
|
|
189
|
+
numbers are naive estimates; with ``dirty=True`` (default, measured
|
|
190
|
+
semantics) Hancom regenerates the whole region — correct entries, styles,
|
|
191
|
+
and page numbers — on its next open, so the first thing a user sees is a
|
|
192
|
+
TOC Hancom itself computed. Returns a summary dict.
|
|
193
|
+
|
|
194
|
+
Collection note (measured): Hancom collects outline-styled paragraphs and
|
|
195
|
+
— via the ``ContentsStyles:wstring:0:`` command — style-0 (바탕글)
|
|
196
|
+
paragraphs too; give body text a non-collected style (e.g. 본문, style 1)
|
|
197
|
+
or it will appear as TOC entries after regeneration.
|
|
198
|
+
"""
|
|
199
|
+
if headings is None:
|
|
200
|
+
detected = outline_heading_paragraphs(doc)
|
|
201
|
+
else:
|
|
202
|
+
detected = [(p, 1, (p.text or "").strip()) for p in headings]
|
|
203
|
+
if not detected:
|
|
204
|
+
raise ValueError("no outline headings found to build a TOC from")
|
|
205
|
+
|
|
206
|
+
anchors: list[tuple[str, int, str]] = []
|
|
207
|
+
for paragraph, lvl, text in detected:
|
|
208
|
+
anchor = ensure_paragraph_anchor_id(doc, paragraph)
|
|
209
|
+
anchors.append((anchor, lvl, text))
|
|
210
|
+
|
|
211
|
+
section = doc.oxml.sections[0]
|
|
212
|
+
toc_field_id = _rand_id()
|
|
213
|
+
|
|
214
|
+
# opening paragraph: [ctrl fieldBegin TABLEOFCONTENTS][t title]
|
|
215
|
+
open_p = ET.Element(f"{_HP}p", {
|
|
216
|
+
"id": _rand_id(), "paraPrIDRef": "0", "styleIDRef": "0",
|
|
217
|
+
"pageBreak": "0", "columnBreak": "0", "merged": "0",
|
|
218
|
+
})
|
|
219
|
+
run = ET.SubElement(open_p, f"{_HP}run", {"charPrIDRef": "0"})
|
|
220
|
+
begin = _field_begin(
|
|
221
|
+
run, ftype="TABLEOFCONTENTS", field_id=toc_field_id,
|
|
222
|
+
editable="1", dirty="1" if dirty else "0",
|
|
223
|
+
)
|
|
224
|
+
params = ET.SubElement(begin, f"{_HP}parameters", {"cnt": "2", "name": ""})
|
|
225
|
+
_param(params, "integerParam", "Prop", "8")
|
|
226
|
+
_param(
|
|
227
|
+
params, "stringParam", "Command",
|
|
228
|
+
_TOC_COMMAND.format(level=level, leader=leader, hyperlink=1 if hyperlink else 0),
|
|
229
|
+
preserve=True,
|
|
230
|
+
)
|
|
231
|
+
t = ET.SubElement(run, f"{_HP}t")
|
|
232
|
+
t.text = title
|
|
233
|
+
|
|
234
|
+
entry_elements = [
|
|
235
|
+
_entry_paragraph(f"{i}. {text}", 1, anchor)
|
|
236
|
+
for i, (anchor, _lvl, text) in enumerate(anchors, start=1)
|
|
237
|
+
]
|
|
238
|
+
|
|
239
|
+
# closing paragraph: [ctrl fieldEnd]
|
|
240
|
+
close_p = ET.Element(f"{_HP}p", {
|
|
241
|
+
"id": _rand_id(), "paraPrIDRef": "0", "styleIDRef": "0",
|
|
242
|
+
"pageBreak": "0", "columnBreak": "0", "merged": "0",
|
|
243
|
+
})
|
|
244
|
+
close_run = ET.SubElement(close_p, f"{_HP}run", {"charPrIDRef": "0"})
|
|
245
|
+
_field_end(close_run, toc_field_id)
|
|
246
|
+
|
|
247
|
+
section.insert_paragraphs(at_index, [open_p, *entry_elements, close_p])
|
|
248
|
+
return {
|
|
249
|
+
"tocFieldId": toc_field_id,
|
|
250
|
+
"entryCount": len(anchors),
|
|
251
|
+
"anchors": [a for a, _l, _t in anchors],
|
|
252
|
+
"cachedPagesAreEstimates": True,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def add_page_crossref(
|
|
257
|
+
doc: HwpxDocument,
|
|
258
|
+
paragraph: Any,
|
|
259
|
+
target_paragraph: Any,
|
|
260
|
+
*,
|
|
261
|
+
cached_page: int = 1,
|
|
262
|
+
) -> dict[str, Any]:
|
|
263
|
+
"""Append a Hancom-native page CROSSREF (상호참조 → 쪽 번호) to ``paragraph``.
|
|
264
|
+
|
|
265
|
+
The cached result run is a best-effort estimate; Hancom recomputes CROSSREF
|
|
266
|
+
caches automatically on edit/save (P0 measured on the gold pair: 2→3)."""
|
|
267
|
+
target_id = ensure_paragraph_anchor_id(doc, target_paragraph)
|
|
268
|
+
field_id = _rand_id()
|
|
269
|
+
p_el = paragraph.element
|
|
270
|
+
|
|
271
|
+
run1 = ET.SubElement(p_el, f"{_HP}run", {"charPrIDRef": "0"})
|
|
272
|
+
begin = _field_begin(run1, ftype="CROSSREF", field_id=field_id, editable="0", dirty="0")
|
|
273
|
+
params = ET.SubElement(begin, f"{_HP}parameters", {"cnt": "8", "name": ""})
|
|
274
|
+
_param(params, "booleanParam", "Fiexde", "1") # sic — Hancom's own spelling
|
|
275
|
+
_param(params, "integerParam", "Prop", "0")
|
|
276
|
+
_param(params, "stringParam", "Command", f"?#{target_id};5;0;0;0;")
|
|
277
|
+
_param(params, "stringParam", "RefPath", f"?#{target_id};")
|
|
278
|
+
_param(params, "stringParam", "RefType", "TARGET_OUTLINE")
|
|
279
|
+
_param(params, "stringParam", "RefContentType", "OBJECT_TYPE_PAGE")
|
|
280
|
+
_param(params, "booleanParam", "RefHyperLink", "false")
|
|
281
|
+
_param(params, "stringParam", "RefOpenType", "HWPHYPERLINK_JUMP_CURRENTTAB")
|
|
282
|
+
|
|
283
|
+
run2 = ET.SubElement(p_el, f"{_HP}run", {"charPrIDRef": "0"})
|
|
284
|
+
t = ET.SubElement(run2, f"{_HP}t")
|
|
285
|
+
t.text = str(cached_page)
|
|
286
|
+
|
|
287
|
+
run3 = ET.SubElement(p_el, f"{_HP}run", {"charPrIDRef": "0"})
|
|
288
|
+
_field_end(run3, field_id)
|
|
289
|
+
|
|
290
|
+
paragraph.section.mark_dirty()
|
|
291
|
+
return {"fieldId": field_id, "targetId": target_id, "cachedPage": cached_page}
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Native TOC / cross-reference fidelity harness (M7 / S-062).
|
|
3
|
+
|
|
4
|
+
Parses the Hancom-native field contract captured in
|
|
5
|
+
``specs/009-native-toc-xrefs/evidence/p0-native-toc-xml-contract.md``:
|
|
6
|
+
|
|
7
|
+
* ``<hp:fieldBegin type="TABLEOFCONTENTS">`` region whose generated entries are
|
|
8
|
+
HYPERLINK fields wrapping a single ``<hp:t>`` of the form
|
|
9
|
+
``제목텍스트<hp:tab leader=".."/>쪽번호`` (page number lives in the *tail* of
|
|
10
|
+
the nested ``hp:tab``),
|
|
11
|
+
* ``<hp:fieldBegin type="CROSSREF">`` whose cached result is the plain ``hp:t``
|
|
12
|
+
run between its fieldBegin and fieldEnd,
|
|
13
|
+
* anchors that are plain paragraph ``id`` attributes (``?#<para-id>``).
|
|
14
|
+
|
|
15
|
+
Measured semantics (P0, owner-confirmed): CROSSREF caches recompute
|
|
16
|
+
automatically on edit/save; the TOC block only recomputes after an explicit
|
|
17
|
+
차례 새로 고침 — so ``dirty`` is NOT a reliable staleness marker and the only
|
|
18
|
+
honest verdict comes from comparing cached page numbers against rendered ones
|
|
19
|
+
(Hancom render -> fitz words). Without an oracle the report degrades to a
|
|
20
|
+
structural verdict with ``render_checked=False`` (Constitution V/IX).
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Sequence
|
|
28
|
+
|
|
29
|
+
from hwpx.document import HwpxDocument
|
|
30
|
+
|
|
31
|
+
_HP = "{http://www.hancom.co.kr/hwpml/2011/paragraph}"
|
|
32
|
+
_TARGET_RE = re.compile(r"\?#(\d+)")
|
|
33
|
+
#: Generated paragraphs share this constant id — useless as an anchor.
|
|
34
|
+
NON_UNIQUE_PARA_ID = "2147483648"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class TocEntry:
|
|
39
|
+
target_id: str | None
|
|
40
|
+
title: str
|
|
41
|
+
cached_page: int | None
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict[str, Any]:
|
|
44
|
+
return {"targetId": self.target_id, "title": self.title, "cachedPage": self.cached_page}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class CrossRef:
|
|
49
|
+
target_id: str | None
|
|
50
|
+
cached_page: int | None
|
|
51
|
+
ref_content_type: str | None
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict[str, Any]:
|
|
54
|
+
return {
|
|
55
|
+
"targetId": self.target_id,
|
|
56
|
+
"cachedPage": self.cached_page,
|
|
57
|
+
"refContentType": self.ref_content_type,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class TocModel:
|
|
63
|
+
"""Everything the harness reads out of one document's XML."""
|
|
64
|
+
|
|
65
|
+
toc_field_id: str | None = None
|
|
66
|
+
toc_command: str | None = None
|
|
67
|
+
entries: list[TocEntry] = field(default_factory=list)
|
|
68
|
+
crossrefs: list[CrossRef] = field(default_factory=list)
|
|
69
|
+
paragraph_texts: dict[str, str] = field(default_factory=dict) # para id -> text
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ── XML parsing ──────────────────────────────────────────────────────
|
|
73
|
+
def _open(source: str | Path | bytes | HwpxDocument) -> HwpxDocument:
|
|
74
|
+
if isinstance(source, HwpxDocument):
|
|
75
|
+
return source
|
|
76
|
+
return HwpxDocument.open(source)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _para_text(p_el: Any) -> str:
|
|
80
|
+
parts: list[str] = []
|
|
81
|
+
for t in p_el.iter(f"{_HP}t"):
|
|
82
|
+
if t.text:
|
|
83
|
+
parts.append(t.text)
|
|
84
|
+
for child in t:
|
|
85
|
+
if child.tail:
|
|
86
|
+
parts.append(child.tail)
|
|
87
|
+
return "".join(parts)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _field_type(ctrl_el: Any) -> tuple[Any, str] | None:
|
|
91
|
+
begin = ctrl_el.find(f"{_HP}fieldBegin")
|
|
92
|
+
if begin is None:
|
|
93
|
+
return None
|
|
94
|
+
return begin, begin.get("type", "")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _string_params(begin_el: Any) -> dict[str, str]:
|
|
98
|
+
params: dict[str, str] = {}
|
|
99
|
+
parameters = begin_el.find(f"{_HP}parameters")
|
|
100
|
+
if parameters is None:
|
|
101
|
+
return params
|
|
102
|
+
for child in parameters:
|
|
103
|
+
name = child.get("name")
|
|
104
|
+
if name:
|
|
105
|
+
params[name] = child.text or ""
|
|
106
|
+
return params
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _target_of(params: dict[str, str]) -> str | None:
|
|
110
|
+
for key in ("RefPath", "Command"):
|
|
111
|
+
value = params.get(key) or ""
|
|
112
|
+
match = _TARGET_RE.search(value)
|
|
113
|
+
if match:
|
|
114
|
+
return match.group(1)
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def parse_toc_model(source: str | Path | bytes | HwpxDocument) -> TocModel:
|
|
119
|
+
"""Parse the native TOC field region, CROSSREF fields, and paragraph ids."""
|
|
120
|
+
doc = _open(source)
|
|
121
|
+
model = TocModel()
|
|
122
|
+
|
|
123
|
+
for section in doc.oxml.sections:
|
|
124
|
+
root = section.element
|
|
125
|
+
inside_toc = False # region state spans paragraphs (fieldBegin..fieldEnd)
|
|
126
|
+
for p_el in root.iter(f"{_HP}p"):
|
|
127
|
+
pid = p_el.get("id")
|
|
128
|
+
if pid and pid not in model.paragraph_texts:
|
|
129
|
+
model.paragraph_texts[pid] = _para_text(p_el)
|
|
130
|
+
|
|
131
|
+
# walk runs/ctrls in document order with a tiny state machine
|
|
132
|
+
hyperlink_target: str | None = None
|
|
133
|
+
crossref: dict[str, Any] | None = None
|
|
134
|
+
for run in p_el.findall(f"{_HP}run"):
|
|
135
|
+
for child in run:
|
|
136
|
+
tag = child.tag
|
|
137
|
+
if tag == f"{_HP}ctrl":
|
|
138
|
+
info = _field_type(child)
|
|
139
|
+
if info is None:
|
|
140
|
+
end = child.find(f"{_HP}fieldEnd")
|
|
141
|
+
if end is not None:
|
|
142
|
+
# closes an open crossref…
|
|
143
|
+
if crossref is not None:
|
|
144
|
+
model.crossrefs.append(
|
|
145
|
+
CrossRef(
|
|
146
|
+
target_id=crossref["target"],
|
|
147
|
+
cached_page=crossref["page"],
|
|
148
|
+
ref_content_type=crossref["content_type"],
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
crossref = None
|
|
152
|
+
# …or the TOC region itself
|
|
153
|
+
if inside_toc and end.get("beginIDRef") == model.toc_field_id:
|
|
154
|
+
inside_toc = False
|
|
155
|
+
continue
|
|
156
|
+
begin, ftype = info
|
|
157
|
+
params = _string_params(begin)
|
|
158
|
+
if ftype == "TABLEOFCONTENTS":
|
|
159
|
+
model.toc_field_id = begin.get("id")
|
|
160
|
+
model.toc_command = params.get("Command")
|
|
161
|
+
inside_toc = True
|
|
162
|
+
elif ftype == "CROSSREF":
|
|
163
|
+
crossref = {
|
|
164
|
+
"target": _target_of(params),
|
|
165
|
+
"page": None,
|
|
166
|
+
"content_type": params.get("RefContentType"),
|
|
167
|
+
}
|
|
168
|
+
elif ftype == "HYPERLINK":
|
|
169
|
+
hyperlink_target = _target_of(params)
|
|
170
|
+
elif tag == f"{_HP}t":
|
|
171
|
+
if crossref is not None:
|
|
172
|
+
digits = (child.text or "").strip()
|
|
173
|
+
if digits.isdigit():
|
|
174
|
+
crossref["page"] = int(digits)
|
|
175
|
+
continue
|
|
176
|
+
tab = child.find(f"{_HP}tab")
|
|
177
|
+
if tab is not None and (hyperlink_target is not None or inside_toc):
|
|
178
|
+
# Hyperlinked entry (ContentsHyperlink:1, gold) or the
|
|
179
|
+
# PLAIN entry Hancom regenerates with ContentsHyperlink:0
|
|
180
|
+
# (measured on a refreshed save): same t+tab+page shape,
|
|
181
|
+
# no target reference — identity resolves by title.
|
|
182
|
+
title = (child.text or "").strip()
|
|
183
|
+
page_text = (tab.tail or "").strip()
|
|
184
|
+
model.entries.append(
|
|
185
|
+
TocEntry(
|
|
186
|
+
target_id=hyperlink_target,
|
|
187
|
+
title=title,
|
|
188
|
+
cached_page=int(page_text) if page_text.isdigit() else None,
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
hyperlink_target = None
|
|
192
|
+
return model
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ── structural verdict (oracle-free) ─────────────────────────────────
|
|
196
|
+
def structural_report(source: str | Path | bytes | HwpxDocument) -> dict[str, Any]:
|
|
197
|
+
"""Oracle-free checks: fields present, anchors resolve, internal consistency.
|
|
198
|
+
|
|
199
|
+
``internal_conflicts`` cross-checks CROSSREF caches (auto-recomputed by
|
|
200
|
+
Hancom) against TOC entry caches (explicit-refresh only) for shared
|
|
201
|
+
targets — a disagreement proves the TOC block is stale without any render.
|
|
202
|
+
"""
|
|
203
|
+
model = parse_toc_model(source)
|
|
204
|
+
targets = [e.target_id for e in model.entries] + [c.target_id for c in model.crossrefs]
|
|
205
|
+
unresolved = [t for t in targets if t and t not in model.paragraph_texts]
|
|
206
|
+
|
|
207
|
+
conflicts: list[dict[str, Any]] = []
|
|
208
|
+
entry_pages = {e.target_id: e.cached_page for e in model.entries if e.target_id}
|
|
209
|
+
for ref in model.crossrefs:
|
|
210
|
+
if ref.target_id in entry_pages and ref.cached_page is not None:
|
|
211
|
+
toc_page = entry_pages[ref.target_id]
|
|
212
|
+
if toc_page is not None and toc_page != ref.cached_page:
|
|
213
|
+
conflicts.append(
|
|
214
|
+
{
|
|
215
|
+
"targetId": ref.target_id,
|
|
216
|
+
"tocCachedPage": toc_page,
|
|
217
|
+
"crossrefCachedPage": ref.cached_page,
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
"hasNativeToc": model.toc_field_id is not None,
|
|
223
|
+
"tocCommand": model.toc_command,
|
|
224
|
+
"entryCount": len(model.entries),
|
|
225
|
+
"crossrefCount": len(model.crossrefs),
|
|
226
|
+
"entries": [e.to_dict() for e in model.entries],
|
|
227
|
+
"crossrefs": [c.to_dict() for c in model.crossrefs],
|
|
228
|
+
"unresolvedTargets": unresolved,
|
|
229
|
+
"targetsResolve": not unresolved,
|
|
230
|
+
"internal_conflicts": conflicts,
|
|
231
|
+
"internally_consistent": not conflicts,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ── repagination trigger ─────────────────────────────────────────────
|
|
236
|
+
def grow_paragraph(doc: HwpxDocument, *, contains: str, added_chars: int = 2000) -> bool:
|
|
237
|
+
"""Lengthen the first body paragraph containing ``contains`` to force a
|
|
238
|
+
page shift on the next Hancom re-layout. Returns True when applied."""
|
|
239
|
+
filler = " 재배열을 유발하기 위한 채움 문장입니다." * (added_chars // 24 + 1)
|
|
240
|
+
for section in doc.sections:
|
|
241
|
+
for paragraph in section.paragraphs:
|
|
242
|
+
text = paragraph.text or ""
|
|
243
|
+
if contains in text:
|
|
244
|
+
paragraph.text = text + filler[:added_chars]
|
|
245
|
+
return True
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ── oracle leg ───────────────────────────────────────────────────────
|
|
250
|
+
def _normalize(text: str) -> str:
|
|
251
|
+
return re.sub(r"\s+", "", text)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
_OUTLINE_PREFIX_RE = re.compile(r"^\d+(?:\.\d+)*\.?")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def heading_rendered_pages(pdf_path: str, headings: dict[str, str]) -> dict[str, int | None]:
|
|
258
|
+
"""Locate each heading's rendered page (1-based) via the PDF text layer.
|
|
259
|
+
|
|
260
|
+
Line-exact matching (measured discipline — a page-substring heuristic
|
|
261
|
+
misfires two ways against a real Hancom render: body echoes of the title
|
|
262
|
+
match later pages, and excluding the TOC's page hides a heading that
|
|
263
|
+
shares it). A rendered heading is a *whole line* equal to the title,
|
|
264
|
+
optionally prefixed by its outline number ("1." / "2.1"). TOC entry lines
|
|
265
|
+
never match (they carry a trailing leader + page digits) and body echoes
|
|
266
|
+
never match (the line continues past the title). Wrapped multi-line
|
|
267
|
+
headings are a known limitation — keep demo headings single-line.
|
|
268
|
+
"""
|
|
269
|
+
from hwpx.form_fit.wordbox import extract_word_boxes
|
|
270
|
+
|
|
271
|
+
boxes = extract_word_boxes(pdf_path)
|
|
272
|
+
grouped: dict[tuple[int, int, int], list[Any]] = {}
|
|
273
|
+
for box in boxes:
|
|
274
|
+
grouped.setdefault((box.page, box.block, box.line), []).append(box)
|
|
275
|
+
lines: list[tuple[int, str]] = []
|
|
276
|
+
for (page, _block, _line), words in sorted(grouped.items()):
|
|
277
|
+
text = _normalize("".join(w.text for w in sorted(words, key=lambda w: w.word_no)))
|
|
278
|
+
if text:
|
|
279
|
+
lines.append((page, text))
|
|
280
|
+
|
|
281
|
+
result: dict[str, int | None] = {}
|
|
282
|
+
for para_id, title in headings.items():
|
|
283
|
+
needle = _normalize(title)
|
|
284
|
+
found: int | None = None
|
|
285
|
+
if needle:
|
|
286
|
+
for page, line_text in lines:
|
|
287
|
+
if line_text == needle or _OUTLINE_PREFIX_RE.sub("", line_text) == needle:
|
|
288
|
+
# LAST match wins: the TOC region precedes the body, and a
|
|
289
|
+
# TOC entry whose page digit wraps to its own line (no
|
|
290
|
+
# right tab stop) is indistinguishable from a numbered
|
|
291
|
+
# heading line — measured against a real render of our own
|
|
292
|
+
# emission. The real heading always follows the TOC.
|
|
293
|
+
found = page + 1 # fitz 0-based -> Hancom 1-based
|
|
294
|
+
result[para_id] = found
|
|
295
|
+
return result
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def toc_verify(
|
|
299
|
+
source: str | Path | bytes,
|
|
300
|
+
*,
|
|
301
|
+
oracle: Any | None = None,
|
|
302
|
+
pdf_path: str | None = None,
|
|
303
|
+
) -> dict[str, Any]:
|
|
304
|
+
"""Full verdict: structural report + (when a render is available) cached
|
|
305
|
+
page numbers checked against rendered heading pages.
|
|
306
|
+
|
|
307
|
+
Pass ``pdf_path`` to reuse an existing render; else ``oracle.render_pdf``
|
|
308
|
+
is used when the oracle reports available. Otherwise degrades honestly.
|
|
309
|
+
"""
|
|
310
|
+
structural = structural_report(source)
|
|
311
|
+
report: dict[str, Any] = {
|
|
312
|
+
"structural": structural,
|
|
313
|
+
"render_checked": False,
|
|
314
|
+
"toc_correctness_ratio": None,
|
|
315
|
+
"stale_entries": [],
|
|
316
|
+
"crossref_correctness_ratio": None,
|
|
317
|
+
"verdict": "unverified",
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
model = parse_toc_model(source)
|
|
321
|
+
|
|
322
|
+
def _entry_key(entry: TocEntry) -> str | None:
|
|
323
|
+
"""Lookup key for an entry: its anchor id, or (plain regenerated
|
|
324
|
+
entries carry no target reference) a title-derived key."""
|
|
325
|
+
if entry.target_id:
|
|
326
|
+
return entry.target_id
|
|
327
|
+
stripped = _OUTLINE_PREFIX_RE.sub("", _normalize(entry.title))
|
|
328
|
+
return f"title::{stripped}" if stripped else None
|
|
329
|
+
|
|
330
|
+
headings: dict[str, str] = {}
|
|
331
|
+
for t in {e.target_id for e in model.entries} | {c.target_id for c in model.crossrefs}:
|
|
332
|
+
if t and t in model.paragraph_texts and model.paragraph_texts[t].strip():
|
|
333
|
+
headings[t] = model.paragraph_texts[t]
|
|
334
|
+
for entry in model.entries:
|
|
335
|
+
key = _entry_key(entry)
|
|
336
|
+
if key and key.startswith("title::"):
|
|
337
|
+
# identity by title: the rendered heading line equals the entry
|
|
338
|
+
# title minus its numbering prefix
|
|
339
|
+
title = _OUTLINE_PREFIX_RE.sub("", entry.title.strip()).strip()
|
|
340
|
+
if title:
|
|
341
|
+
headings[key] = title
|
|
342
|
+
|
|
343
|
+
rendered_pdf = pdf_path
|
|
344
|
+
if rendered_pdf is None and oracle is not None:
|
|
345
|
+
try:
|
|
346
|
+
if getattr(oracle, "available", lambda: False)():
|
|
347
|
+
src = source if isinstance(source, (str, Path)) else None
|
|
348
|
+
if src is None:
|
|
349
|
+
import tempfile
|
|
350
|
+
|
|
351
|
+
handle, tmp = tempfile.mkstemp(suffix=".hwpx")
|
|
352
|
+
Path(tmp).write_bytes(source) # type: ignore[arg-type]
|
|
353
|
+
src = tmp
|
|
354
|
+
rendered_pdf = oracle.render_pdf(str(src))
|
|
355
|
+
except Exception: # pragma: no cover - defensive: degrade, never crash
|
|
356
|
+
rendered_pdf = None
|
|
357
|
+
|
|
358
|
+
if not rendered_pdf:
|
|
359
|
+
if not structural["internally_consistent"]:
|
|
360
|
+
report["verdict"] = "stale_detected_structurally"
|
|
361
|
+
return report
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
actual = heading_rendered_pages(rendered_pdf, headings)
|
|
365
|
+
except Exception: # pragma: no cover - fitz/pdf failure -> honest degrade
|
|
366
|
+
return report
|
|
367
|
+
|
|
368
|
+
report["render_checked"] = True
|
|
369
|
+
report["rendered_pdf"] = rendered_pdf
|
|
370
|
+
report["heading_pages"] = actual
|
|
371
|
+
|
|
372
|
+
stale: list[dict[str, Any]] = []
|
|
373
|
+
scored = 0
|
|
374
|
+
correct = 0
|
|
375
|
+
entry_rows: list[dict[str, Any]] = []
|
|
376
|
+
for entry in model.entries:
|
|
377
|
+
actual_page = actual.get(_entry_key(entry) or "")
|
|
378
|
+
row = entry.to_dict() | {"actualPage": actual_page}
|
|
379
|
+
if entry.cached_page is not None and actual_page is not None:
|
|
380
|
+
scored += 1
|
|
381
|
+
if entry.cached_page == actual_page:
|
|
382
|
+
correct += 1
|
|
383
|
+
else:
|
|
384
|
+
stale.append(row)
|
|
385
|
+
entry_rows.append(row)
|
|
386
|
+
report["toc_entries"] = entry_rows
|
|
387
|
+
report["toc_correctness_ratio"] = (correct / scored) if scored else None
|
|
388
|
+
report["stale_entries"] = stale
|
|
389
|
+
|
|
390
|
+
xr_scored = xr_correct = 0
|
|
391
|
+
xr_rows: list[dict[str, Any]] = []
|
|
392
|
+
for ref in model.crossrefs:
|
|
393
|
+
actual_page = actual.get(ref.target_id or "")
|
|
394
|
+
row = ref.to_dict() | {"actualPage": actual_page}
|
|
395
|
+
if ref.cached_page is not None and actual_page is not None:
|
|
396
|
+
xr_scored += 1
|
|
397
|
+
if ref.cached_page == actual_page:
|
|
398
|
+
xr_correct += 1
|
|
399
|
+
xr_rows.append(row)
|
|
400
|
+
report["crossrefs"] = xr_rows
|
|
401
|
+
report["crossref_correctness_ratio"] = (xr_correct / xr_scored) if xr_scored else None
|
|
402
|
+
|
|
403
|
+
ratios = [r for r in (report["toc_correctness_ratio"], report["crossref_correctness_ratio"]) if r is not None]
|
|
404
|
+
if not ratios:
|
|
405
|
+
report["verdict"] = "unverified"
|
|
406
|
+
elif all(r == 1.0 for r in ratios):
|
|
407
|
+
report["verdict"] = "verified"
|
|
408
|
+
else:
|
|
409
|
+
report["verdict"] = "stale"
|
|
410
|
+
return report
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
-- SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
-- Mac Hancom field-refresh backend (M7 native TOC / cross-references).
|
|
3
|
+
--
|
|
4
|
+
-- Opens an .hwpx IN PLACE, lets Hancom regenerate any dirty="1" fields
|
|
5
|
+
-- (measured semantics: a dirty TABLEOFCONTENTS is rebuilt on open — entries,
|
|
6
|
+
-- styles, and page numbers recomputed by Hancom itself; CROSSREF caches
|
|
7
|
+
-- recompute automatically), saves the document back over the same path
|
|
8
|
+
-- (파일 > 저장하기), and closes it.
|
|
9
|
+
--
|
|
10
|
+
-- Why save-then-close instead of exporting a PDF from the regenerating
|
|
11
|
+
-- session: this Hancom build crashes when PDF export runs right after an
|
|
12
|
+
-- open-time TOC regeneration (measured: deterministic %%EOF-less torsos, then
|
|
13
|
+
-- the process dies). The refreshed FILE is stable; render it in a fresh
|
|
14
|
+
-- session afterwards.
|
|
15
|
+
--
|
|
16
|
+
-- Usage: osascript _refresh_hwpx_mac.applescript <doc.hwpx> [timeoutSecs]
|
|
17
|
+
-- Output: "OK" on success; "ERR: <reason>" otherwise.
|
|
18
|
+
|
|
19
|
+
property procName : "Hancom Office HWP"
|
|
20
|
+
property appName : "Hancom Office HWP"
|
|
21
|
+
property fileMenuName : "파일"
|
|
22
|
+
property saveItemName : "저장하기"
|
|
23
|
+
property closeDocPrefix : "문서 닫기"
|
|
24
|
+
|
|
25
|
+
on run argv
|
|
26
|
+
if (count of argv) < 1 then return "ERR: usage: <doc.hwpx> [timeoutSecs]"
|
|
27
|
+
set inputPath to item 1 of argv
|
|
28
|
+
set timeoutSecs to 90
|
|
29
|
+
if (count of argv) ≥ 2 then
|
|
30
|
+
try
|
|
31
|
+
set timeoutSecs to (item 2 of argv) as integer
|
|
32
|
+
end try
|
|
33
|
+
end if
|
|
34
|
+
set inputBase to do shell script "basename " & quoted form of inputPath
|
|
35
|
+
|
|
36
|
+
try
|
|
37
|
+
do shell script "open -a " & quoted form of appName & " " & quoted form of inputPath
|
|
38
|
+
if not (waitForWindowNamed(inputBase, timeoutSecs)) then
|
|
39
|
+
return "ERR: document window did not open: " & inputBase
|
|
40
|
+
end if
|
|
41
|
+
-- Let the dirty-field regeneration settle before saving.
|
|
42
|
+
delay 3
|
|
43
|
+
|
|
44
|
+
clickFileMenuItem(saveItemName, false)
|
|
45
|
+
delay 2
|
|
46
|
+
-- A compat/format sheet may appear on save: accept the default.
|
|
47
|
+
pressReturnOnAnySheet()
|
|
48
|
+
delay 1
|
|
49
|
+
|
|
50
|
+
clickFileMenuItem(closeDocPrefix, true)
|
|
51
|
+
delay 1
|
|
52
|
+
-- If a save-changes sheet still appears, discard (we already saved).
|
|
53
|
+
dismissDontSaveSheetIfPresent()
|
|
54
|
+
return "OK"
|
|
55
|
+
on error errMsg number errNum
|
|
56
|
+
try
|
|
57
|
+
clickFileMenuItem(closeDocPrefix, true)
|
|
58
|
+
dismissDontSaveSheetIfPresent()
|
|
59
|
+
end try
|
|
60
|
+
return "ERR: " & errMsg & " (" & errNum & ")"
|
|
61
|
+
end try
|
|
62
|
+
end run
|
|
63
|
+
|
|
64
|
+
on clickFileMenuItem(itemName, prefixMatch)
|
|
65
|
+
tell application "System Events" to tell process procName
|
|
66
|
+
set frontmost to true
|
|
67
|
+
delay 0.2
|
|
68
|
+
set fileMenu to missing value
|
|
69
|
+
repeat with mbi in menu bar items of menu bar 1
|
|
70
|
+
if (name of mbi as string) is fileMenuName then
|
|
71
|
+
set fileMenu to menu 1 of mbi
|
|
72
|
+
exit repeat
|
|
73
|
+
end if
|
|
74
|
+
end repeat
|
|
75
|
+
if fileMenu is missing value then error "menu '" & fileMenuName & "' not found"
|
|
76
|
+
repeat with mi in menu items of fileMenu
|
|
77
|
+
try
|
|
78
|
+
set miName to (name of mi as string)
|
|
79
|
+
if (prefixMatch and miName starts with itemName) or (not prefixMatch and miName is itemName) then
|
|
80
|
+
click mi
|
|
81
|
+
return
|
|
82
|
+
end if
|
|
83
|
+
end try
|
|
84
|
+
end repeat
|
|
85
|
+
error "menu item not found: " & itemName
|
|
86
|
+
end tell
|
|
87
|
+
end clickFileMenuItem
|
|
88
|
+
|
|
89
|
+
on windowNames()
|
|
90
|
+
try
|
|
91
|
+
tell application "System Events" to tell process procName
|
|
92
|
+
return (name of windows) as list
|
|
93
|
+
end tell
|
|
94
|
+
on error
|
|
95
|
+
return {}
|
|
96
|
+
end try
|
|
97
|
+
end windowNames
|
|
98
|
+
|
|
99
|
+
on listContains(theList, theValue)
|
|
100
|
+
repeat with x in theList
|
|
101
|
+
if (x as string) is theValue then return true
|
|
102
|
+
end repeat
|
|
103
|
+
return false
|
|
104
|
+
end listContains
|
|
105
|
+
|
|
106
|
+
on waitForWindowNamed(winName, secs)
|
|
107
|
+
repeat (secs * 2) times
|
|
108
|
+
if listContains(windowNames(), winName) then return true
|
|
109
|
+
delay 0.5
|
|
110
|
+
end repeat
|
|
111
|
+
return false
|
|
112
|
+
end waitForWindowNamed
|
|
113
|
+
|
|
114
|
+
on pressReturnOnAnySheet()
|
|
115
|
+
repeat 4 times
|
|
116
|
+
set found to false
|
|
117
|
+
try
|
|
118
|
+
tell application "System Events" to tell process procName
|
|
119
|
+
repeat with i from 1 to (count of windows)
|
|
120
|
+
try
|
|
121
|
+
if (count of sheets of window i) > 0 then
|
|
122
|
+
set frontmost to true
|
|
123
|
+
key code 36 -- Return = default button
|
|
124
|
+
set found to true
|
|
125
|
+
end if
|
|
126
|
+
end try
|
|
127
|
+
end repeat
|
|
128
|
+
end tell
|
|
129
|
+
end try
|
|
130
|
+
if not found then return false
|
|
131
|
+
delay 0.8
|
|
132
|
+
end repeat
|
|
133
|
+
return true
|
|
134
|
+
end pressReturnOnAnySheet
|
|
135
|
+
|
|
136
|
+
on dismissDontSaveSheetIfPresent()
|
|
137
|
+
repeat 6 times
|
|
138
|
+
set handled to false
|
|
139
|
+
try
|
|
140
|
+
tell application "System Events" to tell process procName
|
|
141
|
+
repeat with i from 1 to (count of windows)
|
|
142
|
+
try
|
|
143
|
+
if (count of sheets of window i) > 0 then
|
|
144
|
+
repeat with b in buttons of (sheet 1 of window i)
|
|
145
|
+
try
|
|
146
|
+
set bn to (name of b as string)
|
|
147
|
+
if bn contains "안 함" or bn contains "안함" then
|
|
148
|
+
click b
|
|
149
|
+
set handled to true
|
|
150
|
+
end if
|
|
151
|
+
end try
|
|
152
|
+
end repeat
|
|
153
|
+
end if
|
|
154
|
+
end try
|
|
155
|
+
end repeat
|
|
156
|
+
end tell
|
|
157
|
+
end try
|
|
158
|
+
if handled then return true
|
|
159
|
+
delay 0.3
|
|
160
|
+
end repeat
|
|
161
|
+
return false
|
|
162
|
+
end dismissDontSaveSheetIfPresent
|
|
@@ -162,8 +162,12 @@ on waitForWindowGone(winName, secs)
|
|
|
162
162
|
end waitForWindowGone
|
|
163
163
|
|
|
164
164
|
on waitForFile(p, secs)
|
|
165
|
+
-- size>0 alone is NOT completion: Hancom streams the PDF asynchronously and
|
|
166
|
+
-- closing the document mid-write truncates it (measured: a TOC-regenerating
|
|
167
|
+
-- document produced a deterministic %%EOF-less torso). Require the PDF
|
|
168
|
+
-- trailer marker so the export has actually finished before we move on.
|
|
165
169
|
repeat (secs * 2) times
|
|
166
|
-
if (do shell script "test -s " & quoted form of p & " && echo 1 || echo 0") is "1" then
|
|
170
|
+
if (do shell script "test -s " & quoted form of p & " && tail -c 64 " & quoted form of p & " | grep -q '%%EOF' && echo 1 || echo 0") is "1" then
|
|
167
171
|
return true
|
|
168
172
|
end if
|
|
169
173
|
delay 0.5
|
hwpx/visual/oracle.py
CHANGED
|
@@ -52,6 +52,7 @@ from hwpx.form_fit.wordbox import WordBox
|
|
|
52
52
|
_BACKEND_SCRIPT = "_render_hwpx.ps1"
|
|
53
53
|
_OPEN_RATE_SCRIPT = "_hancom_open_rate.ps1"
|
|
54
54
|
_MAC_BACKEND_SCRIPT = "_render_hwpx_mac.applescript"
|
|
55
|
+
_MAC_REFRESH_SCRIPT = "_refresh_hwpx_mac.applescript"
|
|
55
56
|
_COM_REGISTRY_KEYS = (
|
|
56
57
|
r"HWPFrame.HwpObject\CLSID",
|
|
57
58
|
r"SOFTWARE\Classes\HWPFrame.HwpObject\CLSID",
|
|
@@ -493,6 +494,42 @@ class MacHancomOracle(RenderBackend):
|
|
|
493
494
|
except OSError:
|
|
494
495
|
pass
|
|
495
496
|
|
|
497
|
+
def refresh_document(self, hwpx_path: str) -> bool:
|
|
498
|
+
"""Open ``hwpx_path``, let dirty fields regenerate, save in place, close.
|
|
499
|
+
|
|
500
|
+
The measured native-TOC re-number trigger (M7): a ``dirty="1"``
|
|
501
|
+
TABLEOFCONTENTS is rebuilt on open — Hancom itself computes entries and
|
|
502
|
+
page numbers — and CROSSREF caches recompute automatically. Exporting a
|
|
503
|
+
PDF from the same regenerating session crashes this Hancom build
|
|
504
|
+
(deterministic truncated PDFs, then the process dies), so refresh and
|
|
505
|
+
render are intentionally two separate sessions. Returns True when the
|
|
506
|
+
file was re-saved.
|
|
507
|
+
"""
|
|
508
|
+
if not self.available():
|
|
509
|
+
return False
|
|
510
|
+
src = os.path.abspath(hwpx_path)
|
|
511
|
+
try:
|
|
512
|
+
before = os.stat(src).st_mtime_ns
|
|
513
|
+
except OSError:
|
|
514
|
+
return False
|
|
515
|
+
with resources.as_file(
|
|
516
|
+
resources.files("hwpx.visual").joinpath(_MAC_REFRESH_SCRIPT)
|
|
517
|
+
) as script:
|
|
518
|
+
cmd = [self._osascript, str(script), src, str(int(self.timeout))]
|
|
519
|
+
try:
|
|
520
|
+
proc = subprocess.run(
|
|
521
|
+
cmd, capture_output=True, text=True,
|
|
522
|
+
timeout=self.timeout + 60.0, check=False,
|
|
523
|
+
)
|
|
524
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
525
|
+
return False
|
|
526
|
+
if "OK" not in (proc.stdout or ""):
|
|
527
|
+
return False
|
|
528
|
+
try:
|
|
529
|
+
return os.stat(src).st_mtime_ns != before
|
|
530
|
+
except OSError:
|
|
531
|
+
return False
|
|
532
|
+
|
|
496
533
|
|
|
497
534
|
class NullOracle(RenderBackend):
|
|
498
535
|
"""Sentinel backend for environments with no reachable Hancom.
|
|
@@ -131,6 +131,8 @@ hwpx/tools/table_navigation.py,sha256=rtbrWFKpJhqC3LD0ZXImyHgjmDR2hjHCFy3_S-qNBw
|
|
|
131
131
|
hwpx/tools/template_analyzer.py,sha256=LEc5xazy0YwPCY5sLycSvOk3U8NRMKWDYDu_rz1WZXM,23802
|
|
132
132
|
hwpx/tools/text_extract_cli.py,sha256=BmsDAwNXpDPhEayb9ez2ORtGNzPd_Xxduy4_cLXhnUw,2188
|
|
133
133
|
hwpx/tools/text_extractor.py,sha256=m9OSBd4FjZrzlMP8b4uS2Lnmjp-u-t7EBKFDF3P1bTM,25514
|
|
134
|
+
hwpx/tools/toc_author.py,sha256=vkAwMO6uTR9Rk8Sy5h6Zvx8uYb_vbptr7bBi2EBLfNI,11932
|
|
135
|
+
hwpx/tools/toc_fidelity.py,sha256=POljSwcjFFmLMtXH5WVmzGA03aBMLcN39hg52DpdRzM,17336
|
|
134
136
|
hwpx/tools/validator.py,sha256=U856izL9NcJZOiKDYoCpwOSaFNQ2Un8Jn4pzL20A96Q,7100
|
|
135
137
|
hwpx/tools/_schemas/header.xsd,sha256=mJXuFMuHGT1JnFFaluUpYUglwjMCNlfbFCRVM26eHXE,664
|
|
136
138
|
hwpx/tools/_schemas/section.xsd,sha256=MgvavVHG05RDfUnVPxVU10H4FQOja5ON04_m9Uk_m7E,522
|
|
@@ -142,17 +144,18 @@ hwpx/tools/fuzz/minimize.py,sha256=gv9fEU1PsmpXR-Z6wiejMC0umHV90GYhtO2Ht8x2__Y,1
|
|
|
142
144
|
hwpx/tools/fuzz/runner.py,sha256=NkSrlWUf813SqEwkhi05LmusMgM9r2j2JiPCqzzvPN8,17906
|
|
143
145
|
hwpx/visual/__init__.py,sha256=KCmFghTLZjWA1reS-dHhdrn2i0FBUQMUv3qghZb7Fqo,2347
|
|
144
146
|
hwpx/visual/_hancom_open_rate.ps1,sha256=stgE7ID2DuIpHWZHi414hmPuvPKnbecPO-oLrFWYBSg,16016
|
|
147
|
+
hwpx/visual/_refresh_hwpx_mac.applescript,sha256=N26GN5hYjXUwhh_LYI-wt3DWTFwshUX4hBpCDrODEXc,4684
|
|
145
148
|
hwpx/visual/_render_hwpx.ps1,sha256=r7bUPQvMMwVhz5YiUIEZPSAO18GbPPfnaK8iZb67d3w,2412
|
|
146
|
-
hwpx/visual/_render_hwpx_mac.applescript,sha256=
|
|
149
|
+
hwpx/visual/_render_hwpx_mac.applescript,sha256=Zq-D0HOlz7xlIeN1su1nL4EzSU9rBmPlvtU4HBwdr08,7980
|
|
147
150
|
hwpx/visual/detectors.py,sha256=WySbPnhyp4vTZWtkHoAiNY9G9AoZbVNgVgwaEZzt1R0,4853
|
|
148
151
|
hwpx/visual/diff.py,sha256=0X5T9IgwRZU3td-7vnPrlowovtGud7P_ymq0KVehlKk,5677
|
|
149
152
|
hwpx/visual/masks.py,sha256=oXhgynAb4uKjJtZ2BGHHdAjyvWGqSFlZFQ-iJxzHiuo,1832
|
|
150
|
-
hwpx/visual/oracle.py,sha256=
|
|
153
|
+
hwpx/visual/oracle.py,sha256=VNO-tAB-lzTdW5XtSELxg_Aau369qRFBsJvNryHF_D8,31319
|
|
151
154
|
hwpx/visual/report.py,sha256=2RhXN1KBYOZTim9FNpeUhaaDHR7oFxI6Z2DLUkDIiwE,1717
|
|
152
|
-
python_hwpx-2.
|
|
153
|
-
python_hwpx-2.
|
|
154
|
-
python_hwpx-2.
|
|
155
|
-
python_hwpx-2.
|
|
156
|
-
python_hwpx-2.
|
|
157
|
-
python_hwpx-2.
|
|
158
|
-
python_hwpx-2.
|
|
155
|
+
python_hwpx-2.20.0.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
|
|
156
|
+
python_hwpx-2.20.0.dist-info/licenses/NOTICE,sha256=k48h6EaGQE8Y1c0dS9sIOOcz4YqkbcImWClF7pBOgsg,2473
|
|
157
|
+
python_hwpx-2.20.0.dist-info/METADATA,sha256=ocHd1cXv2SP9a-kS7cxsutbef5hTE-3Ox8IpK2ii8FA,19982
|
|
158
|
+
python_hwpx-2.20.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
159
|
+
python_hwpx-2.20.0.dist-info/entry_points.txt,sha256=4U6WXYWHxEiWp2VRHo97fvOYNh7ebu6roonk7chxKcY,453
|
|
160
|
+
python_hwpx-2.20.0.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
|
|
161
|
+
python_hwpx-2.20.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|