python-hwpx 3.2.0__py3-none-any.whl → 3.3.1__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.
@@ -242,10 +242,7 @@ def _validate_resource(value: object, *, name: str) -> dict[str, Any]:
242
242
  return record
243
243
 
244
244
 
245
- def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool = True) -> dict[str, Any]:
246
- """Validate and detach one strict ``hwpx.agent-blueprint/v1`` manifest."""
247
-
248
- manifest = _object(value, "blueprint")
245
+ def _validate_manifest_header(manifest: dict[str, Any]) -> None:
249
246
  _exact_keys(manifest, required=_MANIFEST_KEYS, name="blueprint")
250
247
  if manifest["schemaVersion"] != BLUEPRINT_SCHEMA:
251
248
  raise AgentContractError("invalid_syntax", "unsupported blueprint schema", target="schemaVersion")
@@ -257,12 +254,16 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
257
254
  if mode not in BLUEPRINT_MODES:
258
255
  raise AgentContractError("invalid_syntax", "unsupported blueprint mode", target="mode")
259
256
 
257
+
258
+ def _validate_source(manifest: dict[str, Any]) -> None:
260
259
  source = _object(manifest["source"], "source")
261
260
  _exact_keys(source, required={"revision", "label"}, name="source")
262
261
  if not REVISION_PATTERN.fullmatch(str(source["revision"])):
263
262
  raise AgentContractError("invalid_syntax", "source.revision must be sha256", target="source.revision")
264
263
  _validate_public_json(source, name="source")
265
264
 
265
+
266
+ def _validate_root(manifest: dict[str, Any]) -> dict[str, Any]:
266
267
  root = _object(manifest["root"], "root")
