python-hwpx 2.18.0__tar.gz → 2.20.0__tar.gz
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.
- {python_hwpx-2.18.0/src/python_hwpx.egg-info → python_hwpx-2.20.0}/PKG-INFO +1 -1
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/pyproject.toml +2 -2
- python_hwpx-2.20.0/src/hwpx/tools/read_fidelity.py +281 -0
- python_hwpx-2.20.0/src/hwpx/tools/toc_author.py +291 -0
- python_hwpx-2.20.0/src/hwpx/tools/toc_fidelity.py +410 -0
- python_hwpx-2.20.0/src/hwpx/visual/_hancom_open_rate.ps1 +374 -0
- python_hwpx-2.20.0/src/hwpx/visual/_refresh_hwpx_mac.applescript +162 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/visual/_render_hwpx_mac.applescript +5 -1
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/visual/oracle.py +222 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0/src/python_hwpx.egg-info}/PKG-INFO +1 -1
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/python_hwpx.egg-info/SOURCES.txt +10 -0
- python_hwpx-2.20.0/tests/test_corpus_read_fidelity.py +39 -0
- python_hwpx-2.20.0/tests/test_openrate.py +560 -0
- python_hwpx-2.20.0/tests/test_read_fidelity.py +122 -0
- python_hwpx-2.20.0/tests/test_toc_author.py +178 -0
- python_hwpx-2.20.0/tests/test_toc_fidelity.py +205 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/LICENSE +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/NOTICE +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/README.md +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/setup.cfg +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/__init__.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/authoring.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/builder/__init__.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/builder/core.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/builder/report.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/conformance/__init__.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/conformance/badges.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/conformance/corpus/corpus.json +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/conformance/corpus/meeting_summary.hwpx +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/conformance/corpus/notice.hwpx +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/conformance/corpus/report_table.hwpx +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/conformance/corpus.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/conformance/report.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/conformance/roundtrip_batch.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/conformance/runner.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/data/Skeleton.hwpx +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/__init__.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/_support.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/composer.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/harvest.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/plan.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profile.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/application_form/fragments/body.xml +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/application_form/fragments/heading.xml +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/application_form/fragments/info_table.xml +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/application_form/fragments/title.xml +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/application_form/profile.json +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/application_form/template.hwpx +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/home_notice/fragments/body.xml +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/home_notice/fragments/heading.xml +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/home_notice/fragments/title.xml +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/home_notice/profile.json +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/home_notice/template.hwpx +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/official_notice/fragments/body.xml +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/official_notice/fragments/heading.xml +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/official_notice/fragments/info_table.xml +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/official_notice/fragments/title.xml +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/official_notice/profile.json +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/official_notice/template.hwpx +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/report/fragments/body.xml +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/report/fragments/heading.xml +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/report/fragments/info_table.xml +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/report/fragments/title.xml +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/report/profile.json +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/profiles/report/template.hwpx +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/design/validator.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/document.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/exam/__init__.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/exam/compose.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/exam/ir.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/exam/measure.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/exam/parser.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/exam/profile.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/form_fill.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/form_fit/__init__.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/form_fit/apply.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/form_fit/engine.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/form_fit/measure.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/form_fit/policy.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/form_fit/report.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/form_fit/seal.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/form_fit/wordbox.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/layout/__init__.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/layout/lint.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/layout/report.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/opc/package.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/opc/relationships.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/opc/security.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/opc/xml_utils.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/oxml/__init__.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/oxml/_document_impl.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/oxml/body.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/oxml/canonical_defaults.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/oxml/common.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/oxml/document.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/oxml/header.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/oxml/header_part.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/oxml/memo.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/oxml/namespaces.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/oxml/numbering.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/oxml/objects.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/oxml/paragraph.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/oxml/parser.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/oxml/run.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/oxml/schema.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/oxml/section.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/oxml/simple_parts.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/oxml/table.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/oxml/utils.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/package.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/patch.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/presets/__init__.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/presets/proposal.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/py.typed +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/quality/__init__.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/quality/ledger.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/quality/policy.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/quality/report.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/quality/save_pipeline.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/template_formfit.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/templates.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/__init__.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/_schemas/header.xsd +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/_schemas/section.xsd +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/advanced_generators.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/archive_cli.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/doc_diff.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/exporter.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/fuzz/__init__.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/fuzz/__main__.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/fuzz/catalog.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/fuzz/generator.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/fuzz/minimize.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/fuzz/runner.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/generic_inventory.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/id_integrity.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/idempotence.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/ir_equality.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/layout_preview.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/mail_merge.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/markdown_export.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/object_finder.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/official_lint.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/package_reconcile.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/package_validator.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/page_guard.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/pii.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/recover.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/redline.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/repair.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/report_parser.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/report_utils.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/roundtrip_diff.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/style_profile.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/table_cleanup.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/table_compute.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/table_navigation.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/template_analyzer.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/text_extract_cli.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/text_extractor.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/tools/validator.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/visual/__init__.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/visual/_render_hwpx.ps1 +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/visual/detectors.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/visual/diff.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/visual/masks.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/hwpx/visual/report.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/python_hwpx.egg-info/dependency_links.txt +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/python_hwpx.egg-info/entry_points.txt +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/python_hwpx.egg-info/requires.txt +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/src/python_hwpx.egg-info/top_level.txt +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_advanced_generators.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_authoring_profile_routing.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_authoring_profile_routing_oracle.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_authoring_render_check.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_builder_core.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_builder_plan_v2.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_builder_vertical_slice.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_comment_node_robustness.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_conformance.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_coverage_promotion.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_coverage_targets.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_design_builder.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_deviations_registry.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_doc_diff.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_document_context_manager.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_document_formatting.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_document_plan.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_document_plan_computed_fields.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_document_save_api.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_exam_compose.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_exam_compose_oracle.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_exam_fixtures.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_exam_ir.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_exam_measure.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_exam_parser.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_exam_profile.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_existing_document_format_editing.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_form_fields.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_form_fill_split_run.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_form_fit.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_form_fit_integration.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_form_fit_seal.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_form_fit_seal_placement.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_form_fit_wordbox.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_fuzz_loop.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_fuzz_regressions.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_gap_closure_tools.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_government_report_preset.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_government_table_profile.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_hp_tab_support.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_hwpxlib_corpus_read.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_id_generator_range.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_id_integrity.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_image_object_workflow.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_inline_models.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_integration_hwpx_compatibility.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_integration_roundtrip.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_kordoc_absorption.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_layout_lint.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_layout_preview.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_mail_merge_fit.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_mail_merge_table_compute.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_mail_merge_xlsx.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_markdown_export.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_memo_and_style_editing.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_namespace_handling.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_new_features.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_official_document_style.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_official_lint_gongmun_gate.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_official_lint_tableaware.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_opc_package.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_open_safety_corpus.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_oxml_parsing.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_packaging_license_metadata.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_packaging_py_typed.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_paragraph_keep_together.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_paragraph_section_management.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_pii.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_pii_structural.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_pii_wiring.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_proposal_preset.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_question_split_detector.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_recover_broken_zip.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_redline_authoring.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_redline_verify.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_repair_repack.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_report_parser.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_report_utils.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_repr_snapshots.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_rhwp_t1_gates.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_rhwp_t2_verification.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_roundtrip_fidelity.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_save_pipeline.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_save_pipeline_no_bypass.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_section_headers.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_set_paragraph_format_keep.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_skeleton_template_ids.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_split_merged_cell.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_style_profile.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_table_cleanup.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_table_navigation.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_tables_default_border.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_template_analyzer_enrichment.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_template_formfit.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_text_extractor_annotations.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_validation_severity.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_validator_comment_nodes.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_version_metadata.py +0 -0
- {python_hwpx-2.18.0 → python_hwpx-2.20.0}/tests/test_visual_oracle.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-hwpx"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.20.0"
|
|
8
8
|
description = "한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 자동화 라이브러리"
|
|
9
9
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -81,7 +81,7 @@ include = ["hwpx*"]
|
|
|
81
81
|
"hwpx" = ["py.typed"]
|
|
82
82
|
"hwpx.tools" = ["_schemas/*.xsd"]
|
|
83
83
|
"hwpx.data" = ["Skeleton.hwpx"]
|
|
84
|
-
"hwpx.visual" = ["_render_hwpx.ps1", "_render_hwpx_mac.applescript", "_hancom_open_rate.ps1"]
|
|
84
|
+
"hwpx.visual" = ["_render_hwpx.ps1", "_render_hwpx_mac.applescript", "_refresh_hwpx_mac.applescript", "_hancom_open_rate.ps1"]
|
|
85
85
|
"hwpx.design" = [
|
|
86
86
|
"profiles/*/profile.json",
|
|
87
87
|
"profiles/*/template.hwpx",
|
|
@@ -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}
|