python-hwpx 6.2.1__tar.gz → 6.3.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-6.2.1/src/python_hwpx.egg-info → python_hwpx-6.3.0}/PKG-INFO +1 -1
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/pyproject.toml +2 -2
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/_document_primitives.py +6 -2
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/body.py +4 -1
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/memo.py +3 -1
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/note_authoring.py +1 -1
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/table_patch.py +29 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/markdown_export.py +1 -1
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/object_finder.py +1 -1
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/package_validator.py +78 -8
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/text_extractor.py +1 -1
- {python_hwpx-6.2.1 → python_hwpx-6.3.0/src/python_hwpx.egg-info}/PKG-INFO +1 -1
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/python_hwpx.egg-info/SOURCES.txt +3 -0
- python_hwpx-6.3.0/tests/test_instid_attribute_case.py +100 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_layout_lint.py +82 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_release_workflow_safety.py +8 -8
- python_hwpx-6.3.0/tests/test_roundtrip_fidelity_corpus.py +47 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_table_patch.py +67 -0
- python_hwpx-6.3.0/tests/test_tracked_overlap_render_smoke.py +112 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/LICENSE +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/NOTICE +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/README.md +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/setup.cfg +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/__init__.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/__init__.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/_legacy.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/_resolve.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/_units.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/fields.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/headings.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/highlight.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/layout.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/media.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/memos.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/ns/__init__.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/ns/_base.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/ns/fields.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/ns/media.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/ns/notes.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/ns/page.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/ns/parts.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/ns/refs.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/ns/shapes.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/ns/styles.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/ns/tables.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/ns/text.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/ns/tracking.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/persistence.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/shapes.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/_document/tracked.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/body_patch.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/capabilities.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/data/Skeleton.hwpx +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/data/contract_docs/known-traps.md +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/data/contract_docs/mutation-semantics.md +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/data/contract_docs/recipes-traversal.md +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/data/contract_docs/support-matrix.md +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/document.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/equation/__init__.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/equation/authoring.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/equation/eqedit.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/equation/mathml.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/equation/render.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/equation/tokens.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/errors.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/experimental.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/form_fit/__init__.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/form_fit/apply.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/form_fit/engine.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/form_fit/measure.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/form_fit/policy.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/form_fit/report.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/ingest/__init__.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/ingest/base.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/ingest/hwpx_converter.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/layout/__init__.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/layout/lint.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/layout/report.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/model.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/mutation_report.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/objects/__init__.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/objects/binary_item.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/objects/checkbox.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/objects/form_field.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/objects/highlight.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/objects/results.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/objects/tracked.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/opc/package.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/opc/relationships.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/opc/security.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/opc/xml_utils.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/__init__.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/_document_impl.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/canonical_defaults.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/color.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/common.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/document.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/document_metadata.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/document_parts.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/drop_cap.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/dutmal_compose.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/field_marks.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/header.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/header_compat.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/header_part.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/history_part.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/master_page.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/master_page_authoring.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/namespaces.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/numbering.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/numbering_kinds.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/objects.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/paragraph.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/parser.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/run.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/schema.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/section.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/section_format.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/section_layout.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/section_story.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/settings.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/simple_parts.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/table.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/utils.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/oxml/version_part.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/package.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/patch.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/plan/__init__.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/plan/_execute.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/plan/_model.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/plan/_schema.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/plan/_validate.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/py.typed +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/quality/__init__.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/quality/ledger.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/quality/policy.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/quality/rendering.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/quality/report.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/quality/save_pipeline.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/templates.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/__init__.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/_schemas/header.xsd +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/_schemas/section.xsd +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/archive_cli.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/doc_diff.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/document_merge.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/document_viewer.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/exporter.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/generic_inventory.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/id_integrity.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/idempotence.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/ir_equality.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/layout_preview.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/mail_merge.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/package_reconcile.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/page_guard.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/read_fidelity.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/recover.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/redline.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/repair.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/report_utils.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/roundtrip_diff.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/table_cleanup.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/table_navigation.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/template_analyzer.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/text_extract_cli.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/toc_author.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/toc_fidelity.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/hwpx/tools/validator.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/python_hwpx.egg-info/dependency_links.txt +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/python_hwpx.egg-info/entry_points.txt +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/python_hwpx.egg-info/requires.txt +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/src/python_hwpx.egg-info/top_level.txt +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_add_control.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_add_heading.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_adoption_metrics_snapshot.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_arc_authoring.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_authoring_defaults.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_authoring_quality_corpus.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_blind_eval_fixture.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_body_patch.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_border_fill_image_gradient.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_build_element_census.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_byte_patch_identity.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_capabilities_surface.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_cell_line_spacing.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_chart_authoring.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_check_box_authoring.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_column_break.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_comment_node_robustness.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_container_authoring.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_contract_docs_sync.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_corpus_read_fidelity.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_coverage_ledger.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_coverage_promotion.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_coverage_targets.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_deviations_registry.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_doc_diff.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_document_context_manager.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_document_facade_surface.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_document_formatting.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_document_merge.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_document_metadata.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_document_ops_runtime_boundary.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_document_save_api.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_document_viewer.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_documentation_code_fences.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_drop_cap_authoring.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_dutmal_compose_authoring.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_edit_plan.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_editor_surface_inventory.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_engine_surface_6_0.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_equation_authoring.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_equation_converter.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_equation_render.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_error_contract.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_error_messages.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_evalplan_core_primitives.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_existing_document_format_editing.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_field_marks.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_form_field_authoring.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_form_fields.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_form_fill_core_primitives.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_form_fit.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_form_fit_integration.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_fuzz_catalog_derive_expected.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_fuzz_loop.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_fuzz_regressions.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_gap_closure_tools.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_golden_api.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_header_compat_authoring.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_highlight_authoring.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_hp_tab_support.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_hwpxlib_corpus_read.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_id_generator_range.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_id_integrity.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_image_object_workflow.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_index_mark.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_ingest.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_inline_models.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_integration_hwpx_compatibility.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_integration_roundtrip.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_layout_cache_scope.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_layout_preview.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_library_boundary_closure.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_llms_txt.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_mail_merge_xlsx.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_markdown_export.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_master_page_authoring.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_memo_and_style_editing.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_memo_shape_authoring.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_moved_surface_hints.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_mutation_report.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_namespace_handling.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_namespace_hygiene.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_new_features.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_note_authoring_contract.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_ns_fields.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_ns_media.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_ns_notes.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_ns_page.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_ns_refs.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_ns_shapes.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_ns_tables.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_ns_tracking.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_opc_package.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_open_safety_corpus.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_outline_numbering.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_oxml_modularization.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_oxml_parsing.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_packaging_license_metadata.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_packaging_py_typed.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_page_number_control.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_para_pr_dedup.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_paragraph_keep_together.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_paragraph_property_version_switch.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_paragraph_section_management.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_part_hierarchy_read_models.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_polygon_authoring.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_product_boundary.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_public_artifact_hygiene.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_public_runtime_boundary.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_read_fidelity.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_recover_broken_zip.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_redline_authoring.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_redline_verify.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_repair_repack.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_report_utils.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_repr_snapshots.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_return_contract.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_roundtrip_fidelity.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_row_heights.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_run_choice_atom_authoring.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_save_pipeline.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_section_headers.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_section_properties_real_roundtrip.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_set_paragraph_format_keep.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_shape_geometry_contract.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_shape_open_safety_risk_check.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_skeleton_template_ids.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_split_cell.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_split_merged_cell.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_stable_surface.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_style_resolution.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_tab_definition_version_switch.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_table_cell_equalize.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_table_cleanup.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_table_label.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_table_navigation.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_table_patch_dryrun.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_table_patch_m105.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_table_split_merge.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_tables_default_border.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_template_analyzer_enrichment.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_text_extractor_annotations.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_text_extractor_run_choice_atoms.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_title_mark.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_toc_author.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_toc_fidelity.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_validation_severity.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_validator_comment_nodes.py +0 -0
- {python_hwpx-6.2.1 → python_hwpx-6.3.0}/tests/test_version_metadata.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["setuptools==
|
|
2
|
+
requires = ["setuptools==84.0.0", "wheel==0.48.0"]
|
|
3
3
|
build-backend = "setuptools.build_meta"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-hwpx"
|
|
7
|
-
version = "6.
|
|
7
|
+
version = "6.3.0"
|
|
8
8
|
description = "한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 문서 라이브러리"
|
|
9
9
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -200,8 +200,12 @@ def _refresh_copied_paragraph_subtree_ids(paragraph: ET.Element) -> None:
|
|
|
200
200
|
}:
|
|
201
201
|
node.set("id", _object_id())
|
|
202
202
|
|
|
203
|
-
|
|
204
|
-
|
|
203
|
+
# 스키마·실한컴 산출물의 속성명은 소문자 "instid"다. 과거 이 코드가
|
|
204
|
+
# 방출한 "instId"(카멜케이스)를 실은 파일도 재발급 대상에 남긴다 —
|
|
205
|
+
# 속성명 자체는 보존(바이트 보수), 값만 재발급.
|
|
206
|
+
for inst_attr in ("instid", "instId"):
|
|
207
|
+
if inst_attr in node.attrib:
|
|
208
|
+
node.set(inst_attr, _object_id())
|
|
205
209
|
|
|
206
210
|
|
|
207
211
|
def _clone_paragraph_element(paragraph: ET.Element) -> ET.Element:
|
|
@@ -32,7 +32,10 @@ INLINE_OBJECT_NAMES = {
|
|
|
32
32
|
"ole",
|
|
33
33
|
"chart",
|
|
34
34
|
"video",
|
|
35
|
-
"audio"
|
|
35
|
+
# "audio"는 제외 — 실한컴(Windows 한글 2024 실측)은 소리 삽입을
|
|
36
|
+
# hp:ole(EMBEDDED/ICON)로 방출하고 hp:audio를 생성하지 않으며, 보유
|
|
37
|
+
# 실코퍼스 표본도 0건이다. 실물 미검증 요소를 인라인 읽기 지원으로
|
|
38
|
+
# 분류하지 않는다(빈도 0 = 읽기측 불투명 보존만). python-hwpx#89.
|
|
36
39
|
"textart",
|
|
37
40
|
}
|
|
38
41
|
|
|
@@ -277,7 +277,9 @@ class HwpxOxmlNote:
|
|
|
277
277
|
|
|
278
278
|
@property
|
|
279
279
|
def inst_id(self) -> str | None:
|
|
280
|
-
|
|
280
|
+
# 정본 속성명은 "instid"(스키마·실한컴). "instId"는 과거 우리 저작이
|
|
281
|
+
# 방출한 카멜케이스 산출물 호환용 폴백.
|
|
282
|
+
return self.element.get("instid") or self.element.get("instId")
|
|
281
283
|
|
|
282
284
|
@property
|
|
283
285
|
def text(self) -> str:
|
|
@@ -1053,6 +1053,34 @@ def _delete_rows(table: str, del_rows: Iterable[int]) -> str:
|
|
|
1053
1053
|
return _rebuild(prefix, rows, suffix, rowcnt=len(rows))
|
|
1054
1054
|
|
|
1055
1055
|
|
|
1056
|
+
def _reorder_rows(table: str, order: Sequence[int]) -> str:
|
|
1057
|
+
"""물리 행 재배열 — 편집기 [표>정렬…] 대응의 엔진 프리미티브.
|
|
1058
|
+
|
|
1059
|
+
*order*는 현재 물리 행 인덱스의 완전 순열: 새 ``i``번째 행 = 기존
|
|
1060
|
+
``order[i]``. 행 문자열을 통째로 옮기고 각 셀의 ``rowAddr``만 목적지
|
|
1061
|
+
인덱스로 재기입한다(행 높이·서식·문단은 행과 함께 이동). 수직 병합
|
|
1062
|
+
(rowSpan>1)이 하나라도 있으면 refuse — 병합 블록을 가로지르는 재배열은
|
|
1063
|
+
의미가 정의되지 않는다. 정렬 키 비교(어떤 순서로 놓을지)는 호출자 몫이다.
|
|
1064
|
+
"""
|
|
1065
|
+
_guard_flat(table)
|
|
1066
|
+
prefix, rows, suffix = _parse_table(table)
|
|
1067
|
+
if sorted(order) != list(range(len(rows))):
|
|
1068
|
+
raise TableStructureError(
|
|
1069
|
+
f"reorder_rows: order must be a permutation of 0..{len(rows) - 1}, got {list(order)}"
|
|
1070
|
+
)
|
|
1071
|
+
for row in rows:
|
|
1072
|
+
for tc in _S_TC.findall(row):
|
|
1073
|
+
if (_si(tc, "cellSpan", "rowSpan") or 1) > 1:
|
|
1074
|
+
raise TableStructureError(
|
|
1075
|
+
"reorder_rows: vertical merge (rowSpan>1) present — refuse"
|
|
1076
|
+
)
|
|
1077
|
+
new_rows = [
|
|
1078
|
+
_map_cells(rows[src], lambda tc, dest=dest: _ss(tc, "cellAddr", "rowAddr", dest))
|
|
1079
|
+
for dest, src in enumerate(order)
|
|
1080
|
+
]
|
|
1081
|
+
return _rebuild(prefix, new_rows, suffix)
|
|
1082
|
+
|
|
1083
|
+
|
|
1056
1084
|
_PARA_ID_RE = re.compile(r'(<hp:p\b[^>]*\bid=")(\d+)(")')
|
|
1057
1085
|
|
|
1058
1086
|
|
|
@@ -1389,6 +1417,7 @@ def _widths_arg(o: Mapping[str, Any]) -> dict[int, int]:
|
|
|
1389
1417
|
_STRUCT_OPS = {
|
|
1390
1418
|
"delete_column": lambda t, o: _collapse_empty_rows(_delete_columns(t, o["cols"] if "cols" in o else [o["col"]])),
|
|
1391
1419
|
"delete_row": lambda t, o: _delete_rows(t, o["rows"] if "rows" in o else [o["row"]]),
|
|
1420
|
+
"reorder_rows": lambda t, o: _reorder_rows(t, [int(x) for x in o["order"]]),
|
|
1392
1421
|
"insert_row_by_clone": lambda t, o: _insert_row_by_clone(t, o["ref_row"], int(o.get("count", 1))),
|
|
1393
1422
|
"insert_block_by_clone": lambda t, o: _insert_block_by_clone(t, int(o["ref_rows"][0]), int(o["ref_rows"][1]), int(o.get("count", 1))),
|
|
1394
1423
|
"set_column_widths": lambda t, o: _set_column_widths(t, _widths_arg(o)),
|
|
@@ -261,7 +261,7 @@ def _md_handle_ctrl_child(child, state: _MdParagraphState, base_cp, chars, doc=N
|
|
|
261
261
|
def _md_handle_note_child(
|
|
262
262
|
child, tag: str, state: _MdParagraphState, base_cp, chars, doc, notes_out: list | None
|
|
263
263
|
) -> None:
|
|
264
|
-
inst_id = child.get("instId", "")
|
|
264
|
+
inst_id = child.get("instid") or child.get("instId", "")
|
|
265
265
|
kind = "fn" if tag == "footNote" else "en"
|
|
266
266
|
marker = f"[^{kind}{inst_id}]"
|
|
267
267
|
if state.link_url is not None:
|
|
@@ -317,7 +317,7 @@ class ObjectFinder:
|
|
|
317
317
|
) -> AnnotationMatch:
|
|
318
318
|
path = describe_element_path(element, parent_map)
|
|
319
319
|
found = FoundElement(section=section, path=path, element=element)
|
|
320
|
-
inst_id = element.get("instId") or ""
|
|
320
|
+
inst_id = element.get("instid") or element.get("instId") or ""
|
|
321
321
|
behavior = options.footnote if kind == "footnote" else options.endnote
|
|
322
322
|
if behavior == "inline":
|
|
323
323
|
text = _resolve_note_text(
|
|
@@ -331,14 +331,17 @@ def _first_child_by_local(element: ET.Element, name: str) -> ET.Element | None:
|
|
|
331
331
|
return None
|
|
332
332
|
|
|
333
333
|
|
|
334
|
-
def
|
|
335
|
-
"""Return visible text
|
|
334
|
+
def _simple_paragraph_text(paragraph: ET.Element) -> str | None:
|
|
335
|
+
"""Return visible text for plain text-only paragraphs (``None`` = 판정 불가).
|
|
336
336
|
|
|
337
337
|
Paragraphs containing fields, shapes, tables, or other embedded controls are
|
|
338
338
|
skipped to avoid guessing how a specific editor counts their layout units.
|
|
339
|
+
Single-position control characters (tab/linebreak/hyphen/nbspace) are
|
|
340
|
+
represented as one space — a deliberate width underestimate so downstream
|
|
341
|
+
width judgments stay conservative.
|
|
339
342
|
"""
|
|
340
343
|
|
|
341
|
-
|
|
344
|
+
pieces: list[str] = []
|
|
342
345
|
for child in paragraph:
|
|
343
346
|
child_name = _local_name(child).lower()
|
|
344
347
|
if child_name == "linesegarray":
|
|
@@ -348,12 +351,73 @@ def _simple_paragraph_text_length(paragraph: ET.Element) -> int | None:
|
|
|
348
351
|
for run_child in child:
|
|
349
352
|
run_child_name = _local_name(run_child).lower()
|
|
350
353
|
if run_child_name == "t":
|
|
351
|
-
|
|
354
|
+
pieces.append("".join(run_child.itertext()))
|
|
352
355
|
elif run_child_name in {"tab", "linebreak", "hyphen", "nbspace"}:
|
|
353
|
-
|
|
356
|
+
pieces.append(" ")
|
|
354
357
|
else:
|
|
355
358
|
return None
|
|
356
|
-
return
|
|
359
|
+
return "".join(pieces)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _simple_paragraph_text_length(paragraph: ET.Element) -> int | None:
|
|
363
|
+
text = _simple_paragraph_text(paragraph)
|
|
364
|
+
return None if text is None else len(text)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
#: 꼬리 폭 판정 마진 — 추정 폭이 줄 폭의 이 배수를 넘을 때만 증명으로 취급.
|
|
368
|
+
_STALE_TAIL_WIDTH_FACTOR = 2.0
|
|
369
|
+
_STALE_TAIL_MIN_CHARS = 8
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _check_line_seg_tail_coverage(
|
|
373
|
+
issues: list[PackageValidationIssue],
|
|
374
|
+
part_name: str,
|
|
375
|
+
paragraph_index: int,
|
|
376
|
+
text: str,
|
|
377
|
+
line_segs: list[ET.Element],
|
|
378
|
+
) -> None:
|
|
379
|
+
"""Catch caches that lack lines for text grown past the last cached line.
|
|
380
|
+
|
|
381
|
+
The stale-cache direction the textpos range check cannot see: an edit made
|
|
382
|
+
the paragraph *longer* but an old ``<hp:linesegarray>`` survived, so every
|
|
383
|
+
cached ``textpos`` is still in range — yet the cache has too few lines and
|
|
384
|
+
Hancom renders the tail overlapped (the tracked-change overlap defect).
|
|
385
|
+
Provable-with-margin: the tail after the last cached line start must not
|
|
386
|
+
measure wider than that line's extent × ``_STALE_TAIL_WIDTH_FACTOR``,
|
|
387
|
+
with width estimated by the same conservative metrics the overflow lint
|
|
388
|
+
uses.
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
last: tuple[int, int, int] | None = None
|
|
392
|
+
for seg in line_segs:
|
|
393
|
+
try:
|
|
394
|
+
textpos = int(seg.get("textpos") or "")
|
|
395
|
+
horzsize = int(seg.get("horzsize") or "")
|
|
396
|
+
textheight = int(seg.get("textheight") or "")
|
|
397
|
+
except ValueError:
|
|
398
|
+
return
|
|
399
|
+
if last is None or textpos > last[0]:
|
|
400
|
+
last = (textpos, horzsize, textheight)
|
|
401
|
+
if last is None:
|
|
402
|
+
return
|
|
403
|
+
last_pos, horzsize, textheight = last
|
|
404
|
+
if horzsize <= 0 or textheight <= 0 or not 0 <= last_pos <= len(text):
|
|
405
|
+
return
|
|
406
|
+
tail = text[last_pos:]
|
|
407
|
+
if len(tail) < _STALE_TAIL_MIN_CHARS:
|
|
408
|
+
return
|
|
409
|
+
from hwpx.form_fit.measure import estimate_text_width
|
|
410
|
+
|
|
411
|
+
tail_width = estimate_text_width(tail, textheight / 100.0)
|
|
412
|
+
if tail_width > horzsize * _STALE_TAIL_WIDTH_FACTOR:
|
|
413
|
+
_error(
|
|
414
|
+
issues,
|
|
415
|
+
part_name,
|
|
416
|
+
f"paragraph {paragraph_index} lineseg cache lacks lines for its tail: "
|
|
417
|
+
f"{len(tail)} chars after the last cached line start (textpos={last_pos}) "
|
|
418
|
+
f"measure ~{tail_width / horzsize:.1f}x the cached line extent {horzsize} "
|
|
419
|
+
"— stale layout cache; Hancom reuses it and renders the tail overlapped",
|
|
420
|
+
)
|
|
357
421
|
|
|
358
422
|
|
|
359
423
|
def _check_line_seg_text_positions(
|
|
@@ -367,15 +431,18 @@ def _check_line_seg_text_positions(
|
|
|
367
431
|
for paragraph_index, paragraph in enumerate(
|
|
368
432
|
element for element in root.iter() if _local_name(element) == "p"
|
|
369
433
|
):
|
|
370
|
-
|
|
371
|
-
if
|
|
434
|
+
text = _simple_paragraph_text(paragraph)
|
|
435
|
+
if text is None:
|
|
372
436
|
continue
|
|
437
|
+
text_length = len(text)
|
|
373
438
|
for child in paragraph:
|
|
374
439
|
if _local_name(child).lower() != "linesegarray":
|
|
375
440
|
continue
|
|
441
|
+
line_segs: list[ET.Element] = []
|
|
376
442
|
for line_seg in child:
|
|
377
443
|
if _local_name(line_seg).lower() != "lineseg":
|
|
378
444
|
continue
|
|
445
|
+
line_segs.append(line_seg)
|
|
379
446
|
textpos_raw = line_seg.get("textpos")
|
|
380
447
|
if textpos_raw is None:
|
|
381
448
|
continue
|
|
@@ -396,6 +463,9 @@ def _check_line_seg_text_positions(
|
|
|
396
463
|
f"{paragraph_index} has stale lineseg textpos={textpos} "
|
|
397
464
|
f"beyond text length {text_length}",
|
|
398
465
|
)
|
|
466
|
+
_check_line_seg_tail_coverage(
|
|
467
|
+
issues, part_name, paragraph_index, text, line_segs
|
|
468
|
+
)
|
|
399
469
|
|
|
400
470
|
|
|
401
471
|
def _check_table_editor_acceptance(
|
|
@@ -483,7 +483,7 @@ class TextExtractor:
|
|
|
483
483
|
return
|
|
484
484
|
|
|
485
485
|
kind_name = "footnote" if kind == "footNote" else "endnote"
|
|
486
|
-
inst_id = element.get("instId") or ""
|
|
486
|
+
inst_id = element.get("instid") or element.get("instId") or ""
|
|
487
487
|
|
|
488
488
|
if option == "placeholder":
|
|
489
489
|
fragments.append(
|
|
@@ -230,6 +230,7 @@ tests/test_image_object_workflow.py
|
|
|
230
230
|
tests/test_index_mark.py
|
|
231
231
|
tests/test_ingest.py
|
|
232
232
|
tests/test_inline_models.py
|
|
233
|
+
tests/test_instid_attribute_case.py
|
|
233
234
|
tests/test_integration_hwpx_compatibility.py
|
|
234
235
|
tests/test_integration_roundtrip.py
|
|
235
236
|
tests/test_layout_cache_scope.py
|
|
@@ -283,6 +284,7 @@ tests/test_report_utils.py
|
|
|
283
284
|
tests/test_repr_snapshots.py
|
|
284
285
|
tests/test_return_contract.py
|
|
285
286
|
tests/test_roundtrip_fidelity.py
|
|
287
|
+
tests/test_roundtrip_fidelity_corpus.py
|
|
286
288
|
tests/test_row_heights.py
|
|
287
289
|
tests/test_run_choice_atom_authoring.py
|
|
288
290
|
tests/test_save_pipeline.py
|
|
@@ -312,6 +314,7 @@ tests/test_text_extractor_run_choice_atoms.py
|
|
|
312
314
|
tests/test_title_mark.py
|
|
313
315
|
tests/test_toc_author.py
|
|
314
316
|
tests/test_toc_fidelity.py
|
|
317
|
+
tests/test_tracked_overlap_render_smoke.py
|
|
315
318
|
tests/test_validation_severity.py
|
|
316
319
|
tests/test_validator_comment_nodes.py
|
|
317
320
|
tests/test_version_metadata.py
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""instid 속성명 대소문자 회귀 (#88).
|
|
3
|
+
|
|
4
|
+
OWPML 스키마·실한컴 산출물의 인스턴스 ID 속성명은 소문자 ``instid``다
|
|
5
|
+
(픽스처 전수 재집계 instid 744 / instId 0). 과거 코드가 4곳에서 카멜케이스
|
|
6
|
+
``instId``를 읽거나 써서: ① 문단 복제 재발급 분기가 사문화(중복 instid 복제),
|
|
7
|
+
② 각주/미주 저작이 스키마에 없는 속성명을 방출, ③ 리더가 실한컴 파일에서
|
|
8
|
+
항상 None을 봤다. 수리 계약: 쓰기는 ``instid``, 읽기는 ``instid`` 우선 +
|
|
9
|
+
과거 자사 산출물(``instId``) 폴백, 복제 재발급은 양쪽 속성명 모두 값 재발급
|
|
10
|
+
(속성명 자체는 보존 — 바이트 보수).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
from hwpx import HwpxDocument
|
|
17
|
+
from hwpx.oxml._document_primitives import _clone_paragraph_element
|
|
18
|
+
from hwpx.oxml.memo import HwpxOxmlNote
|
|
19
|
+
from hwpx.tools.markdown_export import export_markdown
|
|
20
|
+
|
|
21
|
+
HP = "{http://www.hancom.co.kr/hwpml/2011/paragraph}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _footnote_elements(document: HwpxDocument):
|
|
25
|
+
for paragraph in document.paragraphs:
|
|
26
|
+
for element in paragraph.element.iter():
|
|
27
|
+
if element.tag == f"{HP}footNote":
|
|
28
|
+
yield element
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_footnote_authoring_emits_schema_case_instid() -> None:
|
|
32
|
+
document = HwpxDocument.new()
|
|
33
|
+
paragraph = document.add_paragraph("본문")
|
|
34
|
+
paragraph.add_footnote("각주 본문")
|
|
35
|
+
|
|
36
|
+
notes = list(_footnote_elements(document))
|
|
37
|
+
assert notes, "각주가 저작되지 않았다"
|
|
38
|
+
for note in notes:
|
|
39
|
+
assert note.get("instid"), "스키마 정본 속성명 instid가 없다"
|
|
40
|
+
assert note.get("instId") is None, "카멜케이스 instId를 다시 방출했다"
|
|
41
|
+
|
|
42
|
+
data = document.to_bytes()
|
|
43
|
+
assert b'instId="' not in data
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_clone_reissues_schema_case_instid() -> None:
|
|
47
|
+
document = HwpxDocument.new()
|
|
48
|
+
paragraph = document.add_paragraph("본문")
|
|
49
|
+
paragraph.add_footnote("각주 본문")
|
|
50
|
+
source = paragraph.element
|
|
51
|
+
original = next(iter(_footnote_elements(document))).get("instid")
|
|
52
|
+
assert original
|
|
53
|
+
|
|
54
|
+
cloned = _clone_paragraph_element(source)
|
|
55
|
+
cloned_note = next(
|
|
56
|
+
el for el in cloned.iter() if el.tag == f"{HP}footNote"
|
|
57
|
+
)
|
|
58
|
+
assert cloned_note.get("instid"), "복제본에서 instid가 사라졌다"
|
|
59
|
+
assert cloned_note.get("instid") != original, "복제본 instid가 재발급되지 않았다 (중복 인스턴스 ID)"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_clone_reissues_legacy_camelcase_value_but_keeps_name() -> None:
|
|
63
|
+
document = HwpxDocument.new()
|
|
64
|
+
paragraph = document.add_paragraph("본문")
|
|
65
|
+
run = paragraph.element.makeelement(f"{HP}run", {"charPrIDRef": "0"})
|
|
66
|
+
paragraph.element.append(run)
|
|
67
|
+
legacy = run.makeelement(f"{HP}footNote", {"instId": "9999"})
|
|
68
|
+
run.append(legacy)
|
|
69
|
+
|
|
70
|
+
cloned = _clone_paragraph_element(paragraph.element)
|
|
71
|
+
cloned_note = next(el for el in cloned.iter() if el.tag == f"{HP}footNote")
|
|
72
|
+
assert cloned_note.get("instId") not in (None, "9999"), "과거 자사 산출물의 instId도 값 재발급 대상"
|
|
73
|
+
assert cloned_note.get("instid") is None, "속성명은 보존한다 (바이트 보수)"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_note_reader_prefers_schema_case_with_legacy_fallback() -> None:
|
|
77
|
+
document = HwpxDocument.new()
|
|
78
|
+
paragraph = document.add_paragraph("본문")
|
|
79
|
+
element = paragraph.element.makeelement(f"{HP}footNote", {"instid": "11"})
|
|
80
|
+
assert HwpxOxmlNote(element, paragraph).inst_id == "11"
|
|
81
|
+
|
|
82
|
+
legacy = paragraph.element.makeelement(f"{HP}footNote", {"instId": "22"})
|
|
83
|
+
assert HwpxOxmlNote(legacy, paragraph).inst_id == "22"
|
|
84
|
+
|
|
85
|
+
both = paragraph.element.makeelement(
|
|
86
|
+
f"{HP}footNote", {"instid": "11", "instId": "22"}
|
|
87
|
+
)
|
|
88
|
+
assert HwpxOxmlNote(both, paragraph).inst_id == "11"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_markdown_note_marker_carries_authored_instid() -> None:
|
|
92
|
+
"""저작(쓰기)과 export(읽기)가 같은 속성명으로 만나야 마커에 ID가 실린다."""
|
|
93
|
+
|
|
94
|
+
document = HwpxDocument.new()
|
|
95
|
+
paragraph = document.add_paragraph("본문")
|
|
96
|
+
paragraph.add_footnote("각주 본문")
|
|
97
|
+
|
|
98
|
+
markdown = export_markdown(HwpxDocument.open(document.to_bytes()))
|
|
99
|
+
match = re.search(r"\[\^fn(\d+)\]", markdown)
|
|
100
|
+
assert match, f"각주 마커에 instid가 비었다: {markdown!r}"
|
|
@@ -125,6 +125,88 @@ def test_seeded_stale_lineseg_is_caught():
|
|
|
125
125
|
assert any(f.code == STALE_LINESEG_DETECTED for f in report.errors)
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
def _add_measured_lineseg(root, text: str, seg_attrs: list[dict[str, str]]) -> None:
|
|
129
|
+
"""Attach a full-attribute cache (textpos/horzsize/textheight) to *text*'s paragraph."""
|
|
130
|
+
|
|
131
|
+
para = _text_paragraph(root, text)
|
|
132
|
+
ns = para.tag.rsplit("}", 1)[0].lstrip("{")
|
|
133
|
+
lsa = etree.SubElement(para, f"{{{ns}}}lineSegArray")
|
|
134
|
+
for attrs in seg_attrs:
|
|
135
|
+
etree.SubElement(lsa, f"{{{ns}}}lineSeg", attrs)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
_A4_LINE = {"horzsize": "42520", "textheight": "1000"} # 10pt · 표준 본문 줄 폭
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_grown_text_with_stale_single_line_cache_is_caught():
|
|
142
|
+
"""#85 방향: 텍스트가 캐시 줄 수 너머로 자랐는데 textpos는 전부 유효한 경우.
|
|
143
|
+
|
|
144
|
+
범위 검사(textpos > len)는 이 방향을 못 본다 — 꼬리 폭 판정이 잡아야 한다.
|
|
145
|
+
120 한글자 문단에 1줄짜리 캐시: 꼬리 폭 ~120em ≫ 줄 폭 42520×2.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
text = "가" * 120
|
|
149
|
+
doc = HwpxDocument.new()
|
|
150
|
+
doc.add_paragraph(text)
|
|
151
|
+
data = _inject_section0(
|
|
152
|
+
_bytes(doc),
|
|
153
|
+
lambda root: _add_measured_lineseg(root, text, [{"textpos": "0", **_A4_LINE}]),
|
|
154
|
+
)
|
|
155
|
+
report = lint_layout(data)
|
|
156
|
+
assert not report.ok
|
|
157
|
+
assert any(
|
|
158
|
+
f.code == STALE_LINESEG_DETECTED and "lacks lines" in f.message
|
|
159
|
+
for f in report.errors
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_valid_multiline_cache_tail_passes():
|
|
164
|
+
"""정상 다줄 캐시(한컴 저장 형태): 마지막 줄 이후 꼬리가 한 줄 안 → 침묵."""
|
|
165
|
+
|
|
166
|
+
text = "가" * 120
|
|
167
|
+
doc = HwpxDocument.new()
|
|
168
|
+
doc.add_paragraph(text)
|
|
169
|
+
segs = [
|
|
170
|
+
{"textpos": "0", **_A4_LINE},
|
|
171
|
+
{"textpos": "42", **_A4_LINE},
|
|
172
|
+
{"textpos": "84", **_A4_LINE},
|
|
173
|
+
]
|
|
174
|
+
data = _inject_section0(
|
|
175
|
+
_bytes(doc), lambda root: _add_measured_lineseg(root, text, segs)
|
|
176
|
+
)
|
|
177
|
+
assert lint_layout(data).ok
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_tracked_insert_prefix_artifact_replay():
|
|
181
|
+
"""#85 실물 재현 쌍: 수리 전 산출물은 FAIL, 현행(수리 후) 산출물은 PASS.
|
|
182
|
+
|
|
183
|
+
현행 코드로 변경추적 삽입을 하면 편집 문단의 캐시가 제거된다(a22342c) —
|
|
184
|
+
그 산출물이 PASS 쪽. FAIL 쪽은 같은 편집에 수리 전처럼 옛 1줄 캐시를
|
|
185
|
+
바이트에 되붙여 재구성한다.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
base = "시행문 본문 첫 문단"
|
|
189
|
+
doc = HwpxDocument.new()
|
|
190
|
+
paragraph = doc.add_paragraph(base, char_pr_id_ref="0")
|
|
191
|
+
doc.tracking.insert(paragraph, " 추가로 붙는 안내 문구가 길게 이어진다 " + "안내" * 50, date="2026-08-19")
|
|
192
|
+
|
|
193
|
+
fixed = doc.to_bytes() # 수리 후: 캐시 없음
|
|
194
|
+
assert lint_layout(fixed).ok
|
|
195
|
+
|
|
196
|
+
edited_text = paragraph.text
|
|
197
|
+
|
|
198
|
+
def reattach(root): # 수리 전 재구성: 옛 1줄 캐시가 그대로 남았던 상태
|
|
199
|
+
_add_measured_lineseg(root, edited_text, [{"textpos": "0", **_A4_LINE}])
|
|
200
|
+
|
|
201
|
+
broken = _inject_section0(fixed, reattach)
|
|
202
|
+
report = lint_layout(broken)
|
|
203
|
+
assert not report.ok
|
|
204
|
+
assert any(
|
|
205
|
+
f.code == STALE_LINESEG_DETECTED and "lacks lines" in f.message
|
|
206
|
+
for f in report.errors
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
128
210
|
# --------------------------------------------------------------------------- #
|
|
129
211
|
# 2: dirty ↔ lineseg leak (ledger-gated).
|
|
130
212
|
# --------------------------------------------------------------------------- #
|
|
@@ -33,7 +33,7 @@ SBOM_STEP = "Generate release SBOM"
|
|
|
33
33
|
PYPI_ACTION = "pypa/gh-action-pypi-publish@"
|
|
34
34
|
GITHUB_ACTION = "softprops/action-gh-release@"
|
|
35
35
|
CHECKOUT_ACTION = (
|
|
36
|
-
"actions/checkout@
|
|
36
|
+
"actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"
|
|
37
37
|
)
|
|
38
38
|
EXPECTED_TRIGGER = {
|
|
39
39
|
"push": {
|
|
@@ -97,7 +97,7 @@ mkdir -p release-artifacts
|
|
|
97
97
|
EXPECTED_RELEASE_STEPS = (
|
|
98
98
|
(
|
|
99
99
|
"Checkout repository",
|
|
100
|
-
"actions/checkout@
|
|
100
|
+
"actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1",
|
|
101
101
|
False,
|
|
102
102
|
None,
|
|
103
103
|
None,
|
|
@@ -106,7 +106,7 @@ EXPECTED_RELEASE_STEPS = (
|
|
|
106
106
|
),
|
|
107
107
|
(
|
|
108
108
|
"Set up Python",
|
|
109
|
-
"actions/setup-python@
|
|
109
|
+
"actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97",
|
|
110
110
|
False,
|
|
111
111
|
{"python-version": "3.12"},
|
|
112
112
|
None,
|
|
@@ -135,7 +135,7 @@ EXPECTED_RELEASE_STEPS = (
|
|
|
135
135
|
(SBOM_STEP, None, True, None, None, None, None),
|
|
136
136
|
(
|
|
137
137
|
"Publish package to PyPI",
|
|
138
|
-
"pypa/gh-action-pypi-publish@
|
|
138
|
+
"pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33",
|
|
139
139
|
False,
|
|
140
140
|
None,
|
|
141
141
|
None,
|
|
@@ -644,12 +644,12 @@ def test_public_legacy_cap_is_a_required_fail_closed_job() -> None:
|
|
|
644
644
|
" needs: legacy-cap\n"
|
|
645
645
|
" runs-on: ubuntu-latest\n"
|
|
646
646
|
" steps:\n"
|
|
647
|
-
f" - uses: {CHECKOUT_ACTION} # v7.0.
|
|
647
|
+
f" - uses: {CHECKOUT_ACTION} # v7.0.1\n",
|
|
648
648
|
" prepublish:\n"
|
|
649
649
|
" needs: legacy-cap\n"
|
|
650
650
|
" runs-on: ubuntu-latest\n"
|
|
651
651
|
" steps:\n"
|
|
652
|
-
f" - uses: {CHECKOUT_ACTION} # v7.0.
|
|
652
|
+
f" - uses: {CHECKOUT_ACTION} # v7.0.1\n"
|
|
653
653
|
" with:\n"
|
|
654
654
|
" ref: main\n",
|
|
655
655
|
1,
|
|
@@ -857,8 +857,8 @@ def test_core_release_build_inputs_and_remote_provenance_are_frozen() -> None:
|
|
|
857
857
|
(ROOT / "pyproject.toml").read_text(encoding="utf-8")
|
|
858
858
|
)
|
|
859
859
|
assert pyproject["build-system"]["requires"] == [
|
|
860
|
-
"setuptools==
|
|
861
|
-
"wheel==0.
|
|
860
|
+
"setuptools==84.0.0",
|
|
861
|
+
"wheel==0.48.0",
|
|
862
862
|
]
|
|
863
863
|
assert _workflow_safety_failures(RELEASE.read_text(encoding="utf-8")) == []
|
|
864
864
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""왕복 충실도 로컬 절반의 상주 회귀 — 실질 구조 변화 0을 매 스위트가 재검한다.
|
|
3
|
+
|
|
4
|
+
`scripts/roundtrip_fidelity.py`의 분모(실한컴 provenance 증명 파일 전수)를
|
|
5
|
+
무편집 재직렬화(open→to_bytes)하고, 분류가 무해 범주(byte-identical /
|
|
6
|
+
zip-container-only / cosmetic)에만 속하는지 단언한다. substantive-structural-
|
|
7
|
+
change가 하나라도 나오면 이 테스트가 릴리스 전에 막는다 — 공개 지표
|
|
8
|
+
(docs/corpus-metrics.md 왕복 충실도 절)의 "실질 변화 0"이 주장이 아니라
|
|
9
|
+
게이트가 되게 하는 장치다. 실한컴 재개봉 판정(외부 관찰자 절반)은 별도
|
|
10
|
+
배치(automation `scripts/roundtrip_reopen_mac.py`) 소관이다.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib.util
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
19
|
+
|
|
20
|
+
_spec = importlib.util.spec_from_file_location(
|
|
21
|
+
"roundtrip_fidelity", ROOT / "scripts" / "roundtrip_fidelity.py"
|
|
22
|
+
)
|
|
23
|
+
assert _spec and _spec.loader
|
|
24
|
+
rf = importlib.util.module_from_spec(_spec)
|
|
25
|
+
sys.modules[_spec.name] = rf # dataclass가 자기 모듈을 sys.modules에서 찾는다
|
|
26
|
+
_spec.loader.exec_module(rf)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_corpus_roundtrip_has_no_substantive_change(tmp_path, monkeypatch):
|
|
30
|
+
monkeypatch.setattr(rf, "OUTPUT_DIR", tmp_path / "roundtrip")
|
|
31
|
+
|
|
32
|
+
included, excluded = rf.discover_corpus()
|
|
33
|
+
assert included, "왕복 분모가 비었다 — 코퍼스 루트/자격 심사 확인"
|
|
34
|
+
# 자격 심사 정직성: 알려진 제외 3건(비실한컴 1 + 실한컴 거부 픽스처 2)은
|
|
35
|
+
# 반드시 사유와 함께 제외 목록에 있어야 한다.
|
|
36
|
+
excluded_paths = {e.rel_path for e in excluded}
|
|
37
|
+
assert set(rf.KNOWN_NON_ORACLE_EXCLUSIONS) <= excluded_paths
|
|
38
|
+
|
|
39
|
+
offenders: list[str] = []
|
|
40
|
+
for entry in included:
|
|
41
|
+
result = rf.measure_file(entry)
|
|
42
|
+
if result.overall_category.startswith("substantive"):
|
|
43
|
+
offenders.append(f"{entry.rel_path}: {result.overall_category}")
|
|
44
|
+
assert not offenders, (
|
|
45
|
+
"무편집 왕복에서 실질 구조 변화 발생 — 수리 전 릴리스 금지:\n"
|
|
46
|
+
+ "\n".join(offenders)
|
|
47
|
+
)
|