267
268
  _exact_keys(
268
269
  root,
@@ -275,7 +276,12 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
275
276
  raise AgentContractError("unknown_kind", "root kind is unknown", target="root.kind")
276
277
  if not str(root["sourcePath"]).startswith("/"):
277
278
  raise AgentContractError("invalid_syntax", "root sourcePath must be semantic", target="root.sourcePath")
279
+ return root
280
+
278
281
 
282
+ def _validate_nodes(
283
+ manifest: dict[str, Any],
284
+ ) -> tuple[list[dict[str, Any]], set[str], dict[str, dict[str, Any]]]:
279
285
  nodes = _list(manifest["nodes"], "nodes")
280
286
  if not nodes or len(nodes) > MAX_BLUEPRINT_NODES:
281
287
  raise AgentContractError("resource_limit", "node count is outside limits", target="nodes")
@@ -321,7 +327,15 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
321
327
  _validate_public_json(node["sourceHint"], name=f"{name}.sourceHint")
322
328
  detached_nodes.append(node)
323
329
  nodes_by_id[node_id] = node
330
+ return detached_nodes, node_ids, nodes_by_id
331
+
324
332
 
333
+ def _check_node_tree_shape(
334
+ root: dict[str, Any],
335
+ detached_nodes: list[dict[str, Any]],
336
+ node_ids: set[str],
337
+ nodes_by_id: dict[str, dict[str, Any]],
338
+ ) -> str:
325
339
  if str(root["blueprintId"]) not in node_ids:
326
340
  raise AgentContractError("invariant_violation", "root does not reference a node", target="root")
327
341
  for index, node in enumerate(detached_nodes):
@@ -344,6 +358,14 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
344
358
  raise AgentContractError(
345
359
  "invariant_violation", "blueprint nodes must form one rooted tree", target="nodes"
346
360
  )
361
+ return root_id
362
+
363
+
364
+ def _check_node_reachability(
365
+ root_id: str,
366
+ node_ids: set[str],
367
+ nodes_by_id: dict[str, dict[str, Any]],
368
+ ) -> None:
347
369
  visited: set[str] = set()
348
370
  visiting: set[str] = set()
349
371
 
@@ -364,6 +386,10 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
364
386
  if visited != node_ids:
365
387
  raise AgentContractError("invariant_violation", "blueprint contains orphan nodes", target="nodes")
366
388
 
389
+
390
+ def _validate_dependency_records(
391
+ manifest: dict[str, Any],
392
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
367
393
  styles = _list(manifest["styles"], "styles")
368
394
  numbering = _list(manifest["numbering"], "numbering")
369
395
  resources = _list(manifest["resources"], "resources")
@@ -382,6 +408,14 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
382
408
  detached_resources = [
383
409
  _validate_resource(item, name=f"resources[{index}]") for index, item in enumerate(resources)
384
410
  ]
411
+ return detached_styles, detached_numbering, detached_resources
412
+
413
+
414
+ def _check_dependency_uniqueness(
415
+ detached_styles: list[dict[str, Any]],
416
+ detached_numbering: list[dict[str, Any]],
417
+ detached_resources: list[dict[str, Any]],
418
+ ) -> list[str]:
385
419
  style_keys = [str(item["key"]) for item in detached_styles]
386
420
  numbering_keys = [str(item["key"]) for item in detached_numbering]
387
421
  resource_keys = [str(item["key"]) for item in detached_resources]
@@ -393,10 +427,18 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
393
427
  raise AgentContractError("invariant_violation", "resource asset paths must be unique", target="resources")
394
428
  if sum(int(item["size"]) for item in detached_resources) > MAX_TOTAL_ASSET_BYTES:
395
429
  raise AgentContractError("resource_limit", "total asset bytes exceed limit", target="resources")
430
+ return dependency_keys
431
+
396
432
 
397
- style_key_set = set(style_keys)
398
- numbering_key_set = set(numbering_keys)
399
- resource_key_set = set(resource_keys)
433
+ def _check_node_dependency_refs(
434
+ detached_nodes: list[dict[str, Any]],
435
+ detached_styles: list[dict[str, Any]],
436
+ detached_numbering: list[dict[str, Any]],
437
+ detached_resources: list[dict[str, Any]],
438
+ ) -> None:
439
+ style_key_set = {str(item["key"]) for item in detached_styles}
440
+ numbering_key_set = {str(item["key"]) for item in detached_numbering}
441
+ resource_key_set = {str(item["key"]) for item in detached_resources}
400
442
  for index, node in enumerate(detached_nodes):
401
443
  if not set(node["styleRefs"]) <= style_key_set:
402
444
  raise AgentContractError("invariant_violation", "node has an orphan style reference", target=f"nodes[{index}].styleRefs")
@@ -405,6 +447,12 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
405
447
  if not set(node["resourceRefs"]) <= resource_key_set:
406
448
  raise AgentContractError("invariant_violation", "node has an orphan resource reference", target=f"nodes[{index}].resourceRefs")
407
449
 
450
+
451
+ def _validate_reference_records(
452
+ manifest: dict[str, Any],
453
+ node_ids: set[str],
454
+ dependency_keys: list[str],
455
+ ) -> tuple[list[dict[str, Any]], dict[str, list[str]]]:
408
456
  references = _list(manifest["references"], "references")
409
457
  if len(references) > MAX_REFERENCES:
410
458
  raise AgentContractError("resource_limit", "reference count exceeds limit", target="references")
@@ -423,6 +471,13 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
423
471
  if target in node_ids:
424
472
  reference_edges[str(record["from"])].append(target)
425
473
  reference_records.append(record)
474
+ return reference_records, reference_edges
475
+
476
+
477
+ def _check_reference_inventory(
478
+ detached_nodes: list[dict[str, Any]],
479
+ reference_records: list[dict[str, Any]],
480
+ ) -> None:
426
481
  for index, node in enumerate(detached_nodes):
427
482
  declared_fields = {
428
483
  str(record["field"])
@@ -431,6 +486,12 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
431
486
  }
432
487
  if set(node["references"]) != declared_fields:
433
488
  raise AgentContractError("invariant_violation", "node reference inventory is inconsistent", target=f"nodes[{index}].references")
489
+
490
+
491
+ def _check_reference_acyclicity(
492
+ node_ids: set[str],
493
+ reference_edges: dict[str, list[str]],
494
+ ) -> None:
434
495
  reference_visited: set[str] = set()
435
496
  reference_visiting: set[str] = set()
436
497
 
@@ -448,6 +509,8 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
448
509
  for node_id in sorted(node_ids):
449
510
  visit_reference(node_id)
450
511
 
512
+
513
+ def _validate_metadata_sections(manifest: dict[str, Any]) -> list[Any]:
451
514
  unsupported = _list(manifest["unsupported"], "unsupported")
452
515
  for index, item in enumerate(unsupported):
453
516
  record = _object(item, f"unsupported[{index}]")
@@ -471,7 +534,10 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
471
534
  if fidelity["ceiling"] not in FIDELITY_LEVELS:
472
535
  raise AgentContractError("invalid_syntax", "fidelity ceiling is invalid", target="fidelity.ceiling")
473
536
  fidelity["reasons"] = [str(item) for item in _list(fidelity["reasons"], "fidelity.reasons")]
537
+ return unsupported
474
538
 
539
+
540
+ def _verify_manifest_integrity(manifest: dict[str, Any], verify_hash: bool) -> None:
475
541
  _validate_public_json(manifest, name="blueprint")
476
542
  declared_hash = manifest["blueprintHash"]
477
543
  if not SHA256_PATTERN.fullmatch(str(declared_hash)):
@@ -479,6 +545,36 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
479
545
  if verify_hash and declared_hash != blueprint_hash(manifest):
480
546
  raise AgentContractError("verification_failed", "blueprintHash mismatch", target="blueprintHash")
481
547
  canonical_manifest_bytes(manifest, include_hash=True)
548
+
549
+
550
+ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool = True) -> dict[str, Any]:
551
+ """Validate and detach one strict ``hwpx.agent-blueprint/v1`` manifest."""
552
+
553
+ manifest = _object(value, "blueprint")
554
+ _validate_manifest_header(manifest)
555
+ _validate_source(manifest)
556
+ root = _validate_root(manifest)
557
+
558
+ detached_nodes, node_ids, nodes_by_id = _validate_nodes(manifest)
559
+ root_id = _check_node_tree_shape(root, detached_nodes, node_ids, nodes_by_id)
560
+ _check_node_reachability(root_id, node_ids, nodes_by_id)
561
+
562
+ detached_styles, detached_numbering, detached_resources = _validate_dependency_records(manifest)
563
+ dependency_keys = _check_dependency_uniqueness(
564
+ detached_styles, detached_numbering, detached_resources
565
+ )
566
+ _check_node_dependency_refs(
567
+ detached_nodes, detached_styles, detached_numbering, detached_resources
568
+ )
569
+
570
+ reference_records, reference_edges = _validate_reference_records(
571
+ manifest, node_ids, dependency_keys
572
+ )
573
+ _check_reference_inventory(detached_nodes, reference_records)
574
+ _check_reference_acyclicity(node_ids, reference_edges)
575
+
576
+ unsupported = _validate_metadata_sections(manifest)
577
+ _verify_manifest_integrity(manifest, verify_hash)
482
578
  return {
483
579
  **manifest,
484
580
  "nodes": detached_nodes,
@@ -8,13 +8,15 @@ import xml.etree.ElementTree as ET
8
8
  from dataclasses import dataclass
9
9
  from pathlib import Path, PurePosixPath
10
10
  from typing import Any, BinaryIO, Literal, Sequence
11
- from zipfile import ZIP_STORED, BadZipFile, ZipFile
11
+ from zipfile import ZIP_STORED, BadZipFile, ZipFile, ZipInfo
12
12
 
13
13
  from lxml import etree as LET # type: ignore[reportMissingImports]
14
14
 
15
15
  from ..oxml.namespaces import HWPML_COMPAT_ROOT_NAMESPACES
16
16
  from ..opc.relationships import (
17
17
  MAIN_ROOTFILE_MEDIA_TYPE,
18
+ ManifestRelationships,
19
+ RootFileRef,
18
20
  is_header_part_name,
19
21
  is_section_part_name,
20
22
  parse_container_rootfiles,
@@ -562,6 +564,312 @@ def _fallback_named_parts(
562
564
  return matches
563
565
 
564
566
 
567
+ def _check_mimetype(
568
+ zf: ZipFile,
569
+ infos: list[ZipInfo],
570
+ name_set: set[str],
571
+ issues: list[PackageValidationIssue],
572
+ ) -> None:
573
+ if MIMETYPE_PATH not in name_set:
574
+ _error(issues, MIMETYPE_PATH, "missing required file")
575
+ return
576
+ mimetype_bytes = _safe_read(zf, MIMETYPE_PATH)
577
+ if mimetype_bytes is None:
578
+ _error(
579
+ issues,
580
+ MIMETYPE_PATH,
581
+ "unable to read entry for integrity validation",
582
+ )
583
+ return
584
+ try:
585
+ mimetype = mimetype_bytes.decode("utf-8").strip()
586
+ except UnicodeDecodeError:
587
+ mimetype = "<binary>"
588
+ if mimetype != EXPECTED_MIMETYPE:
589
+ _error(
590
+ issues,
591
+ MIMETYPE_PATH,
592
+ f"expected {EXPECTED_MIMETYPE!r}, got {mimetype!r}",
593
+ )
594
+ if infos[0].filename != MIMETYPE_PATH:
595
+ _error(issues, MIMETYPE_PATH, "must be the first ZIP entry")
596
+ if zf.getinfo(MIMETYPE_PATH).compress_type != ZIP_STORED:
597
+ _error(issues, MIMETYPE_PATH, "must use ZIP_STORED")
598
+
599
+
600
+ def _check_required_presence(
601
+ name_set: set[str], issues: list[PackageValidationIssue]
602
+ ) -> None:
603
+ if CONTAINER_PATH not in name_set:
604
+ _error(issues, CONTAINER_PATH, "missing required file")
605
+ if VERSION_PATH not in name_set:
606
+ _warning(
607
+ issues,
608
+ VERSION_PATH,
609
+ "missing optional version.xml; minimum editor-open package set allows omission",
610
+ )
611
+
612
+
613
+ def _check_preview_text(
614
+ zf: ZipFile, name_set: set[str], issues: list[PackageValidationIssue]
615
+ ) -> None:
616
+ if PREVIEW_TEXT_PATH not in name_set:
617
+ _warning(
618
+ issues,
619
+ PREVIEW_TEXT_PATH,
620
+ "missing Preview/PrvText.txt; macOS Hancom compatibility may require it",
621
+ )
622
+ return
623
+ preview_bytes = _safe_read(zf, PREVIEW_TEXT_PATH)
624
+ if preview_bytes is None:
625
+ _error(issues, PREVIEW_TEXT_PATH, "unable to read preview text entry")
626
+ elif len(preview_bytes) > 1024 * 1024:
627
+ _warning(
628
+ issues,
629
+ PREVIEW_TEXT_PATH,
630
+ "Preview/PrvText.txt is unusually large; expected a compact text snapshot",
631
+ )
632
+
633
+
634
+ def _parse_all_xml(
635
+ zf: ZipFile, names: list[str], issues: list[PackageValidationIssue]
636
+ ) -> dict[str, ET.Element]:
637
+ xml_roots: dict[str, ET.Element] = {}
638
+ for name in names:
639
+ if not (name.endswith(".xml") or name.endswith(".hpf")):
640
+ continue
641
+ payload = _safe_read(zf, name)
642
+ if payload is None:
643
+ _error(issues, name, "unable to read entry for XML parsing")
644
+ continue
645
+ try:
646
+ root = _parse_xml(payload)
647
+ xml_roots[name] = root
648
+ _check_hwpml_compat_root(issues, name, payload, root)
649
+ _check_line_seg_text_positions(issues, name, root)
650
+ _check_table_editor_acceptance(issues, name, root)
651
+ _check_section_properties_location(issues, name, root)
652
+ _check_header_editor_acceptance(issues, name, root)
653
+ _check_bold_fontref_axis(issues, name, root)
654
+ except ValueError as exc:
655
+ _error(issues, name, str(exc))
656
+ return xml_roots
657
+
658
+
659
+ def _check_rootfile_parts(
660
+ rootfiles: tuple[RootFileRef, ...],
661
+ name_set: set[str],
662
+ issues: list[PackageValidationIssue],
663
+ ) -> None:
664
+ for rootfile in rootfiles:
665
+ if rootfile.full_path not in name_set:
666
+ _error(
667
+ issues,
668
+ CONTAINER_PATH,
669
+ f"rootfile points to missing part {rootfile.full_path!r}",
670
+ )
671
+
672
+
673
+ def _check_manifest_hrefs(
674
+ relationships: ManifestRelationships,
675
+ selected_rootfile: RootFileRef,
676
+ name_set: set[str],
677
+ issues: list[PackageValidationIssue],
678
+ ) -> None:
679
+ for item in relationships.items:
680
+ if item.resolved_path not in name_set:
681
+ _error(
682
+ issues,
683
+ selected_rootfile.full_path,
684
+ f"manifest href missing from archive: {item.href!r} -> {item.resolved_path!r}",
685
+ )
686
+
687
+ for idref in relationships.dangling_idrefs:
688
+ _warning(
689
+ issues,
690
+ selected_rootfile.full_path,
691
+ f"spine itemref references missing manifest id {idref!r}",
692
+ )
693
+
694
+
695
+ def _resolve_section_paths(
696
+ relationships: ManifestRelationships,
697
+ selected_rootfile: RootFileRef,
698
+ name_set: set[str],
699
+ issues: list[PackageValidationIssue],
700
+ ) -> list[str]:
701
+ section_paths = [
702
+ path for path in relationships.spine_paths if is_section_part_name(path)
703
+ ]
704
+ resolved_section_paths: list[str]
705
+ if section_paths:
706
+ resolved_section_paths = list(dict.fromkeys(section_paths))
707
+ for path in section_paths:
708
+ if path not in name_set:
709
+ _error(
710
+ issues,
711
+ selected_rootfile.full_path,
712
+ f"spine section part missing from archive: {path!r}",
713
+ )
714
+ else:
715
+ fallback_sections = [
716
+ name for name in sorted(name_set) if is_section_part_name(name)
717
+ ]
718
+ if fallback_sections:
719
+ resolved_section_paths = fallback_sections
720
+ _warning(
721
+ issues,
722
+ selected_rootfile.full_path,
723
+ "manifest spine does not resolve any section parts; engine will fall back "
724
+ "to filename-based section discovery",
725
+ )
726
+ else:
727
+ resolved_section_paths = []
728
+ _error(
729
+ issues,
730
+ selected_rootfile.full_path,
731
+ "no section parts found in manifest spine or archive fallback",
732
+ )
733
+ return resolved_section_paths
734
+
735
+
736
+ def _check_header_section_counts(
737
+ relationships: ManifestRelationships,
738
+ xml_roots: dict[str, ET.Element],
739
+ resolved_section_paths: list[str],
740
+ name_set: set[str],
741
+ issues: list[PackageValidationIssue],
742
+ ) -> None:
743
+ resolved_header_paths = list(dict.fromkeys(relationships.header_paths))
744
+ if not resolved_header_paths and HEADER_PATH in name_set:
745
+ resolved_header_paths = [HEADER_PATH]
746
+ for header_path in resolved_header_paths:
747
+ header_root = xml_roots.get(header_path)
748
+ if header_root is None or _local_name(header_root) != "head":
749
+ continue
750
+ declared_section_count = header_root.get("secCnt")
751
+ if declared_section_count is None:
752
+ _warning(
753
+ issues,
754
+ header_path,
755
+ "hh:head secCnt is missing; resolved section count cannot be cross-checked",
756
+ )
757
+ continue
758
+ try:
759
+ parsed_section_count = int(declared_section_count)
760
+ except ValueError:
761
+ _error(
762
+ issues,
763
+ header_path,
764
+ f"hh:head secCnt must be an integer, got {declared_section_count!r}",
765
+ )
766
+ continue
767
+ if parsed_section_count != len(resolved_section_paths):
768
+ _error(
769
+ issues,
770
+ header_path,
771
+ "hh:head secCnt does not match resolved section count: "
772
+ f"declared={parsed_section_count}, resolved={len(resolved_section_paths)}",
773
+ )
774
+
775
+
776
+ def _check_header_fallback(
777
+ relationships: ManifestRelationships,
778
+ name_set: set[str],
779
+ selected_rootfile: RootFileRef,
780
+ issues: list[PackageValidationIssue],
781
+ ) -> None:
782
+ if not relationships.header_paths and HEADER_PATH in name_set:
783
+ _warning(
784
+ issues,
785
+ selected_rootfile.full_path,
786
+ "manifest spine does not resolve a header part; engine will fall back to "
787
+ f"{HEADER_PATH!r}",
788
+ )
789
+
790
+ for path in relationships.header_paths:
791
+ if path not in name_set:
792
+ _error(
793
+ issues,
794
+ selected_rootfile.full_path,
795
+ f"header part missing from archive: {path!r}",
796
+ )
797
+
798
+
799
+ def _check_master_page_parts(
800
+ relationships: ManifestRelationships,
801
+ name_set: set[str],
802
+ selected_rootfile: RootFileRef,
803
+ issues: list[PackageValidationIssue],
804
+ ) -> None:
805
+ if not relationships.master_page_paths:
806
+ fallback_master_pages = _fallback_named_parts(
807
+ name_set, token="master", extra_token="page"
808
+ )
809
+ if fallback_master_pages:
810
+ _warning(
811
+ issues,
812
+ selected_rootfile.full_path,
813
+ "manifest does not reference masterPage parts; engine will fall back to "
814
+ "filename-based discovery",
815
+ )
816
+ for path in relationships.master_page_paths:
817
+ if path not in name_set:
818
+ _error(
819
+ issues,
820
+ selected_rootfile.full_path,
821
+ f"masterPage part missing from archive: {path!r}",
822
+ )
823
+
824
+
825
+ def _check_history_parts(
826
+ relationships: ManifestRelationships,
827
+ name_set: set[str],
828
+ selected_rootfile: RootFileRef,
829
+ issues: list[PackageValidationIssue],
830
+ ) -> None:
831
+ if not relationships.history_paths:
832
+ fallback_histories = _fallback_named_parts(name_set, token="history")
833
+ if fallback_histories:
834
+ _warning(
835
+ issues,
836
+ selected_rootfile.full_path,
837
+ "manifest does not reference history parts; engine will fall back to "
838
+ "filename-based discovery",
839
+ )
840
+ for path in relationships.history_paths:
841
+ if path not in name_set:
842
+ _error(
843
+ issues,
844
+ selected_rootfile.full_path,
845
+ f"history part missing from archive: {path!r}",
846
+ )
847
+
848
+
849
+ def _check_version_part(
850
+ relationships: ManifestRelationships,
851
+ name_set: set[str],
852
+ selected_rootfile: RootFileRef,
853
+ issues: list[PackageValidationIssue],
854
+ ) -> None:
855
+ if relationships.version_path is None and VERSION_PATH in name_set:
856
+ _warning(
857
+ issues,
858
+ selected_rootfile.full_path,
859
+ "manifest does not reference a version part; engine will fall back to "
860
+ f"{VERSION_PATH!r}",
861
+ )
862
+ elif (
863
+ relationships.version_path is not None
864
+ and relationships.version_path not in name_set
865
+ ):
866
+ _error(
867
+ issues,
868
+ selected_rootfile.full_path,
869
+ f"manifest version part missing from archive: {relationships.version_path!r}",
870
+ )
871
+
872
+
565
873
  def validate_package(source: str | Path | bytes | BinaryIO) -> PackageValidationReport:
566
874
  checked_parts: list[str] = []
567
875
  issues: list[PackageValidationIssue] = []
@@ -593,77 +901,11 @@ def validate_package(source: str | Path | bytes | BinaryIO) -> PackageValidation
593
901
  if bad_entry is not None:
594
902
  _error(issues, bad_entry, "ZIP CRC/integrity check failed")
595
903
 
596
- if MIMETYPE_PATH not in name_set:
597
- _error(issues, MIMETYPE_PATH, "missing required file")
598
- else:
599
- mimetype_bytes = _safe_read(zf, MIMETYPE_PATH)
600
- if mimetype_bytes is None:
601
- _error(
602
- issues,
603
- MIMETYPE_PATH,
604
- "unable to read entry for integrity validation",
605
- )
606
- else:
607
- try:
608
- mimetype = mimetype_bytes.decode("utf-8").strip()
609
- except UnicodeDecodeError:
610
- mimetype = "<binary>"
611
- if mimetype != EXPECTED_MIMETYPE:
612
- _error(
613
- issues,
614
- MIMETYPE_PATH,
615
- f"expected {EXPECTED_MIMETYPE!r}, got {mimetype!r}",
616
- )
617
- if infos[0].filename != MIMETYPE_PATH:
618
- _error(issues, MIMETYPE_PATH, "must be the first ZIP entry")
619
- if zf.getinfo(MIMETYPE_PATH).compress_type != ZIP_STORED:
620
- _error(issues, MIMETYPE_PATH, "must use ZIP_STORED")
621
-
622
- if CONTAINER_PATH not in name_set:
623
- _error(issues, CONTAINER_PATH, "missing required file")
624
- if VERSION_PATH not in name_set:
625
- _warning(
626
- issues,
627
- VERSION_PATH,
628
- "missing optional version.xml; minimum editor-open package set allows omission",
629
- )
904
+ _check_mimetype(zf, infos, name_set, issues)
905
+ _check_required_presence(name_set, issues)
906
+ _check_preview_text(zf, name_set, issues)
630
907
 
631
- if PREVIEW_TEXT_PATH not in name_set:
632
- _warning(
633
- issues,
634
- PREVIEW_TEXT_PATH,
635
- "missing Preview/PrvText.txt; macOS Hancom compatibility may require it",
636
- )
637
- else:
638
- preview_bytes = _safe_read(zf, PREVIEW_TEXT_PATH)
639
- if preview_bytes is None:
640
- _error(issues, PREVIEW_TEXT_PATH, "unable to read preview text entry")
641
- elif len(preview_bytes) > 1024 * 1024:
642
- _warning(
643
- issues,
644
- PREVIEW_TEXT_PATH,
645
- "Preview/PrvText.txt is unusually large; expected a compact text snapshot",
646
- )
647
-
648
- xml_roots: dict[str, ET.Element] = {}
649
- for name in names:
650
- if not (name.endswith(".xml") or name.endswith(".hpf")):
651
- continue
652
- payload = _safe_read(zf, name)
653
- if payload is None:
654
- _error(issues, name, "unable to read entry for XML parsing")
655
- continue
656
- try:
657
- root = _parse_xml(payload)
658
- xml_roots[name] = root
659
- _check_hwpml_compat_root(issues, name, payload, root)
660
- _check_line_seg_text_positions(issues, name, root)
661
- _check_table_editor_acceptance(issues, name, root)
662
- _check_section_properties_location(issues, name, root)
663
- _check_header_editor_acceptance(issues, name, root)
664
- _check_bold_fontref_axis(issues, name, root)
665
- except ValueError as exc:
666
- _error(issues, name, str(exc))
908
+ xml_roots = _parse_all_xml(zf, names, issues)
667
909
 
668
910
  container_root = xml_roots.get(CONTAINER_PATH)
669
911
  if container_root is None:
@@ -674,13 +916,7 @@ def validate_package(source: str | Path | bytes | BinaryIO) -> PackageValidation
674
916
  _error(issues, CONTAINER_PATH, "declares no rootfile entries")
675
917
  return PackageValidationReport(tuple(checked_parts), tuple(issues))
676
918
 
677
- for rootfile in rootfiles:
678
- if rootfile.full_path not in name_set:
679
- _error(
680
- issues,
681
- CONTAINER_PATH,
682
- f"rootfile points to missing part {rootfile.full_path!r}",
683
- )
919
+ _check_rootfile_parts(rootfiles, name_set, issues)
684
920
 
685
921
  selected_rootfile, used_rootfile_fallback = select_main_rootfile(rootfiles)
686
922
  if selected_rootfile is None:
@@ -709,154 +945,17 @@ def validate_package(source: str | Path | bytes | BinaryIO) -> PackageValidation
709
945
  known_parts=name_set,
710
946
  )
711
947
 
712
- for item in relationships.items:
713
- if item.resolved_path not in name_set:
714
- _error(
715
- issues,
716
- selected_rootfile.full_path,
717
- f"manifest href missing from archive: {item.href!r} -> {item.resolved_path!r}",
718
- )
719
-
720
- for idref in relationships.dangling_idrefs:
721
- _warning(
722
- issues,
723
- selected_rootfile.full_path,
724
- f"spine itemref references missing manifest id {idref!r}",
725
- )
726
-
727
- section_paths = [
728
- path for path in relationships.spine_paths if is_section_part_name(path)
729
- ]
730
- resolved_section_paths: list[str]
731
- if section_paths:
732
- resolved_section_paths = list(dict.fromkeys(section_paths))
733
- for path in section_paths:
734
- if path not in name_set:
735
- _error(
736
- issues,
737
- selected_rootfile.full_path,
738
- f"spine section part missing from archive: {path!r}",
739
- )
740
- else:
741
- fallback_sections = [
742
- name for name in sorted(name_set) if is_section_part_name(name)
743
- ]
744
- if fallback_sections:
745
- resolved_section_paths = fallback_sections
746
- _warning(
747
- issues,
748
- selected_rootfile.full_path,
749
- "manifest spine does not resolve any section parts; engine will fall back "
750
- "to filename-based section discovery",
751
- )
752
- else:
753
- resolved_section_paths = []
754
- _error(
755
- issues,
756
- selected_rootfile.full_path,
757
- "no section parts found in manifest spine or archive fallback",
758
- )
759
-
760
- resolved_header_paths = list(dict.fromkeys(relationships.header_paths))
761
- if not resolved_header_paths and HEADER_PATH in name_set:
762
- resolved_header_paths = [HEADER_PATH]
763
- for header_path in resolved_header_paths:
764
- header_root = xml_roots.get(header_path)
765
- if header_root is None or _local_name(header_root) != "head":
766
- continue
767
- declared_section_count = header_root.get("secCnt")
768
- if declared_section_count is None:
769
- _warning(
770
- issues,
771
- header_path,
772
- "hh:head secCnt is missing; resolved section count cannot be cross-checked",
773
- )
774
- continue
775
- try:
776
- parsed_section_count = int(declared_section_count)
777
- except ValueError:
778
- _error(
779
- issues,
780
- header_path,
781
- f"hh:head secCnt must be an integer, got {declared_section_count!r}",
782
- )
783
- continue
784
- if parsed_section_count != len(resolved_section_paths):
785
- _error(
786
- issues,
787
- header_path,
788
- "hh:head secCnt does not match resolved section count: "
789
- f"declared={parsed_section_count}, resolved={len(resolved_section_paths)}",
790
- )
791
-
792
- if not relationships.header_paths and HEADER_PATH in name_set:
793
- _warning(
794
- issues,
795
- selected_rootfile.full_path,
796
- "manifest spine does not resolve a header part; engine will fall back to "
797
- f"{HEADER_PATH!r}",
798
- )
799
-
800
- for path in relationships.header_paths:
801
- if path not in name_set:
802
- _error(
803
- issues,
804
- selected_rootfile.full_path,
805
- f"header part missing from archive: {path!r}",
806
- )
807
-
808
- if not relationships.master_page_paths:
809
- fallback_master_pages = _fallback_named_parts(
810
- name_set, token="master", extra_token="page"
811
- )
812
- if fallback_master_pages:
813
- _warning(
814
- issues,
815
- selected_rootfile.full_path,
816
- "manifest does not reference masterPage parts; engine will fall back to "
817
- "filename-based discovery",
818
- )
819
- for path in relationships.master_page_paths:
820
- if path not in name_set:
821
- _error(
822
- issues,
823
- selected_rootfile.full_path,
824
- f"masterPage part missing from archive: {path!r}",
825
- )
826
-
827
- if not relationships.history_paths:
828
- fallback_histories = _fallback_named_parts(name_set, token="history")
829
- if fallback_histories:
830
- _warning(
831
- issues,
832
- selected_rootfile.full_path,
833
- "manifest does not reference history parts; engine will fall back to "
834
- "filename-based discovery",
835
- )
836
- for path in relationships.history_paths:
837
- if path not in name_set:
838
- _error(
839
- issues,
840
- selected_rootfile.full_path,
841
- f"history part missing from archive: {path!r}",
842
- )
843
-
844
- if relationships.version_path is None and VERSION_PATH in name_set:
845
- _warning(
846
- issues,
847
- selected_rootfile.full_path,
848
- "manifest does not reference a version part; engine will fall back to "
849
- f"{VERSION_PATH!r}",
850
- )
851
- elif (
852
- relationships.version_path is not None
853
- and relationships.version_path not in name_set
854
- ):
855
- _error(
856
- issues,
857
- selected_rootfile.full_path,
858
- f"manifest version part missing from archive: {relationships.version_path!r}",
859
- )
948
+ _check_manifest_hrefs(relationships, selected_rootfile, name_set, issues)
949
+ resolved_section_paths = _resolve_section_paths(
950
+ relationships, selected_rootfile, name_set, issues
951
+ )
952
+ _check_header_section_counts(
953
+ relationships, xml_roots, resolved_section_paths, name_set, issues
954
+ )
955
+ _check_header_fallback(relationships, name_set, selected_rootfile, issues)
956
+ _check_master_page_parts(relationships, name_set, selected_rootfile, issues)
957
+ _check_history_parts(relationships, name_set, selected_rootfile, issues)
958
+ _check_version_part(relationships, name_set, selected_rootfile, issues)
860
959
 
861
960
  return PackageValidationReport(tuple(checked_parts), tuple(issues))
862
961
 
hwpx/visual/oracle.py CHANGED
@@ -39,6 +39,7 @@ import shutil
39
39
  import subprocess
40
40
  import sys
41
41
  import tempfile
42
+ import time
42
43
  from importlib import resources
43
44
  from pathlib import Path
44
45
 
@@ -64,6 +65,71 @@ _MAC_APP_CANDIDATES = (
64
65
  "/Applications/Hancom Office HWP 2024.app",
65
66
  "/Applications/Hancom Office HWP 2022.app",
66
67
  )
68
+ _STRUCTURAL_ONLY_ENV = "HWPX_ORACLE_STRUCTURAL_ONLY"
69
+ _TRUTHY_ENV_VALUES = frozenset({"1", "true", "yes", "on"})
70
+ # The reachability probe must answer well under the customer E2E budget; a
71
+ # TCC-blocked osascript otherwise hangs until the full render timeout.
72
+ _MAC_PROBE_TIMEOUT = 5.0
73
+ _MAC_PROBE_SCRIPT = 'tell application "System Events" to count processes'
74
+ # Process-lifetime probe verdict per osascript binary: the TCC grant cannot
75
+ # change for an already-running process, so one probe per process is honest.
76
+ _MAC_PROBE_CACHE: dict[str, bool] = {}
77
+
78
+
79
+ _BUDGET_ENV = "HWPX_ORACLE_BUDGET_SECONDS"
80
+
81
+
82
+ def structural_only() -> bool:
83
+ """True when ``HWPX_ORACLE_STRUCTURAL_ONLY`` requests no-oracle operation.
84
+
85
+ In this mode every verification degrades to the labelled structural path:
86
+ ``resolve_oracle`` returns :class:`NullOracle` and the Mac GUI backend
87
+ reports ``available() == False`` even when Hancom is installed.
88
+ """
89
+
90
+ return os.environ.get(_STRUCTURAL_ONLY_ENV, "").strip().lower() in _TRUTHY_ENV_VALUES
91
+
92
+
93
+ def env_budget_seconds() -> float | None:
94
+ """The externally-declared oracle budget, or ``None`` when unset/invalid.
95
+
96
+ ``HWPX_ORACLE_BUDGET_SECONDS`` is the single deadline a hosting process
97
+ (customer E2E, installed verify) declares once; ``resolve_oracle`` threads
98
+ it into every backend subprocess timeout. Non-numeric values are ignored;
99
+ zero or negative means the budget is already exhausted.
100
+ """
101
+
102
+ raw = os.environ.get(_BUDGET_ENV, "").strip()
103
+ if not raw:
104
+ return None
105
+ try:
106
+ value = float(raw)
107
+ except ValueError:
108
+ return None
109
+ return max(0.0, value)
110
+
111
+
112
+ def _deadline_from(budget_seconds: float | None) -> float | None:
113
+ """Turn an optional budget into an absolute monotonic deadline."""
114
+
115
+ if budget_seconds is None:
116
+ return None
117
+ return time.monotonic() + max(0.0, budget_seconds)
118
+
119
+
120
+ def _clamped_timeout(base: float, deadline: float | None) -> float | None:
121
+ """Clamp ``base`` to the remaining deadline budget.
122
+
123
+ ``None`` means the budget is already exhausted: the caller must degrade
124
+ without spawning the subprocess at all.
125
+ """
126
+
127
+ if deadline is None:
128
+ return base
129
+ remaining = deadline - time.monotonic()
130
+ if remaining <= 0:
131
+ return None
132
+ return min(base, remaining)
67
133
 
68
134
 
69
135
  class RenderBackend:
@@ -108,10 +174,14 @@ class WindowsComOracle(RenderBackend):
108
174
  powershell: str | None = None,
109
175
  timeout: float = 300.0,
110
176
  dpi: int = 150,
177
+ budget_seconds: float | None = None,
111
178
  ) -> None:
112
179
  self._powershell = powershell or "powershell"
113
180
  self.timeout = timeout
114
181
  self.dpi = dpi
182
+ # Single externally-propagated deadline: every subprocess timeout in
183
+ # one public call is clamped so the whole call fits this budget.
184
+ self.budget_seconds = budget_seconds
115
185
 
116
186
  def available(self) -> bool:
117
187
  """True only on Windows with the ``HWPFrame.HwpObject`` COM class registered."""
@@ -142,6 +212,10 @@ class WindowsComOracle(RenderBackend):
142
212
  result: dict[str, str | None] = {src: None for src, _ in pairs}
143
213
  if not pairs or not self.available():
144
214
  return result
215
+ deadline = _deadline_from(self.budget_seconds)
216
+ run_timeout = _clamped_timeout(self.timeout + 60.0 * len(pairs), deadline)
217
+ if run_timeout is None:
218
+ return result
145
219
 
146
220
  tmp = tempfile.mkdtemp(prefix="hwpx-render-")
147
221
  try:
@@ -159,7 +233,7 @@ class WindowsComOracle(RenderBackend):
159
233
  ]
160
234
  try:
161
235
  subprocess.run(
162
- cmd, capture_output=True, timeout=self.timeout + 60.0 * len(pairs),
236
+ cmd, capture_output=True, timeout=run_timeout,
163
237
  check=False,
164
238
  )
165
239
  except (subprocess.TimeoutExpired, OSError):
@@ -230,6 +304,10 @@ class WindowsComOracle(RenderBackend):
230
304
  return []
231
305
  if not self.available():
232
306
  return [self._unverified_entry(p) for p in paths]
307
+ deadline = _deadline_from(self.budget_seconds)
308
+ run_timeout = _clamped_timeout(self.timeout + 60.0 * len(paths), deadline)
309
+ if run_timeout is None:
310
+ return [self._unverified_entry(p) for p in paths]
233
311
 
234
312
  abs_paths = [os.path.abspath(p) for p in paths]
235
313
  # path -> requested (original) string, for surfacing the caller's path.
@@ -251,7 +329,7 @@ class WindowsComOracle(RenderBackend):
251
329
  try:
252
330
  subprocess.run(
253
331
  cmd, capture_output=True,
254
- timeout=self.timeout + 60.0 * len(paths), check=False,
332
+ timeout=run_timeout, check=False,
255
333
  )
256
334
  except (subprocess.TimeoutExpired, OSError):
257
335
  # Subprocess never finished: prefer the crash-safe checkpoint
@@ -285,11 +363,13 @@ class WindowsComOracle(RenderBackend):
285
363
 
286
364
  opened_raw = record.get("opened")
287
365
  opened = bool(opened_raw) if opened_raw is not None else None
288
- text_length = record.get("textLength")
289
- try:
290
- text_length = int(text_length) if text_length is not None else None
291
- except (TypeError, ValueError):
292
- text_length = None
366
+ text_length: int | None = None
367
+ text_length_raw = record.get("textLength")
368
+ if isinstance(text_length_raw, (bool, int, float, str)):
369
+ try:
370
+ text_length = int(text_length_raw)
371
+ except (TypeError, ValueError):
372
+ text_length = None
293
373
  error = record.get("error")
294
374
  parsed: bool | None
295
375
  if opened is None:
@@ -345,13 +425,13 @@ class WindowsComOracle(RenderBackend):
345
425
  by_path[os.path.abspath(src)] = norm
346
426
  out: list[dict[str, object]] = []
347
427
  for abs_path in abs_paths:
348
- norm = by_path.get(abs_path)
349
- if norm is None:
428
+ found = by_path.get(abs_path)
429
+ if found is None:
350
430
  out.append(self._unverified_entry(requested.get(abs_path, abs_path)))
351
431
  else:
352
432
  # Surface the caller's original path string.
353
- norm["path"] = requested.get(abs_path, norm.get("path"))
354
- out.append(norm)
433
+ found["path"] = requested.get(abs_path, found.get("path"))
434
+ out.append(found)
355
435
  return out
356
436
 
357
437
  def _merge_checkpoint(
@@ -409,10 +489,14 @@ class MacHancomOracle(RenderBackend):
409
489
  timeout: float = 300.0,
410
490
  dpi: int = 150,
411
491
  osascript: str = "osascript",
492
+ budget_seconds: float | None = None,
412
493
  ) -> None:
413
494
  self.timeout = timeout
414
495
  self.dpi = dpi
415
496
  self._osascript = osascript
497
+ # Single externally-propagated deadline: every subprocess timeout in
498
+ # one public call is clamped so the whole call fits this budget.
499
+ self.budget_seconds = budget_seconds
416
500
 
417
501
  def _app_path(self) -> str | None:
418
502
  for candidate in _MAC_APP_CANDIDATES:
@@ -433,12 +517,46 @@ class MacHancomOracle(RenderBackend):
433
517
  return line
434
518
  return None
435
519
 
520
+ def _automation_reachable(self) -> bool:
521
+ """Fast preflight for GUI automation (System Events + Automation TCC).
522
+
523
+ An installed Hancom does NOT imply the GUI transport works: without a
524
+ logged-in GUI session or with Automation/Apple Events denied, osascript
525
+ blocks until its own timeout — far beyond any caller budget. This probe
526
+ answers within :data:`_MAC_PROBE_TIMEOUT` seconds and is cached for the
527
+ process lifetime (TCC grants cannot change for a running process).
528
+ """
529
+
530
+ cached = _MAC_PROBE_CACHE.get(self._osascript)
531
+ if cached is not None:
532
+ return cached
533
+ reachable = False
534
+ try:
535
+ proc = subprocess.run(
536
+ [self._osascript, "-e", _MAC_PROBE_SCRIPT],
537
+ capture_output=True, text=True,
538
+ timeout=_MAC_PROBE_TIMEOUT, check=False,
539
+ )
540
+ reachable = proc.returncode == 0
541
+ except (OSError, subprocess.TimeoutExpired):
542
+ reachable = False
543
+ _MAC_PROBE_CACHE[self._osascript] = reachable
544
+ return reachable
545
+
436
546
  def available(self) -> bool:
437
- """True only on macOS with ``Hancom Office HWP.app`` installed."""
547
+ """True only on macOS with Hancom installed AND GUI automation reachable.
548
+
549
+ ``HWPX_ORACLE_STRUCTURAL_ONLY`` forces ``False`` so no caller can enter
550
+ GUI automation in structural-only operation.
551
+ """
438
552
 
553
+ if structural_only():
554
+ return False
439
555
  if sys.platform != "darwin":
440
556
  return False
441
- return self._app_path() is not None
557
+ if self._app_path() is None:
558
+ return False
559
+ return self._automation_reachable()
442
560
 
443
561
  def render_pdf(self, hwpx_path: str, out_pdf: str | None = None) -> str | None:
444
562
  """Render a single ``.hwpx`` to PDF via the GUI; returns the path or ``None``."""
@@ -466,6 +584,14 @@ class MacHancomOracle(RenderBackend):
466
584
  except OSError:
467
585
  pass
468
586
 
587
+ deadline = _deadline_from(self.budget_seconds)
588
+ run_timeout = _clamped_timeout(self.timeout + 60.0, deadline)
589
+ if run_timeout is None:
590
+ return None
591
+ # The AppleScript receives its own internal wait limit; keep it inside
592
+ # the clamped subprocess timeout so the script never outlives the budget.
593
+ script_timeout = max(1, int(min(self.timeout, run_timeout)))
594
+
469
595
  cleanup_staged = False
470
596
  try:
471
597
  if not staged_is_source:
@@ -475,12 +601,12 @@ class MacHancomOracle(RenderBackend):
475
601
  resources.files("hwpx.visual").joinpath(_MAC_BACKEND_SCRIPT)
476
602
  ) as script:
