python-hwpx 3.1.0__py3-none-any.whl → 3.3.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/agent/commands.py CHANGED
@@ -21,7 +21,7 @@ from pathlib import Path
21
21
  from typing import Any
22
22
  from xml.etree import ElementTree as ET
23
23
 
24
- from lxml import etree as LET
24
+ from lxml import etree as LET # type: ignore[reportAttributeAccessIssue] # lxml has no complete bundled typing
25
25
 
26
26
  from hwpx.document import HwpxDocument
27
27
  from hwpx.oxml import HwpxOxmlTable
@@ -38,6 +38,12 @@ from .model import (
38
38
  validate_agent_batch,
39
39
  )
40
40
  from .path import parse_path
41
+ from .story import (
42
+ HEADER_STORY_EDITABLE_PROPERTIES,
43
+ HEADER_STORY_KIND,
44
+ HeaderStoryBinding,
45
+ try_parse_header_story_path,
46
+ )
41
47
 
42
48
  _EMPTY_REVISION = "sha256:" + hashlib.sha256(b"").hexdigest()
43
49
  _HWP_UNITS_PER_MM = 7200 / 25.4
@@ -242,6 +248,21 @@ def _validate_property_values(kind: str, properties: Mapping[str, Any], *, creat
242
248
  raise AgentContractError("unknown_property", f"untyped property: {target}", target=target)
243
249
 
244
250
 
251
+ def _validate_header_story_properties(properties: Mapping[str, Any]) -> None:
252
+ unknown = sorted(set(properties) - HEADER_STORY_EDITABLE_PROPERTIES)
253
+ if unknown:
254
+ raise AgentContractError(
255
+ "unknown_property",
256
+ f"header does not support properties: {unknown}",
257
+ target=f"header.{unknown[0]}",
258
+ )
259
+ if "text" not in properties:
260
+ raise AgentContractError(
261
+ "invalid_syntax", "existing header set requires text", target="header.text"
262
+ )
263
+ _require_string(properties["text"], "header.text")
264
+
265
+
245
266
  def _preflight(batch: Mapping[str, Any]) -> None:
246
267
  """Reject the complete static command matrix before the first mutation."""
247
268
 
@@ -254,6 +275,9 @@ def _preflight(batch: Mapping[str, Any]) -> None:
254
275
  return alias_kinds[command_id][field]
255
276
  except KeyError as exc: # validate_agent_batch already checks ordering
256
277
  raise AgentContractError("not_found", f"unknown command alias: {value}") from exc
278
+ story = try_parse_header_story_path(value)
279
+ if story is not None:
280
+ return story.kind
257
281
  parsed = parse_path(value)
258
282
  return parsed.segments[-1].kind if parsed.segments else "document"
259
283
 
@@ -264,6 +288,9 @@ def _preflight(batch: Mapping[str, Any]) -> None:
264
288
  return alias_kinds[command_id]["parentPath" if field == "path" else field]
265
289
  except KeyError:
266
290
  return "document"
291
+ story = try_parse_header_story_path(value)
292
+ if story is not None:
293
+ return "section"
267
294
  parsed = parse_path(value)
268
295
  return parsed.segments[-2].kind if len(parsed.segments) > 1 else "document"
269
296
 
@@ -298,6 +325,19 @@ def _preflight(batch: Mapping[str, Any]) -> None:
298
325
  }
299
326
  continue
300
327
  source_kind = reference_kind(command["path"])
328
+ if source_kind == HEADER_STORY_KIND:
329
+ if op != "set":
330
+ raise AgentContractError(
331
+ "unsupported_operation",
332
+ f"{op} is unsupported for {source_kind}",
333
+ target=command["commandId"],
334
+ )
335
+ _validate_header_story_properties(command["properties"])
336
+ alias_kinds[command["commandId"]] = {
337
+ "path": source_kind,
338
+ "parentPath": "section",
339
+ }
340
+ continue
301
341
  if op not in NODE_PROPERTY_CATALOG_V1[source_kind]["operations"]:
