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.
@@ -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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hwpx
3
- Version: 2.19.0
3
+ Version: 2.20.0
4
4
  Summary: 한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 자동화 라이브러리
5
5
  Author: python-hwpx Maintainers
6
6
  License-Expression: Apache-2.0
@@ -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=ghyvFNwtJ3-0cfy8jQ6HyuJVnFFfXt_Uy5tHjccd5os,7620
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=aON9dPbpbYnUoQ7b1aD5bez2d8BevYeZgWbxdIhP_sA,29746
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.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,,
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,,