477
603
  cmd = [
478
- self._osascript, str(script), staged, out_pdf, str(int(self.timeout)),
604
+ self._osascript, str(script), staged, out_pdf, str(script_timeout),
479
605
  ]
480
606
  try:
481
607
  subprocess.run(
482
608
  cmd, capture_output=True, text=True,
483
- timeout=self.timeout + 60.0, check=False,
609
+ timeout=run_timeout, check=False,
484
610
  )
485
611
  except (subprocess.TimeoutExpired, OSError):
486
612
  return None
@@ -507,6 +633,11 @@ class MacHancomOracle(RenderBackend):
507
633
  """
508
634
  if not self.available():
509
635
  return False
636
+ deadline = _deadline_from(self.budget_seconds)
637
+ run_timeout = _clamped_timeout(self.timeout + 60.0, deadline)
638
+ if run_timeout is None:
639
+ return False
640
+ script_timeout = max(1, int(min(self.timeout, run_timeout)))
510
641
  src = os.path.abspath(hwpx_path)
511
642
  try:
512
643
  before = os.stat(src).st_mtime_ns
@@ -515,11 +646,11 @@ class MacHancomOracle(RenderBackend):
515
646
  with resources.as_file(
516
647
  resources.files("hwpx.visual").joinpath(_MAC_REFRESH_SCRIPT)
517
648
  ) as script:
518
- cmd = [self._osascript, str(script), src, str(int(self.timeout))]
649
+ cmd = [self._osascript, str(script), src, str(script_timeout)]
519
650
  try:
520
651
  proc = subprocess.run(
521
652
  cmd, capture_output=True, text=True,
522
- timeout=self.timeout + 60.0, check=False,
653
+ timeout=run_timeout, check=False,
523
654
  )
524
655
  except (subprocess.TimeoutExpired, OSError):
525
656
  return False
@@ -551,17 +682,29 @@ def resolve_oracle(
551
682
  timeout: float = 300.0,
552
683
  dpi: int = 150,
553
684
  osascript: str = "osascript",
685
+ budget_seconds: float | None = None,
554
686
  ) -> RenderBackend:
555
687
  """Return the best reachable render backend (Windows COM → Mac GUI → Null).