302
342
  raise AgentContractError(
303
343
  "unsupported_operation",
@@ -309,11 +349,11 @@ def _preflight(batch: Mapping[str, Any]) -> None:
309
349
  destination_kind = parent_kind(command["path"])
310
350
  if op in {"move", "copy"}:
311
351
  destination_kind = reference_kind(command["parent"])
312
- expected_parent = _MOVE_COPY_PARENTS.get(source_kind)
313
- if destination_kind != expected_parent:
352
+ move_parent = _MOVE_COPY_PARENTS.get(source_kind)
353
+ if destination_kind != move_parent:
314
354
  raise AgentContractError(
315
355
  "incompatible_parent",
316
- f"{source_kind} requires a {expected_parent} parent, not {destination_kind}",
356
+ f"{source_kind} requires a {move_parent} parent, not {destination_kind}",
317
357
  target=command["commandId"],
318
358
  )
319
359
  alias_kinds[command["commandId"]] = {
@@ -570,6 +610,42 @@ def _mark_containing_section(document: HwpxDocument, target: Any) -> None:
570
610
  raise AgentContractError("not_found", "mutated element is detached")
571
611
 
572
612
 
613
+ def _apply_header_story_set(
614
+ binding: HeaderStoryBinding, properties: Mapping[str, Any]
615
+ ) -> dict[str, Any]:
616
+ """Apply the bounded existing-header mutation through its OXML owner."""
617
+
618
+ _validate_header_story_properties(properties)
619
+ try:
620
+ binding.native.set_simple_text_preserving(properties["text"])
621
+ except AgentContractError:
622
+ raise
623
+ except ValueError as exc:
624
+ # The OXML owner uses ValueError for rich/control-bearing or otherwise
625
+ # structurally unsafe headers. Do not let it degrade to the generic
626
+ # invariant envelope or fall back to the destructive whole-story setter.
627
+ message = str(exc) or "header content cannot be edited losslessly"
628
+ lowered = message.lower()
629
+ code = (
630
+ "ambiguous_target"
631
+ if "ambiguous" in lowered
632
+ else "not_found"
633
+ if "not found" in lowered
634
+ else "unsupported_content"
635
+ )
636
+ raise AgentContractError(
637
+ code,
638
+ message,
639
+ target=binding.path,
640
+ ) from exc
641
+ return {
642
+ "text": {
643
+ "before": binding.text,
644
+ "after": properties["text"],
645
+ }
646
+ }
647
+
648
+
573
649
  def _apply_set(document: HwpxDocument, record: NodeRecord, properties: Mapping[str, Any]) -> dict[str, Any]:
574
650
  _ensure_operation(record, "set")
575
651
  _validate_property_values(record.kind, properties, creating=False)
@@ -711,7 +787,10 @@ def _add(
711
787
  if "pageHeightMm" in properties:
712
788
  size_kwargs["height"] = round(properties["pageHeightMm"] * _HWP_UNITS_PER_MM)
713
789
  if size_kwargs:
714
- created.properties.set_page_size(**size_kwargs)
790
+ created.properties.set_page_size(
791
+ width=size_kwargs.get("width"),
792
+ height=size_kwargs.get("height"),
793
+ )
715
794
  return created
716
795
  if kind == "paragraph":
717
796
  created = parent.native.add_paragraph(str(properties.get("text", "")))
@@ -1081,6 +1160,47 @@ def _member_diff(before: bytes, after: bytes) -> dict[str, Any]:
1081
1160
  return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
1082
1161
 
1083
1162
 
1163
+ def _verify_header_story_candidates(
1164
+ candidate_data: bytes,
1165
+ candidate_revision: str,
1166
+ expectations: Mapping[str, Mapping[str, str]],
1167
+ ) -> dict[str, Any]:
1168
+ receipts: list[dict[str, Any]] = []
1169
+ if expectations:
1170
+ with HwpxDocument.open(candidate_data) as reopened:
1171
+ view = HwpxAgentDocument.from_document(
1172
+ reopened, revision=candidate_revision
1173
+ )
1174
+ for expectation in expectations.values():
1175
+ path = expectation["path"]
1176
+ binding = view._resolve_header_story(path)
1177
+ if (
1178
+ binding.stable_id != expectation["stableId"]
1179
+ or binding.page_type != expectation["pageType"]
1180
+ or binding.text != expectation["text"]
1181
+ ):
1182
+ raise AgentContractError(
1183
+ "verification_failed",
1184
+ "reopened header story does not match the committed binding",
1185
+ target=path,
1186
+ )
1187
+ receipts.append(
1188
+ {
1189
+ "commandId": expectation["commandId"],
1190
+ "path": binding.path,
1191
+ "stableId": binding.stable_id,
1192
+ "pageType": binding.page_type,
1193
+ "textMatched": True,
1194
+ }
1195
+ )
1196
+ return {
1197
+ "schemaVersion": "hwpx.agent-story-preservation/v1",
1198
+ "ok": True,
1199
+ "storyCount": len(receipts),
1200
+ "stories": receipts,
1201
+ }
1202
+
1203
+
1084
1204
  def _request_hash(batch: Mapping[str, Any]) -> str:
1085
1205
  payload = json.dumps(
1086
1206
  batch,
@@ -1192,6 +1312,7 @@ def apply_document_commands(
1192
1312
  aliases: dict[str, dict[str, str]] = {}
1193
1313
  identity_changes: list[Mapping[str, str]] = []
1194
1314
  semantic_changes: list[Mapping[str, Any]] = []
1315
+ story_expectations: dict[str, Mapping[str, str]] = {}
1195
1316
  _call_fault(fault_injector, "before_open")
1196
1317
  with HwpxDocument.open(input_data) as document:
1197
1318
  view = HwpxAgentDocument.from_document(document, revision=input_revision)
@@ -1203,11 +1324,24 @@ def apply_document_commands(
1203
1324
  parent_path: str | None = None
1204
1325
  changed: dict[str, Any] = {}
1205
1326
  generated: list[dict[str, str]] = []
1327
+ target_native: Any | None = None
1328
+ story_before: HeaderStoryBinding | None = None
1206
1329
  if op == "set":
1207
1330
  resolved_path = _resolve_alias(command["path"], aliases)
1208
- record = view.resolve_record(resolved_path, expected_revision=input_revision)
1209
- changed = _apply_set(document, record, command["properties"])
1210
- target_native = record.native
1331
+ story_path = try_parse_header_story_path(resolved_path)
1332
+ if story_path is not None:
1333
+ story_before = view._resolve_header_story(
1334
+ story_path, expected_revision=input_revision
1335
+ )
1336
+ changed = _apply_header_story_set(
1337
+ story_before, command["properties"]
1338
+ )
1339
+ else:
1340
+ record = view.resolve_record(
1341
+ resolved_path, expected_revision=input_revision
1342
+ )
1343
+ changed = _apply_set(document, record, command["properties"])
1344
+ target_native = record.native
1211
1345
  elif op == "add":
1212
1346
  parent_path = _resolve_alias(command["parent"], aliases)
1213
1347
  parent = view.resolve_record(parent_path, expected_revision=input_revision)
@@ -1244,7 +1378,26 @@ def apply_document_commands(
1244
1378
 
1245
1379
  _call_fault(fault_injector, "after_command", index)
1246
1380
  view = HwpxAgentDocument.from_document(document, revision=input_revision)
1247
- if op == "set" and resolved_path is not None:
1381
+ result_stable_id: str | None = None
1382
+ if story_before is not None and resolved_path is not None:
1383
+ target_story = view._resolve_header_story(resolved_path)
1384
+ if target_story.stable_id != story_before.stable_id:
1385
+ raise AgentContractError(
1386
+ "invariant_violation",
1387
+ "header story identity changed during text edit",
1388
+ target=resolved_path,
1389
+ )
1390
+ result_path = target_story.path
1391
+ result_parent = target_story.parent_path
1392
+ result_stable_id = target_story.stable_id
1393
+ story_expectations[target_story.binding_key] = {
1394
+ "commandId": command_id,
1395
+ "path": target_story.path,
1396
+ "stableId": target_story.stable_id,
1397
+ "pageType": target_story.page_type,
1398
+ "text": command["properties"]["text"],
1399
+ }
1400
+ elif op == "set" and resolved_path is not None:
1248
1401
  # Some native bindings (notably form fields) are request-local
1249
1402
  # match dictionaries. A property edit is non-structural, so
1250
1403
  # its canonical path is the durable post-edit lookup key.
@@ -1269,6 +1422,11 @@ def apply_document_commands(
1269
1422
  "generatedIdentities": generated,
1270
1423
  "warnings": [],
1271
1424
  }
1425
+ if result_stable_id is not None:
1426
+ # Command results are already an untyped JSON mapping. This
1427
+ # story-only receipt carries the actual stable identity
1428
+ # without changing AgentBatchResult or the ToolSpec schema.
1429
+ result["stableId"] = result_stable_id
1272
1430
  command_results.append(result)
1273
1431
  semantic_changes.append(
1274
1432
  {
@@ -1285,6 +1443,12 @@ def apply_document_commands(
1285
1443
  _call_fault(fault_injector, "after_serialize")
1286
1444
 
1287
1445
  candidate_revision = _revision(candidate_data)
1446
+ if story_expectations:
1447
+ verification["storyPreservation"] = _verify_header_story_candidates(
1448
+ candidate_data,
1449
+ candidate_revision,
1450
+ story_expectations,
1451
+ )
1288
1452
  semantic_diff = {
1289
1453
  "schemaVersion": "hwpx.agent-semantic-diff/v1",
1290
1454
  "inputRevision": input_revision,
hwpx/agent/document.py CHANGED
@@ -8,7 +8,7 @@ from collections import Counter, defaultdict
8
8
  from dataclasses import dataclass, field
9
9
  from os import PathLike
10
10
  from pathlib import Path
11
- from typing import Any
11
+ from typing import Any, Sequence, cast
12
12
 
13
13
  from hwpx.document import HwpxDocument
14
14
  from hwpx.oxml import HwpxOxmlShape
@@ -22,7 +22,14 @@ from .model import (
22
22
  NODE_PROPERTY_CATALOG_V1,
23
23
  )
24
24
  from .path import SemanticPath, canonicalize_path, identified_segment, indexed_segment
25
+ from .query import QueryRecord as _QueryRecord
25
26
  from .query import QueryResult, evaluate_selector, parse_selector
27
+ from .story import (
28
+ HeaderStoryBinding,
29
+ HeaderStoryPath,
30
+ parse_header_story_path,
31
+ resolve_header_story,
32
+ )
26
33
 
27
34
  _HWP_UNITS_PER_MM = 7200 / 25.4
28
35
  _SHAPE_KINDS = frozenset({"line", "rect", "ellipse", "arc", "polygon", "curve", "connectLine"})
@@ -129,7 +136,7 @@ class HwpxAgentDocument:
129
136
  def __enter__(self) -> "HwpxAgentDocument":
130
137
  return self
131
138
 
132
- def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
139
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: # type: ignore[exit-return] # frozen public signature
133
140
  self.close()
134
141
  return False
135
142
 
@@ -689,6 +696,25 @@ class HwpxAgentDocument:
689
696
  raise AgentContractError("volatile_target", "target has a positional path", target=canonical)
690
697
  return record
691
698
 
699
+ def _resolve_header_story(
700
+ self,
701
+ path: str | HeaderStoryPath,
702
+ *,
703
+ expected_revision: str | None = None,
704
+ ) -> HeaderStoryBinding:
705
+ """Resolve the private command-only existing-header seam.
706
+
707
+ Header stories deliberately remain absent from ``records``, ``get``,
708
+ and ``query`` so the frozen public projection/catalog does not drift.
709
+ """
710
+
711
+ parsed = parse_header_story_path(path) if isinstance(path, str) else path
712
+ if expected_revision is not None and expected_revision != self.revision:
713
+ raise AgentContractError(
714
+ "stale_revision", "document revision does not match", target=parsed.canonical
715
+ )
716
+ return resolve_header_story(self.document, parsed)
717
+
692
718
  def _public_node(self, record: NodeRecord, *, depth: int, child_limit: int) -> AgentNode:
693
719
  supported_total = len(record.child_paths)
694
720
  selected_paths = record.child_paths[:child_limit] if depth > 0 else []
@@ -751,7 +777,8 @@ class HwpxAgentDocument:
751
777
  if expected_revision is not None and expected_revision != self.revision:
752
778
  raise AgentContractError("stale_revision", "document revision does not match", target="selector")
753
779
  parsed = parse_selector(selector)
754
- matches, truncated = evaluate_selector(self.records, parsed, limit=limit)
780
+ query_records = cast(Sequence[_QueryRecord], self.records)
781
+ matches, truncated = evaluate_selector(query_records, parsed, limit=limit)
755
782
  if not 0 <= node_depth <= MAX_VIEW_DEPTH:
756
783
  raise AgentContractError("resource_limit", "nodeDepth is out of bounds", target="nodeDepth")
757
784
  if not 1 <= child_limit <= MAX_CHILDREN_PER_NODE:
@@ -760,7 +787,12 @@ class HwpxAgentDocument:
760
787
  selector=selector,
761
788
  revision=self.revision,
762
789
  nodes=tuple(
763
- self._public_node(record, depth=node_depth, child_limit=child_limit) for record in matches
790
+ self._public_node(
791
+ cast(NodeRecord, record),
792
+ depth=node_depth,
793
+ child_limit=child_limit,
794
+ )
795
+ for record in matches
764
796
  ),
765
797
  truncated=truncated,
766
798
  )
hwpx/agent/story.py ADDED
@@ -0,0 +1,207 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Private, contract-neutral bindings for existing non-body stories.
3
+
4
+ The public semantic path grammar intentionally remains frozen at the Feature
5
+ 024 catalog. This module recognizes one narrower command-only seam: an
6
+ existing section header selected by its native id or page type. It does not
7
+ project headers into the public view, accept package paths, or expose XML.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ from .model import AgentContractError
18
+ from .path import MAX_PATH_CHARS
19
+
20
+ HEADER_STORY_KIND = "header"
21
+ HEADER_STORY_EDITABLE_PROPERTIES = frozenset({"text"})
22
+ HEADER_PAGE_TYPES = frozenset({"BOTH", "EVEN", "ODD"})
23
+
24
+ _HEADER_STORY_RE = re.compile(
25
+ r"^/section\[(?P<section>0*[1-9][0-9]*)\]/header\["
26
+ r"@(?P<attribute>id|page-type)=(?P<value>\"(?:[^\"\\]|\\.)*\")\]$"
27
+ )
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class HeaderStoryPath:
32
+ """Parsed command-only path for one existing section header."""
33
+
34
+ section_index: int
35
+ attribute: str
36
+ value: str
37
+
38
+ @property
39
+ def kind(self) -> str:
40
+ return HEADER_STORY_KIND
41
+
42
+ @property
43
+ def canonical(self) -> str:
44
+ value = json.dumps(self.value, ensure_ascii=False, separators=(",", ":"))
45
+ return (
46
+ f"/section[{self.section_index}]/"
47
+ f"header[@{self.attribute}={value}]"
48
+ )
49
+
50
+ @property
51
+ def parent_path(self) -> str:
52
+ return f"/section[{self.section_index}]"
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class HeaderStoryBinding:
57
+ """Request-local native binding for an existing logical header."""
58
+
59
+ path: str
60
+ parent_path: str
61
+ stable_id: str
62
+ native_id: str
63
+ page_type: str
64
+ section_index: int
65
+ native: Any
66
+ text: str
67
+
68
+ @property
69
+ def kind(self) -> str:
70
+ return HEADER_STORY_KIND
71
+
72
+ @property
73
+ def binding_key(self) -> str:
74
+ # Native ids are not promised to be document-global. Section scope is
75
+ # part of the logical binding even though the receipt exposes the same
76
+ # kind:id stable-id convention used by projected semantic nodes.
77
+ return f"{self.section_index}:{self.native_id}"
78
+
79
+
80
+ def try_parse_header_story_path(value: object) -> HeaderStoryPath | None:
81
+ """Return a private header path, or ``None`` for the public path parser.
82
+
83
+ Only exact supported forms are intercepted. Unsupported forms such as
84
+ ``header[1]`` deliberately continue through :func:`parse_path` and retain
85
+ the frozen ``unknown_kind`` behavior.
86
+ """
87
+
88
+ if not isinstance(value, str) or "/header[@" not in value:
89
+ return None
90
+ if len(value) > MAX_PATH_CHARS:
91
+ raise AgentContractError("resource_limit", "path is too long", target="path")
92
+ match = _HEADER_STORY_RE.fullmatch(value)
93
+ if match is None:
94
+ return None
95
+ try:
96
+ decoded = json.loads(match.group("value"))
97
+ except (TypeError, ValueError, json.JSONDecodeError) as exc:
98
+ raise AgentContractError(
99
+ "invalid_syntax", "invalid header story path string", target="path"
100
+ ) from exc
101
+ if not isinstance(decoded, str) or not decoded or len(decoded) > 256:
102
+ raise AgentContractError(
103
+ "resource_limit", "header story selector is invalid", target="path"
104
+ )
105
+ attribute = match.group("attribute")
106
+ if attribute == "page-type" and decoded not in HEADER_PAGE_TYPES:
107
+ raise AgentContractError(
108
+ "invalid_syntax",
109
+ "header page type must be BOTH, EVEN, or ODD",
110
+ target="path",
111
+ )
112
+ return HeaderStoryPath(
113
+ section_index=int(match.group("section")),
114
+ attribute=attribute,
115
+ value=decoded,
116
+ )
117
+
118
+
119
+ def parse_header_story_path(value: str) -> HeaderStoryPath:
120
+ """Parse an exact private header path for focused internal tests."""
121
+
122
+ parsed = try_parse_header_story_path(value)
123
+ if parsed is None:
124
+ raise AgentContractError(
125
+ "invalid_syntax", "unsupported header story path", target="path"
126
+ )
127
+ return parsed
128
+
129
+
130
+ def resolve_header_story(document: Any, path: HeaderStoryPath) -> HeaderStoryBinding:
131
+ """Resolve one unique direct logical header without scanning descendants."""
132
+
133
+ try:
134
+ sections = document.sections
135
+ except (AttributeError, TypeError, ValueError) as exc:
136
+ raise AgentContractError(
137
+ "unsupported_content", "document section structure is unavailable", target=path.canonical
138
+ ) from exc
139
+ if path.section_index > len(sections):
140
+ raise AgentContractError(
141
+ "not_found", "header story section does not exist", target=path.canonical
142
+ )
143
+ section = sections[path.section_index - 1]
144
+ try:
145
+ headers = tuple(section.properties.headers)
146
+ if path.attribute == "id":
147
+ matches = [header for header in headers if header.id == path.value]
148
+ else:
149
+ matches = [
150
+ header for header in headers if header.apply_page_type == path.value
151
+ ]
152
+ except (AttributeError, TypeError, ValueError) as exc:
153
+ raise AgentContractError(
154
+ "unsupported_content", "section header structure is invalid", target=path.canonical
155
+ ) from exc
156
+
157
+ if not matches:
158
+ raise AgentContractError(
159
+ "not_found", f"existing header story not found: {path.canonical}", target=path.canonical
160
+ )
161
+ if len(matches) > 1:
162
+ raise AgentContractError(
163
+ "ambiguous_target",
164
+ f"header story selector is not unique: {path.canonical}",
165
+ target=path.canonical,
166
+ )
167
+
168
+ header = matches[0]
169
+ try:
170
+ native_id = header.id
171
+ page_type = header.apply_page_type
172
+ text = header.text
173
+ except (AttributeError, TypeError, ValueError) as exc:
174
+ raise AgentContractError(
175
+ "unsupported_content", "header story cannot be read safely", target=path.canonical
176
+ ) from exc
177
+ if not native_id or len(native_id) > 256:
178
+ raise AgentContractError(
179
+ "unsupported_content", "header story has no bounded native identity", target=path.canonical
180
+ )
181
+ if page_type not in HEADER_PAGE_TYPES:
182
+ raise AgentContractError(
183
+ "unsupported_content", "header story has an unsupported page type", target=path.canonical
184
+ )
185
+
186
+ return HeaderStoryBinding(
187
+ path=path.canonical,
188
+ parent_path=path.parent_path,
189
+ stable_id=f"header:{native_id}",
190
+ native_id=native_id,
191
+ page_type=page_type,
192
+ section_index=path.section_index,
193
+ native=header,
194
+ text=text,
195
+ )
196
+
197
+
198
+ __all__ = [
199
+ "HEADER_PAGE_TYPES",
200
+ "HEADER_STORY_EDITABLE_PROPERTIES",
201
+ "HEADER_STORY_KIND",
202
+ "HeaderStoryBinding",
203
+ "HeaderStoryPath",
204
+ "parse_header_story_path",
205
+ "resolve_header_story",
206
+ "try_parse_header_story_path",
207
+ ]
hwpx/document.py CHANGED
@@ -13,7 +13,7 @@ import uuid
13
13
 
14
14
  from os import PathLike
15
15
  from pathlib import PurePosixPath
16
- from typing import TYPE_CHECKING, Any, BinaryIO, Iterator, Mapping, Sequence, overload
16
+ from typing import TYPE_CHECKING, Any, BinaryIO, Iterator, Mapping, Sequence, cast, overload
17
17
 
18
18
 
19
19
  from .oxml import (
@@ -65,7 +65,12 @@ if TYPE_CHECKING:
65
65
  from .form_fit.policy import FitPolicy
66
66
  from .form_fit.report import FitResult
67
67
  from .tools.validator import ValidationReport
68
- from .tools.table_navigation import TableFillResult, TableLabelSearchResult, TableMapResult
68
+ from .tools.table_navigation import (
69
+ SearchDirection,
70
+ TableFillResult,
71
+ TableLabelSearchResult,
72
+ TableMapResult,
73
+ )
69
74
 
70
75
 
71
76
  def _append_element(
@@ -343,7 +348,9 @@ class HwpxDocument:
343
348
  stream = io.BytesIO(source)
344
349
  open_source = stream
345
350
  internal_resources.append(stream)
346
- package = HwpxPackage.open(open_source)
351
+ # HwpxPackage/ZipFile accepts os.PathLike at runtime; its narrower
352
+ # compatibility annotation intentionally remains frozen.
353
+ package = HwpxPackage.open(cast(Any, open_source))
347
354
  root = HwpxOxmlDocument.from_package(package)
348
355
  return cls(package, root, managed_resources=tuple(internal_resources))
349
356
 
@@ -368,7 +375,7 @@ class HwpxDocument:
368
375
 
369
376
  return self
370
377
 
371
- def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
378
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: # type: ignore[exit-return] # frozen public signature
372
379
  """예외 발생 여부와 무관하게 내부 자원을 안전하게 정리합니다."""
373
380
 
374
381
  self.close()
@@ -1072,7 +1079,7 @@ class HwpxDocument:
1072
1079
  run_attributes=run_attributes,
1073
1080
  include_run=include_run,
1074
1081
  inherit_style=inherit_style,
1075
- **extra_attrs,
1082
+ **cast(Any, extra_attrs),
1076
1083
  )
1077
1084
 
1078
1085
  def add_table(
@@ -1105,7 +1112,7 @@ class HwpxDocument:
1105
1112
  style_id_ref=style_id_ref,
1106
1113
  char_pr_id_ref=char_pr_id_ref,
1107
1114
  include_run=False,
1108
- **extra_attrs,
1115
+ **cast(Any, extra_attrs),
1109
1116
  )
1110
1117
  return paragraph.add_table(
1111
1118
  rows,
@@ -1163,7 +1170,7 @@ class HwpxDocument:
1163
1170
  style_id_ref=style_id_ref,
1164
1171
  char_pr_id_ref=char_pr_id_ref,
1165
1172
  include_run=False,
1166
- **extra_attrs,
1173
+ **cast(Any, extra_attrs),
1167
1174
  )
1168
1175
  return paragraph.add_picture(
1169
1176
  binary_item_id_ref,
@@ -1288,7 +1295,11 @@ class HwpxDocument:
1288
1295
 
1289
1296
  from .tools.table_navigation import find_cell_by_label
1290
1297
 
1291
- return find_cell_by_label(self, label_text, direction=direction)
1298
+ return find_cell_by_label(
1299
+ self,
1300
+ label_text,
1301
+ direction=cast("SearchDirection", direction),
1302
+ )
1292
1303
 
1293
1304
  def _find_field_end_position(
1294
1305
  self,
@@ -1664,7 +1675,7 @@ class HwpxDocument:
1664
1675
  return FitEngine().fit(value, slot, fit_policy, field_id=field_id)
1665
1676
 
1666
1677
  def _font_pt_for_ref(self, char_pr_id_ref: object) -> float:
1667
- style = self.char_property(char_pr_id_ref)
1678
+ style = self.char_property(cast(Any, char_pr_id_ref))
1668
1679
  if style is not None:
1669
1680
  height = style.attributes.get("height")
1670
1681
  if height:
@@ -1729,7 +1740,7 @@ class HwpxDocument:
1729
1740
  style_id_ref=style_id_ref,
1730
1741
  char_pr_id_ref=char_pr_id_ref,
1731
1742
  include_run=False,
1732
- **extra_attrs,
1743
+ **cast(Any, extra_attrs),
1733
1744
  )
1734
1745
  return paragraph.add_shape(
1735
1746
  shape_type,
@@ -1761,7 +1772,7 @@ class HwpxDocument:
1761
1772
  style_id_ref=style_id_ref,
1762
1773
  char_pr_id_ref=char_pr_id_ref,
1763
1774
  include_run=False,
1764
- **extra_attrs,
1775
+ **cast(Any, extra_attrs),
1765
1776
  )
1766
1777
  return paragraph.add_control(
1767
1778
  attributes=attributes,