python-hwpx 2.18.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,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,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}