556
688
 
557
689
  Windows COM is canonical (CI/scale); Mac GUI is the dev/spot-check fallback;
558
690
  :class:`NullOracle` is the degrade sentinel when no Hancom is reachable.
691
+ ``HWPX_ORACLE_STRUCTURAL_ONLY`` short-circuits to :class:`NullOracle`, and
692
+ ``budget_seconds`` propagates one external deadline into every backend
693
+ subprocess timeout.
559
694
  """
560
695
 
561
- windows = WindowsComOracle(powershell=powershell, timeout=timeout, dpi=dpi)
696
+ if structural_only():
697
+ return NullOracle()
698
+ if budget_seconds is None:
699
+ budget_seconds = env_budget_seconds()
700
+ windows = WindowsComOracle(
701
+ powershell=powershell, timeout=timeout, dpi=dpi, budget_seconds=budget_seconds,
702
+ )
562
703
  if windows.available():
563
704
  return windows
564
- mac = MacHancomOracle(timeout=timeout, dpi=dpi, osascript=osascript)
705
+ mac = MacHancomOracle(
706
+ timeout=timeout, dpi=dpi, osascript=osascript, budget_seconds=budget_seconds,
707
+ )
565
708
  if mac.available():
566
709
  return mac
567
710
  return NullOracle()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hwpx
3
- Version: 3.2.0
3
+ Version: 3.3.1
4
4
  Summary: 한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 자동화 라이브러리
5
5
  Author: python-hwpx Maintainers
6
6
  License-Expression: Apache-2.0
@@ -70,7 +70,7 @@ Dynamic: license-file
70
70
  `hwpx-mcp-server`와 `hwpx-plugin`은 같은 프로젝트가 직접 유지보수하는 first-party 연동 구성요소입니다.
71
71
  “first-party”는 프로젝트 유지보수 관계를 뜻하며, 한컴 또는 제3자의 공식 인증을 뜻하지 않습니다.
72
72
 
73
- 현재 PyPI 공개 릴리스는 `python-hwpx 3.2.0`입니다. 일반
73
+ 현재 PyPI 공개 릴리스는 `python-hwpx 3.3.1`입니다. 일반
74
74
  `pip install python-hwpx`로 이 릴리스를 설치할 수 있습니다.
75
75
  현재 패키지 분류는 `Development Status :: 3 - Alpha`입니다. 이 분류는 API와 제품의
76
76
  성숙도를 나타내며, 공개 버전이나 플러그인의 최소 호환 버전을 대신하지 않습니다.
@@ -28,7 +28,7 @@ hwpx/agent/blueprint/bundle.py,sha256=K9-lcCgx2j1EsHzdP0cFn3ejn-YcLQ1NnNiPZNjq25
28
28
  hwpx/agent/blueprint/catalog.py,sha256=D2ax7gQxhoqqtL70LWjcUME6I9TMb2Q7MqR48c19_tc,4669
29
29
  hwpx/agent/blueprint/dump.py,sha256=HgEMr5dI5ozXwZn9tH-2IlFtWphxT_qz7hlZmSn49MU,21745
30
30
  hwpx/agent/blueprint/mapping.py,sha256=bzSYbTrNEumBkP74nEY5M20-BEC4rBGCRvo3QZNQKxc,12761
31
- hwpx/agent/blueprint/model.py,sha256=U1CykldrtKWRXntb6cJxJjs-mGOVFN4jroBbcaQNoTk,28096
31
+ hwpx/agent/blueprint/model.py,sha256=7CegZTyVxFtzeDXHQrAogwS5Hf-MEBSEzcarUl1gNbE,31204
32
32
  hwpx/agent/blueprint/native.py,sha256=O6ZyBmKMw-O-SoEzlWkIgA4E7zXog1Ba9-4Ug5AekpM,28761
33
33
  hwpx/agent/blueprint/replay.py,sha256=EzkjGsFCYkjADZVaX1V8g4LAlZIXOFn7cZJmmCNNpSY,20047
34
34
  hwpx/benchmark/__init__.py,sha256=7CfgxEQ6nUrKHy3-V9VkeaMoGrb12QA8mHzpcfF2ItE,623
@@ -147,7 +147,7 @@ hwpx/tools/markdown_export.py,sha256=FejutCpQHbycO185uljcSwfZuwXMTbGEgXtf5e-a4_k
147
147
  hwpx/tools/object_finder.py,sha256=7i6XI1-r7-ar_IzSZQ82hfOcxVzJFK2XjMDB8oxcmMA,13478
148
148
  hwpx/tools/official_lint.py,sha256=9iszI4CAAEMGdPCOhmQ6XonO1jc1XE2BJXMHgv9hIvY,18600
149
149
  hwpx/tools/package_reconcile.py,sha256=y1Hl7hbPh4YaV59LTdDLzQwgn4g1qEnFmSjmajnrEbA,2416
150
- hwpx/tools/package_validator.py,sha256=mkWFS7xgkizVQR7kQRXfwBF_i3zQk7qt8jmBTgTYZm4,31326
150
+ hwpx/tools/package_validator.py,sha256=fYkJevV_soQuM8LEUQMkwYaTy2k2HV4VWBwfIXFt29Y,33306
151
151
  hwpx/tools/page_guard.py,sha256=nDAVPcvrnuyDxVTA_j22wiYD7CXAD6XlzsMzaz3h_q8,9701
152
152
  hwpx/tools/pii.py,sha256=N3c36eqblaVQ7o6jiT1BV0WrJK_G3gKhQh6MQGAbvCs,12617
153
153
  hwpx/tools/read_fidelity.py,sha256=g4r2GNVExEtL0C-6JMkPIjmH6CtJqNSS_-KsSPTnBbw,10541
@@ -185,15 +185,15 @@ hwpx/visual/diff.py,sha256=0X5T9IgwRZU3td-7vnPrlowovtGud7P_ymq0KVehlKk,5677
185
185
  hwpx/visual/fixture_corpus.py,sha256=Bcy7nscf6RY7sHemGKaqzsyRrWp5ioQdP2wQFFoY20A,6676
186
186
  hwpx/visual/hancom_worker.py,sha256=r70lUnHSdIF4h3plIk_SIkoXpzuACJp1FBE1WMBt5Ic,10161
187
187
  hwpx/visual/masks.py,sha256=oXhgynAb4uKjJtZ2BGHHdAjyvWGqSFlZFQ-iJxzHiuo,1832
188
- hwpx/visual/oracle.py,sha256=VNO-tAB-lzTdW5XtSELxg_Aau369qRFBsJvNryHF_D8,31319
188
+ hwpx/visual/oracle.py,sha256=f3JqplfB5cE_Tx7HaCUQ4aBv7DhMpsp79a4i3BYHr1A,37069
189
189
  hwpx/visual/page_qa.py,sha256=AuJCySfLIdXjPeekhAc3YnrIWOsN0sI35Pnz8BZ6Pro,7278
190
190
  hwpx/visual/qa_contracts.py,sha256=1cjoiRTAWgi7NgE8SPc6bKxdoUfKx9nyou1mgC47TxY,10506
191
191
  hwpx/visual/qa_metrics.py,sha256=I7RdybN-f1Fvcv2j-ceDPoek51nOkMui1Zrd7TEX_C8,9838
192
192
  hwpx/visual/report.py,sha256=2RhXN1KBYOZTim9FNpeUhaaDHR7oFxI6Z2DLUkDIiwE,1717
193
- python_hwpx-3.2.0.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
194
- python_hwpx-3.2.0.dist-info/licenses/NOTICE,sha256=k48h6EaGQE8Y1c0dS9sIOOcz4YqkbcImWClF7pBOgsg,2473
195
- python_hwpx-3.2.0.dist-info/METADATA,sha256=8nLPZJKUFUDSQWMnOr1dDQo4cJ71TQ_IsRY5vZeXu9I,12566
196
- python_hwpx-3.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
197
- python_hwpx-3.2.0.dist-info/entry_points.txt,sha256=PVUBTBQtBHiflQ9XBjfd004w-LrDibgZjwzxgu8Tx7w,480
198
- python_hwpx-3.2.0.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
199
- python_hwpx-3.2.0.dist-info/RECORD,,
193
+ python_hwpx-3.3.1.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
194
+ python_hwpx-3.3.1.dist-info/licenses/NOTICE,sha256=k48h6EaGQE8Y1c0dS9sIOOcz4YqkbcImWClF7pBOgsg,2473
195
+ python_hwpx-3.3.1.dist-info/METADATA,sha256=inWS_guBKXWildz4JpZwh2xIpqw-Zm-2PKjiShL3h78,12566
196
+ python_hwpx-3.3.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
197
+ python_hwpx-3.3.1.dist-info/entry_points.txt,sha256=PVUBTBQtBHiflQ9XBjfd004w-LrDibgZjwzxgu8Tx7w,480
198
+ python_hwpx-3.3.1.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
199
+ python_hwpx-3.3.1.dist-info/RECORD,,