python-hwpx 5.1.1__tar.gz → 5.2.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-5.1.1 → python_hwpx-5.2.0}/NOTICE +9 -3
- {python_hwpx-5.1.1/src/python_hwpx.egg-info → python_hwpx-5.2.0}/PKG-INFO +1 -1
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/pyproject.toml +3 -1
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/__init__.py +4 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/_document/shapes.py +55 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/document.py +47 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/equation/__init__.py +9 -1
- python_hwpx-5.2.0/src/hwpx/equation/authoring.py +457 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/equation/tokens.py +12 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/experimental.py +8 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/paragraph.py +86 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0/src/python_hwpx.egg-info}/PKG-INFO +1 -1
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/python_hwpx.egg-info/SOURCES.txt +2 -0
- python_hwpx-5.2.0/tests/test_equation_authoring.py +345 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_oxml_modularization.py +4 -3
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_product_boundary.py +1 -1
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_stable_surface.py +2 -2
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/LICENSE +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/README.md +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/setup.cfg +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/_document/__init__.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/_document/_units.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/_document/fields.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/_document/layout.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/_document/media.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/_document/memos.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/_document/persistence.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/_document/tracked.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/body_patch.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/data/Skeleton.hwpx +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/equation/eqedit.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/equation/mathml.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/equation/render.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/errors.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/form_fit/__init__.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/form_fit/apply.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/form_fit/engine.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/form_fit/measure.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/form_fit/policy.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/form_fit/report.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/ingest/__init__.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/ingest/base.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/ingest/hwpx_converter.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/layout/__init__.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/layout/lint.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/layout/report.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/mutation_report.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/opc/package.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/opc/relationships.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/opc/security.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/opc/xml_utils.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/__init__.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/_document_impl.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/_document_primitives.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/body.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/canonical_defaults.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/color.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/common.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/document.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/document_parts.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/header.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/header_part.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/memo.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/namespaces.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/numbering.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/objects.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/parser.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/run.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/schema.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/section.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/section_format.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/section_story.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/simple_parts.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/table.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/oxml/utils.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/package.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/patch.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/py.typed +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/quality/__init__.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/quality/ledger.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/quality/policy.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/quality/rendering.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/quality/report.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/quality/save_pipeline.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/table_patch.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/templates.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/__init__.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/_schemas/header.xsd +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/_schemas/section.xsd +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/archive_cli.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/doc_diff.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/document_viewer.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/exporter.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/generic_inventory.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/id_integrity.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/idempotence.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/ir_equality.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/layout_preview.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/mail_merge.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/markdown_export.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/object_finder.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/package_reconcile.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/package_validator.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/page_guard.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/read_fidelity.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/recover.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/redline.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/repair.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/report_utils.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/roundtrip_diff.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/table_cleanup.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/table_navigation.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/template_analyzer.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/text_extract_cli.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/text_extractor.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/toc_author.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/toc_fidelity.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/hwpx/tools/validator.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/python_hwpx.egg-info/dependency_links.txt +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/python_hwpx.egg-info/entry_points.txt +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/python_hwpx.egg-info/requires.txt +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/src/python_hwpx.egg-info/top_level.txt +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_add_control.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_adoption_metrics_snapshot.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_authoring_quality_corpus.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_blind_eval_fixture.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_byte_patch_identity.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_cell_line_spacing.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_comment_node_robustness.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_corpus_read_fidelity.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_coverage_promotion.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_coverage_targets.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_deviations_registry.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_doc_diff.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_document_context_manager.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_document_facade_surface.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_document_formatting.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_document_ops_runtime_boundary.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_document_save_api.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_document_viewer.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_documentation_code_fences.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_equation_converter.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_equation_render.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_error_messages.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_evalplan_core_primitives.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_existing_document_format_editing.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_form_field_authoring.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_form_fields.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_form_fill_core_primitives.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_form_fit.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_form_fit_integration.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_fuzz_catalog_derive_expected.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_fuzz_loop.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_fuzz_regressions.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_gap_closure_tools.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_golden_api.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_hp_tab_support.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_hwpxlib_corpus_read.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_id_generator_range.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_image_object_workflow.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_ingest.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_inline_models.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_integration_hwpx_compatibility.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_integration_roundtrip.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_layout_cache_scope.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_layout_lint.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_layout_preview.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_library_boundary_closure.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_llms_txt.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_mail_merge_xlsx.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_markdown_export.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_memo_and_style_editing.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_moved_surface_hints.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_mutation_report.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_namespace_handling.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_new_features.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_opc_package.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_open_safety_corpus.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_oxml_parsing.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_packaging_license_metadata.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_packaging_py_typed.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_paragraph_keep_together.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_paragraph_section_management.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_public_artifact_hygiene.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_public_runtime_boundary.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_read_fidelity.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_recover_broken_zip.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_redline_authoring.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_redline_verify.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_release_workflow_safety.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_repair_repack.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_report_utils.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_repr_snapshots.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_roundtrip_fidelity.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_row_heights.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_save_pipeline.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_section_headers.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_set_paragraph_format_keep.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_shape_geometry_contract.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_skeleton_template_ids.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_split_cell.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_split_merged_cell.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_table_cleanup.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_table_navigation.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_table_patch.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_table_patch_dryrun.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_table_patch_m105.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_tables_default_border.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_template_analyzer_enrichment.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_text_extractor_annotations.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_toc_author.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_toc_fidelity.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_validation_severity.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_validator_comment_nodes.py +0 -0
- {python_hwpx-5.1.1 → python_hwpx-5.2.0}/tests/test_version_metadata.py +0 -0
|
@@ -30,9 +30,15 @@ reimplemented from public references, without copying source code:
|
|
|
30
30
|
structure and roundtrip fixtures.
|
|
31
31
|
|
|
32
32
|
- OpenBapul/hml-equation-parser (Apache-2.0): HULK-style EqEdit <-> LaTeX token
|
|
33
|
-
mapping, referenced for reader-direction <hp:equation> script rendering
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
mapping, referenced for reader-direction <hp:equation> script rendering and
|
|
34
|
+
for the authoring-direction LaTeX -> EqEdit conversion. The token maps are a
|
|
35
|
+
clean-room re-derivation from the public EqEdit grammar; no source code was
|
|
36
|
+
copied, translated, or vendored.
|
|
37
|
+
- chrisryugj/kordoc (MIT): the authoring-direction approach for native
|
|
38
|
+
<hp:equation> generation — the XML shell contract, reserved-word quoting,
|
|
39
|
+
and untrusted-input guards (source-size and recursion-depth caps) were
|
|
40
|
+
referenced as behavior evidence. Clean-room Python implementation; no source
|
|
41
|
+
code was copied, translated, or vendored.
|
|
36
42
|
|
|
37
43
|
These form-fill and equation references were used as behavior evidence only. The
|
|
38
44
|
implementation is a clean-room Python implementation; no source code was
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-hwpx"
|
|
7
|
-
version = "5.
|
|
7
|
+
version = "5.2.0"
|
|
8
8
|
description = "한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 문서 라이브러리"
|
|
9
9
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -148,6 +148,7 @@ files = [
|
|
|
148
148
|
"src/hwpx/equation/__init__.py",
|
|
149
149
|
"src/hwpx/equation/tokens.py",
|
|
150
150
|
"src/hwpx/equation/eqedit.py",
|
|
151
|
+
"src/hwpx/equation/authoring.py",
|
|
151
152
|
"src/hwpx/equation/mathml.py",
|
|
152
153
|
"src/hwpx/equation/render.py",
|
|
153
154
|
"src/hwpx/tools/document_viewer.py",
|
|
@@ -202,6 +203,7 @@ include = [
|
|
|
202
203
|
"src/hwpx/equation/__init__.py",
|
|
203
204
|
"src/hwpx/equation/tokens.py",
|
|
204
205
|
"src/hwpx/equation/eqedit.py",
|
|
206
|
+
"src/hwpx/equation/authoring.py",
|
|
205
207
|
"src/hwpx/equation/mathml.py",
|
|
206
208
|
"src/hwpx/equation/render.py",
|
|
207
209
|
"src/hwpx/tools/document_viewer.py",
|
|
@@ -48,6 +48,10 @@ _EXPERIMENTAL_EXPORTS = {
|
|
|
48
48
|
# 문서 프리뷰 뷰어(3.8.0 신규). 계약 유동.
|
|
49
49
|
"DocumentViewer": "hwpx.tools.document_viewer",
|
|
50
50
|
"render_document_viewer": "hwpx.tools.document_viewer",
|
|
51
|
+
# 수식 저작(5.2.0 신규, LaTeX -> EqEdit). 계약 유동.
|
|
52
|
+
"UnsupportedLatexError": "hwpx.equation.authoring",
|
|
53
|
+
"estimate_equation_size": "hwpx.equation.authoring",
|
|
54
|
+
"latex_to_eqedit": "hwpx.equation.authoring",
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
# Emptied in 5.0. The 4.x notice on these names said they would go in the next
|
|
@@ -227,3 +227,58 @@ def add_ellipse(
|
|
|
227
227
|
line_color=line_color, line_width=line_width,
|
|
228
228
|
fill_color=fill_color, treat_as_char=treat_as_char,
|
|
229
229
|
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def add_equation(
|
|
233
|
+
doc: "HwpxDocument",
|
|
234
|
+
script: str,
|
|
235
|
+
*,
|
|
236
|
+
paragraph: HwpxOxmlParagraph | None = None,
|
|
237
|
+
section: HwpxOxmlSection | None = None,
|
|
238
|
+
section_index: int | None = None,
|
|
239
|
+
base_unit: int = 1100,
|
|
240
|
+
size: tuple[int, int] | None = None,
|
|
241
|
+
char_pr_id_ref: str | int | None = None,
|
|
242
|
+
) -> HwpxOxmlInlineObject:
|
|
243
|
+
"""Insert an inline equation and prove the standard reader recognizes it.
|
|
244
|
+
|
|
245
|
+
The emitted XML follows the real-Hancom ``<hp:equation>`` contract
|
|
246
|
+
(specs/054-equation-authoring/evidence/p0/equation-contract.md). After
|
|
247
|
+
insertion the equation is re-read through the standard section scan —
|
|
248
|
+
creation fails loudly if the script did not land verbatim (no
|
|
249
|
+
special-casing by design).
|
|
250
|
+
"""
|
|
251
|
+
from ..equation.authoring import estimate_equation_size
|
|
252
|
+
from ..equation.eqedit import MAX_SOURCE_LENGTH
|
|
253
|
+
from ..oxml.namespaces import HP
|
|
254
|
+
|
|
255
|
+
text = (script or "").strip()
|
|
256
|
+
if not text:
|
|
257
|
+
raise ValueError("equation script must be a non-empty string")
|
|
258
|
+
if len(text) > MAX_SOURCE_LENGTH:
|
|
259
|
+
raise ValueError("equation script exceeds size limit")
|
|
260
|
+
if paragraph is None:
|
|
261
|
+
paragraph = doc.add_paragraph(
|
|
262
|
+
"", section=section, section_index=section_index,
|
|
263
|
+
include_run=False,
|
|
264
|
+
)
|
|
265
|
+
if size is None:
|
|
266
|
+
size = estimate_equation_size(text, base_unit=base_unit)
|
|
267
|
+
inline_object = paragraph.add_equation(
|
|
268
|
+
text, base_unit=base_unit, size=size, char_pr_id_ref=char_pr_id_ref,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
created_id = inline_object.element.get("id", "")
|
|
272
|
+
for owning_section in doc.sections:
|
|
273
|
+
for candidate in owning_section.element.iter(f"{HP}equation"):
|
|
274
|
+
if candidate.get("id") != created_id:
|
|
275
|
+
continue
|
|
276
|
+
script_element = candidate.find(f"{HP}script")
|
|
277
|
+
if script_element is None or (script_element.text or "") != text:
|
|
278
|
+
raise RuntimeError(
|
|
279
|
+
"created equation did not store its script verbatim"
|
|
280
|
+
)
|
|
281
|
+
return inline_object
|
|
282
|
+
raise RuntimeError(
|
|
283
|
+
"created equation was not recognized by the standard section scan"
|
|
284
|
+
)
|
|
@@ -1431,6 +1431,53 @@ class HwpxDocument:
|
|
|
1431
1431
|
)
|
|
1432
1432
|
|
|
1433
1433
|
|
|
1434
|
+
def add_equation(
|
|
1435
|
+
self,
|
|
1436
|
+
script: str,
|
|
1437
|
+
*,
|
|
1438
|
+
paragraph: HwpxOxmlParagraph | None = None,
|
|
1439
|
+
section: HwpxOxmlSection | None = None,
|
|
1440
|
+
section_index: int | None = None,
|
|
1441
|
+
base_unit: int = 1100,
|
|
1442
|
+
size: tuple[int, int] | None = None,
|
|
1443
|
+
char_pr_id_ref: str | int | None = None,
|
|
1444
|
+
) -> HwpxOxmlInlineObject:
|
|
1445
|
+
"""Insert an inline equation from an EqEdit script. **Experimental contract.**
|
|
1446
|
+
|
|
1447
|
+
Emits the real-Hancom ``<hp:equation>`` shape (contract:
|
|
1448
|
+
``specs/054-equation-authoring/evidence/p0/equation-contract.md``): the
|
|
1449
|
+
EqEdit source is stored verbatim in ``<hp:script>``, no layout cache is
|
|
1450
|
+
written (Hancom re-lays-out on open), and the shape is inline so it
|
|
1451
|
+
renders in the page flow. The created element is immediately re-read
|
|
1452
|
+
through the standard section scan — creation fails loudly if the
|
|
1453
|
+
standard consumer would not see it (no special-casing by design).
|
|
1454
|
+
|
|
1455
|
+
To author from LaTeX, convert first (typed refusal outside the
|
|
1456
|
+
verified token set)::
|
|
1457
|
+
|
|
1458
|
+
from hwpx.equation import latex_to_eqedit
|
|
1459
|
+
doc.add_equation(latex_to_eqedit(r"\\frac{a}{b}"))
|
|
1460
|
+
|
|
1461
|
+
Args:
|
|
1462
|
+
script: EqEdit script stored as-is (e.g. ``{a} over {b}``).
|
|
1463
|
+
paragraph: Target paragraph (e.g. inside a table cell). When
|
|
1464
|
+
omitted a new paragraph is appended to *section*.
|
|
1465
|
+
base_unit: Equation base font size in 1/100 pt.
|
|
1466
|
+
size: Optional explicit ``(width, height)`` HWPUNIT pair;
|
|
1467
|
+
defaults to a proportional estimate (Hancom re-measures).
|
|
1468
|
+
"""
|
|
1469
|
+
|
|
1470
|
+
return _shapes.add_equation(
|
|
1471
|
+
self,
|
|
1472
|
+
script,
|
|
1473
|
+
paragraph=paragraph,
|
|
1474
|
+
section=section,
|
|
1475
|
+
section_index=section_index,
|
|
1476
|
+
base_unit=base_unit,
|
|
1477
|
+
size=size,
|
|
1478
|
+
char_pr_id_ref=char_pr_id_ref,
|
|
1479
|
+
)
|
|
1480
|
+
|
|
1434
1481
|
def set_page_size(
|
|
1435
1482
|
self,
|
|
1436
1483
|
*,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
"""
|
|
2
|
+
"""Equation support: EqEdit → LaTeX → MathML (reader) and LaTeX → EqEdit (authoring).
|
|
3
3
|
|
|
4
4
|
Clean-room re-derivation of the HULK-style EqEdit vocabulary; see NOTICE for the
|
|
5
5
|
referenced projects. ``latex2mathml`` is an optional dependency
|
|
@@ -9,6 +9,11 @@ block rather than dropping the equation.
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
from .authoring import (
|
|
13
|
+
UnsupportedLatexError,
|
|
14
|
+
estimate_equation_size,
|
|
15
|
+
latex_to_eqedit,
|
|
16
|
+
)
|
|
12
17
|
from .eqedit import (
|
|
13
18
|
MAX_GROUP_DEPTH,
|
|
14
19
|
MAX_SOURCE_LENGTH,
|
|
@@ -39,8 +44,11 @@ __all__ = [
|
|
|
39
44
|
"EquationConversionError",
|
|
40
45
|
"EquationRender",
|
|
41
46
|
"MathMLUnavailableError",
|
|
47
|
+
"UnsupportedLatexError",
|
|
42
48
|
"eqedit_to_latex",
|
|
49
|
+
"estimate_equation_size",
|
|
43
50
|
"latex2mathml_available",
|
|
51
|
+
"latex_to_eqedit",
|
|
44
52
|
"latex_to_mathml",
|
|
45
53
|
"render_equation",
|
|
46
54
|
]
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Authoring-direction LaTeX → EqEdit converter (clean-room). **Experimental.**
|
|
3
|
+
|
|
4
|
+
Reverse of :func:`hwpx.equation.eqedit.eqedit_to_latex`: turns a LaTeX math
|
|
5
|
+
fragment into the EqEdit script Hancom's equation editor stores inside
|
|
6
|
+
``<hp:script>``. Coverage is the verified token set only — any LaTeX command
|
|
7
|
+
or environment outside it raises :class:`UnsupportedLatexError` (typed
|
|
8
|
+
refusal), never a silent approximation, so callers can fail closed.
|
|
9
|
+
|
|
10
|
+
The token vocabulary is the same clean-room re-derivation used by the reader
|
|
11
|
+
(:mod:`hwpx.equation.tokens`); the authoring direction additionally protects
|
|
12
|
+
bare identifiers that collide with EqEdit reserved words by quoting them
|
|
13
|
+
(``T_{int}`` → ``T _{"int"}``) so Hancom does not typeset them as symbols.
|
|
14
|
+
|
|
15
|
+
Contract provenance: specs/054-equation-authoring/evidence/p0/equation-contract.md.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from .eqedit import (
|
|
21
|
+
MAX_GROUP_DEPTH,
|
|
22
|
+
MAX_SOURCE_LENGTH,
|
|
23
|
+
EquationConversionError,
|
|
24
|
+
)
|
|
25
|
+
from .tokens import (
|
|
26
|
+
ACCENTS,
|
|
27
|
+
BIG_OPERATORS,
|
|
28
|
+
DELIMITERS,
|
|
29
|
+
FUNCTIONS,
|
|
30
|
+
GREEK,
|
|
31
|
+
MATRIX_ENVIRONMENTS,
|
|
32
|
+
OPERATORS,
|
|
33
|
+
STRUCTURAL,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UnsupportedLatexError(EquationConversionError):
|
|
38
|
+
"""Raised when a LaTeX fragment uses commands outside the verified set."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _invert(*maps: dict[str, str]) -> dict[str, str]:
|
|
42
|
+
"""LaTeX command → canonical EqEdit token (first-seen wins per map order)."""
|
|
43
|
+
|
|
44
|
+
inverse: dict[str, str] = {}
|
|
45
|
+
for mapping in maps:
|
|
46
|
+
for eqedit_token, latex_command in mapping.items():
|
|
47
|
+
inverse.setdefault(latex_command, eqedit_token)
|
|
48
|
+
return inverse
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Commands whose only EqEdit spellings render as literal text (or wrong glyphs)
|
|
52
|
+
# on the real Hancom build — render-verified 2026-07-31, specs/054 P2 token
|
|
53
|
+
# battery. Emitting them would silently corrupt output, so they are excluded
|
|
54
|
+
# from the inverse maps and refuse with UnsupportedLatexError instead.
|
|
55
|
+
_RENDER_REJECTED = frozenset(
|
|
56
|
+
{"\\limsup", "\\liminf", "\\widehat", "\\widetilde"}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Word-token maps first so lowercase word forms win (``times`` over ``TIMES``);
|
|
60
|
+
# explicit preferences below override where the render oracle verified a
|
|
61
|
+
# different spelling than the reader's first-seen entry.
|
|
62
|
+
_LATEX_TO_EQEDIT: dict[str, str] = {
|
|
63
|
+
latex: eqedit
|
|
64
|
+
for latex, eqedit in _invert(GREEK, OPERATORS, FUNCTIONS, BIG_OPERATORS).items()
|
|
65
|
+
if latex not in _RENDER_REJECTED
|
|
66
|
+
}
|
|
67
|
+
_LATEX_TO_EQEDIT.update(
|
|
68
|
+
{
|
|
69
|
+
# Gold scripts spell these symbolically (equation-contract.md §4).
|
|
70
|
+
"\\pm": "+-",
|
|
71
|
+
"\\mp": "-+",
|
|
72
|
+
# Render-verified spellings (P2 token battery): the word ``to`` kills
|
|
73
|
+
# the rest of the equation, ``leftrightarrow`` draws a plain arrow,
|
|
74
|
+
# lowercase ``forall`` and ``iint``/``iiint`` come out as literal text.
|
|
75
|
+
"\\to": "->",
|
|
76
|
+
"\\rightarrow": "->",
|
|
77
|
+
"\\leftrightarrow": "<->",
|
|
78
|
+
"\\forall": "FORALL",
|
|
79
|
+
"\\iint": "dint",
|
|
80
|
+
"\\iiint": "tint",
|
|
81
|
+
# Common LaTeX aliases sharing a verified target.
|
|
82
|
+
"\\le": "leq",
|
|
83
|
+
"\\ge": "geq",
|
|
84
|
+
"\\ne": "neq",
|
|
85
|
+
"\\dots": "cdots",
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
_LATEX_ACCENTS: dict[str, str] = {
|
|
89
|
+
latex: eqedit
|
|
90
|
+
for latex, eqedit in _invert(ACCENTS).items()
|
|
91
|
+
if latex not in _RENDER_REJECTED
|
|
92
|
+
}
|
|
93
|
+
# LaTeX delimiter commands usable after \left / \right.
|
|
94
|
+
_LATEX_DELIMITERS: dict[str, str] = {
|
|
95
|
+
latex: eqedit
|
|
96
|
+
for eqedit, latex in DELIMITERS.items()
|
|
97
|
+
if latex.startswith("\\")
|
|
98
|
+
# Prefer the symbol spellings (``{`` not ``LBRACE``) — both read back.
|
|
99
|
+
and eqedit not in {"LBRACE", "RBRACE", "LANGLE", "RANGLE"}
|
|
100
|
+
}
|
|
101
|
+
_TEXT_COMMANDS = frozenset({"\\text", "\\mathrm", "\\textrm", "\\mbox"})
|
|
102
|
+
# LaTeX environment name → render-verified EqEdit builder word. ``Bmatrix`` /
|
|
103
|
+
# ``Vmatrix`` have no verified spelling on the real build (P2 battery R10/R11)
|
|
104
|
+
# and refuse; the determinant matrix is EqEdit ``dmatrix`` (R12).
|
|
105
|
+
_ENV_TO_EQEDIT: dict[str, str] = {
|
|
106
|
+
"matrix": "matrix",
|
|
107
|
+
"pmatrix": "pmatrix",
|
|
108
|
+
"bmatrix": "bmatrix",
|
|
109
|
+
"vmatrix": "dmatrix",
|
|
110
|
+
"cases": "cases",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# Bare identifier runs that would collide with EqEdit vocabulary must be quoted
|
|
114
|
+
# so Hancom keeps them literal (reserved-word protection).
|
|
115
|
+
_RESERVED_WORDS = (
|
|
116
|
+
frozenset(GREEK)
|
|
117
|
+
| frozenset(OPERATORS)
|
|
118
|
+
| frozenset(FUNCTIONS)
|
|
119
|
+
| frozenset(BIG_OPERATORS)
|
|
120
|
+
| frozenset(ACCENTS)
|
|
121
|
+
| frozenset(MATRIX_ENVIRONMENTS)
|
|
122
|
+
| STRUCTURAL
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
_SINGLE_CHAR_PASSTHROUGH = frozenset("+-=<>,.;:!|/()[]'")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class _LatexLexer:
|
|
129
|
+
def __init__(self, source: str) -> None:
|
|
130
|
+
self._source = source
|
|
131
|
+
self._pos = 0
|
|
132
|
+
self.tokens: list[str] = []
|
|
133
|
+
self._lex()
|
|
134
|
+
|
|
135
|
+
def _lex(self) -> None:
|
|
136
|
+
source = self._source
|
|
137
|
+
i = 0
|
|
138
|
+
n = len(source)
|
|
139
|
+
while i < n:
|
|
140
|
+
ch = source[i]
|
|
141
|
+
if ch.isspace():
|
|
142
|
+
i += 1
|
|
143
|
+
elif ch == "\\":
|
|
144
|
+
i = self._lex_command(i)
|
|
145
|
+
elif ch in "{}^_&":
|
|
146
|
+
self.tokens.append(ch)
|
|
147
|
+
i += 1
|
|
148
|
+
elif ch.isdigit() or ch == ".":
|
|
149
|
+
j = i
|
|
150
|
+
while j < n and (source[j].isdigit() or source[j] == "."):
|
|
151
|
+
j += 1
|
|
152
|
+
self.tokens.append(source[i:j])
|
|
153
|
+
i = j
|
|
154
|
+
elif ch.isalpha():
|
|
155
|
+
j = i
|
|
156
|
+
while j < n and source[j].isalpha():
|
|
157
|
+
j += 1
|
|
158
|
+
self.tokens.append(source[i:j])
|
|
159
|
+
i = j
|
|
160
|
+
elif ch in _SINGLE_CHAR_PASSTHROUGH:
|
|
161
|
+
self.tokens.append(ch)
|
|
162
|
+
i += 1
|
|
163
|
+
else:
|
|
164
|
+
raise UnsupportedLatexError(
|
|
165
|
+
f"unsupported character in LaTeX input: {ch!r}"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def _lex_command(self, i: int) -> int:
|
|
169
|
+
source = self._source
|
|
170
|
+
n = len(source)
|
|
171
|
+
if i + 1 >= n:
|
|
172
|
+
raise UnsupportedLatexError("dangling backslash at end of input")
|
|
173
|
+
nxt = source[i + 1]
|
|
174
|
+
if nxt == "\\":
|
|
175
|
+
self.tokens.append("\\\\")
|
|
176
|
+
return i + 2
|
|
177
|
+
if not nxt.isalpha():
|
|
178
|
+
# Escaped single character: \{ \} \% \& \$ \| \, ...
|
|
179
|
+
self.tokens.append("\\" + nxt)
|
|
180
|
+
return i + 2
|
|
181
|
+
j = i + 1
|
|
182
|
+
while j < n and source[j].isalpha():
|
|
183
|
+
j += 1
|
|
184
|
+
self.tokens.append(source[i:j])
|
|
185
|
+
return j
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class _LatexParser:
|
|
189
|
+
"""Recursive-descent LaTeX → EqEdit token emitter (verified set only)."""
|
|
190
|
+
|
|
191
|
+
def __init__(self, tokens: list[str]) -> None:
|
|
192
|
+
self._tokens = tokens
|
|
193
|
+
self._pos = 0
|
|
194
|
+
|
|
195
|
+
def _peek(self) -> str | None:
|
|
196
|
+
return self._tokens[self._pos] if self._pos < len(self._tokens) else None
|
|
197
|
+
|
|
198
|
+
def _next(self) -> str | None:
|
|
199
|
+
token = self._peek()
|
|
200
|
+
if token is not None:
|
|
201
|
+
self._pos += 1
|
|
202
|
+
return token
|
|
203
|
+
|
|
204
|
+
def _expect(self, expected: str) -> None:
|
|
205
|
+
token = self._next()
|
|
206
|
+
if token != expected:
|
|
207
|
+
raise UnsupportedLatexError(
|
|
208
|
+
f"expected {expected!r}, found {token!r} — unbalanced LaTeX group"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# -- grammar -------------------------------------------------------------
|
|
212
|
+
def parse(self) -> str:
|
|
213
|
+
parts = self._sequence(depth=0, stop=frozenset())
|
|
214
|
+
if self._peek() is not None:
|
|
215
|
+
raise UnsupportedLatexError(
|
|
216
|
+
f"unbalanced LaTeX group near {self._peek()!r}"
|
|
217
|
+
)
|
|
218
|
+
return " ".join(parts)
|
|
219
|
+
|
|
220
|
+
def _sequence(self, *, depth: int, stop: frozenset[str]) -> list[str]:
|
|
221
|
+
if depth > MAX_GROUP_DEPTH:
|
|
222
|
+
raise EquationConversionError("equation nesting depth exceeded")
|
|
223
|
+
parts: list[str] = []
|
|
224
|
+
while True:
|
|
225
|
+
token = self._peek()
|
|
226
|
+
if token is None or token in stop:
|
|
227
|
+
break
|
|
228
|
+
if token in ("^", "_"):
|
|
229
|
+
self._next()
|
|
230
|
+
script = self._group_or_atom(depth)
|
|
231
|
+
parts.append(f"{token}{{{script}}}")
|
|
232
|
+
continue
|
|
233
|
+
parts.append(self._atom(depth))
|
|
234
|
+
return parts
|
|
235
|
+
|
|
236
|
+
def _group_or_atom(self, depth: int) -> str:
|
|
237
|
+
"""A script/argument body, brace-stripped (rebraced by the caller)."""
|
|
238
|
+
|
|
239
|
+
if self._peek() == "{":
|
|
240
|
+
self._next()
|
|
241
|
+
inner = self._sequence(depth=depth + 1, stop=frozenset({"}"}))
|
|
242
|
+
self._expect("}")
|
|
243
|
+
return " ".join(inner)
|
|
244
|
+
return self._atom(depth)
|
|
245
|
+
|
|
246
|
+
def _atom(self, depth: int) -> str:
|
|
247
|
+
token = self._next()
|
|
248
|
+
if token is None:
|
|
249
|
+
return ""
|
|
250
|
+
if token == "{":
|
|
251
|
+
inner = self._sequence(depth=depth + 1, stop=frozenset({"}"}))
|
|
252
|
+
self._expect("}")
|
|
253
|
+
return "{" + " ".join(inner) + "}"
|
|
254
|
+
if token == "}":
|
|
255
|
+
raise UnsupportedLatexError("unbalanced closing brace in LaTeX input")
|
|
256
|
+
if token == "\\frac" or token == "\\dfrac" or token == "\\tfrac":
|
|
257
|
+
numerator = self._group_or_atom(depth)
|
|
258
|
+
denominator = self._group_or_atom(depth)
|
|
259
|
+
return f"{{{numerator}}} over {{{denominator}}}"
|
|
260
|
+
if token == "\\sqrt":
|
|
261
|
+
if self._peek() == "[":
|
|
262
|
+
self._next()
|
|
263
|
+
index_parts = self._sequence(depth=depth + 1, stop=frozenset({"]"}))
|
|
264
|
+
self._expect("]")
|
|
265
|
+
radicand = self._group_or_atom(depth)
|
|
266
|
+
return f"root {{{' '.join(index_parts)}}} of {{{radicand}}}"
|
|
267
|
+
return f"sqrt {{{self._group_or_atom(depth)}}}"
|
|
268
|
+
if token in _TEXT_COMMANDS:
|
|
269
|
+
return self._text_literal()
|
|
270
|
+
if token == "\\begin":
|
|
271
|
+
return self._environment(depth)
|
|
272
|
+
if token == "\\left":
|
|
273
|
+
return self._left_right(depth)
|
|
274
|
+
if token in ("\\right", "\\end"):
|
|
275
|
+
raise UnsupportedLatexError(f"{token} without a matching opener")
|
|
276
|
+
if token in _LATEX_ACCENTS:
|
|
277
|
+
return f"{_LATEX_ACCENTS[token]} {{{self._group_or_atom(depth)}}}"
|
|
278
|
+
if token in _LATEX_TO_EQEDIT:
|
|
279
|
+
return _LATEX_TO_EQEDIT[token]
|
|
280
|
+
if token == "\\\\":
|
|
281
|
+
raise UnsupportedLatexError(
|
|
282
|
+
"row break (\\\\) is only supported inside a matrix/cases environment"
|
|
283
|
+
)
|
|
284
|
+
if token.startswith("\\") and len(token) == 2 and not token[1].isalpha():
|
|
285
|
+
return self._escaped_char(token[1])
|
|
286
|
+
if token.startswith("\\"):
|
|
287
|
+
raise UnsupportedLatexError(f"unsupported LaTeX command: {token}")
|
|
288
|
+
if token == "&":
|
|
289
|
+
raise UnsupportedLatexError(
|
|
290
|
+
"alignment (&) is only supported inside a matrix/cases environment"
|
|
291
|
+
)
|
|
292
|
+
return self._plain_token(token)
|
|
293
|
+
|
|
294
|
+
def _escaped_char(self, char: str) -> str:
|
|
295
|
+
if char in "{}":
|
|
296
|
+
# EqEdit spells literal braces as the LBRACE/RBRACE words.
|
|
297
|
+
return "LBRACE" if char == "{" else "RBRACE"
|
|
298
|
+
if char in "%$&":
|
|
299
|
+
return char
|
|
300
|
+
raise UnsupportedLatexError(f"unsupported LaTeX escape: \\{char}")
|
|
301
|
+
|
|
302
|
+
def _plain_token(self, token: str) -> str:
|
|
303
|
+
if token.isalpha() and token in _RESERVED_WORDS:
|
|
304
|
+
# Reserved-word protection: keep the identifier literal in Hancom.
|
|
305
|
+
return f'"{token}"'
|
|
306
|
+
return token
|
|
307
|
+
|
|
308
|
+
def _text_literal(self) -> str:
|
|
309
|
+
self._expect("{")
|
|
310
|
+
parts: list[str] = []
|
|
311
|
+
while True:
|
|
312
|
+
token = self._peek()
|
|
313
|
+
if token is None:
|
|
314
|
+
raise UnsupportedLatexError("unterminated \\text{...} literal")
|
|
315
|
+
if token == "}":
|
|
316
|
+
self._next()
|
|
317
|
+
break
|
|
318
|
+
if token in ("{", "\\\\") or (
|
|
319
|
+
isinstance(token, str) and token.startswith("\\") and len(token) > 2
|
|
320
|
+
):
|
|
321
|
+
raise UnsupportedLatexError(
|
|
322
|
+
"\\text{...} supports plain characters only"
|
|
323
|
+
)
|
|
324
|
+
self._next()
|
|
325
|
+
parts.append(token[1] if token.startswith("\\") else token)
|
|
326
|
+
literal = " ".join(parts)
|
|
327
|
+
if '"' in literal:
|
|
328
|
+
raise UnsupportedLatexError('\\text{...} may not contain a quote (")')
|
|
329
|
+
return f'"{literal}"'
|
|
330
|
+
|
|
331
|
+
def _environment(self, depth: int) -> str:
|
|
332
|
+
self._expect("{")
|
|
333
|
+
name = self._next()
|
|
334
|
+
if name == "}":
|
|
335
|
+
raise UnsupportedLatexError("empty \\begin{} environment name")
|
|
336
|
+
self._expect("}")
|
|
337
|
+
builder = _ENV_TO_EQEDIT.get(name or "")
|
|
338
|
+
if builder is None:
|
|
339
|
+
raise UnsupportedLatexError(f"unsupported LaTeX environment: {name}")
|
|
340
|
+
rows: list[list[str]] = [[]]
|
|
341
|
+
current: list[str] = []
|
|
342
|
+
|
|
343
|
+
def flush_cell() -> None:
|
|
344
|
+
rows[-1].append(" ".join(current))
|
|
345
|
+
current.clear()
|
|
346
|
+
|
|
347
|
+
while True:
|
|
348
|
+
token = self._peek()
|
|
349
|
+
if token is None:
|
|
350
|
+
raise UnsupportedLatexError(f"unterminated environment: {name}")
|
|
351
|
+
if token == "\\end":
|
|
352
|
+
self._next()
|
|
353
|
+
self._expect("{")
|
|
354
|
+
end_name = self._next()
|
|
355
|
+
self._expect("}")
|
|
356
|
+
if end_name != name:
|
|
357
|
+
raise UnsupportedLatexError(
|
|
358
|
+
f"environment mismatch: \\begin{{{name}}} closed by "
|
|
359
|
+
f"\\end{{{end_name}}}"
|
|
360
|
+
)
|
|
361
|
+
break
|
|
362
|
+
if token == "&":
|
|
363
|
+
self._next()
|
|
364
|
+
flush_cell()
|
|
365
|
+
continue
|
|
366
|
+
if token == "\\\\":
|
|
367
|
+
self._next()
|
|
368
|
+
flush_cell()
|
|
369
|
+
rows.append([])
|
|
370
|
+
continue
|
|
371
|
+
if token in ("^", "_"):
|
|
372
|
+
self._next()
|
|
373
|
+
current.append(f"{token}{{{self._group_or_atom(depth)}}}")
|
|
374
|
+
continue
|
|
375
|
+
current.append(self._atom(depth))
|
|
376
|
+
flush_cell()
|
|
377
|
+
body = " # ".join(
|
|
378
|
+
" & ".join(cell for cell in row) for row in rows if any(row)
|
|
379
|
+
)
|
|
380
|
+
return f"{builder} {{{body}}}"
|
|
381
|
+
|
|
382
|
+
def _left_right(self, depth: int) -> str:
|
|
383
|
+
open_token = self._next()
|
|
384
|
+
open_delim = self._delimiter(open_token)
|
|
385
|
+
body = self._sequence(depth=depth + 1, stop=frozenset({"\\right"}))
|
|
386
|
+
if self._peek() != "\\right":
|
|
387
|
+
raise UnsupportedLatexError("\\left without a matching \\right")
|
|
388
|
+
self._next()
|
|
389
|
+
close_token = self._next()
|
|
390
|
+
close_delim = self._delimiter(close_token)
|
|
391
|
+
inner = " ".join(body)
|
|
392
|
+
return f"LEFT {open_delim} {inner} RIGHT {close_delim}"
|
|
393
|
+
|
|
394
|
+
def _delimiter(self, token: str | None) -> str:
|
|
395
|
+
if token is None:
|
|
396
|
+
raise UnsupportedLatexError("missing \\left/\\right delimiter")
|
|
397
|
+
if token in _LATEX_DELIMITERS:
|
|
398
|
+
return _LATEX_DELIMITERS[token]
|
|
399
|
+
if token in ("\\{", "\\}"):
|
|
400
|
+
return "LBRACE" if token == "\\{" else "RBRACE"
|
|
401
|
+
if token in DELIMITERS and len(token) == 1:
|
|
402
|
+
return token
|
|
403
|
+
raise UnsupportedLatexError(f"unsupported \\left/\\right delimiter: {token}")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _strip_math_delimiters(latex: str) -> str:
|
|
407
|
+
text = latex.strip()
|
|
408
|
+
for fence in ("$$", "$"):
|
|
409
|
+
if text.startswith(fence) and text.endswith(fence) and len(text) > 2 * len(fence):
|
|
410
|
+
return text[len(fence) : -len(fence)].strip()
|
|
411
|
+
return text
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def latex_to_eqedit(latex: str) -> str:
|
|
415
|
+
"""Convert a LaTeX math fragment to an EqEdit ``<hp:script>`` string.
|
|
416
|
+
|
|
417
|
+
Surrounding ``$...$`` / ``$$...$$`` fences are stripped for convenience.
|
|
418
|
+
Anything outside the verified token set raises
|
|
419
|
+
:class:`UnsupportedLatexError` — no silent approximation.
|
|
420
|
+
|
|
421
|
+
Raises:
|
|
422
|
+
UnsupportedLatexError: unsupported command/environment/character.
|
|
423
|
+
EquationConversionError: size or nesting-depth guard exceeded.
|
|
424
|
+
"""
|
|
425
|
+
|
|
426
|
+
if len(latex) > MAX_SOURCE_LENGTH:
|
|
427
|
+
raise EquationConversionError("LaTeX input exceeds size limit")
|
|
428
|
+
text = _strip_math_delimiters(latex)
|
|
429
|
+
if "$" in text:
|
|
430
|
+
raise UnsupportedLatexError("interior $ math delimiters are not supported")
|
|
431
|
+
if not text:
|
|
432
|
+
raise UnsupportedLatexError("empty LaTeX input")
|
|
433
|
+
tokens = _LatexLexer(text).tokens
|
|
434
|
+
return _LatexParser(tokens).parse()
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def estimate_equation_size(script: str, *, base_unit: int = 1100) -> tuple[int, int]:
|
|
438
|
+
"""Heuristic ``(width, height)`` in HWPUNIT for ``<hp:sz>``.
|
|
439
|
+
|
|
440
|
+
Hancom re-measures the shape when the document is opened (P0 evidence:
|
|
441
|
+
a fixed size rendered correctly), so this only needs to be a sane
|
|
442
|
+
placeholder, mirroring the gold documents' proportions.
|
|
443
|
+
"""
|
|
444
|
+
|
|
445
|
+
visible = len(script.replace("{", "").replace("}", "").replace(" ", ""))
|
|
446
|
+
rows = 1 + script.count("#")
|
|
447
|
+
tall = any(word in script for word in ("over", "sqrt", "int", "sum", "prod", "lim"))
|
|
448
|
+
width = int(base_unit * 0.45 * max(6, visible))
|
|
449
|
+
height = int(base_unit * (2.5 if tall else 1.6) * max(1, rows))
|
|
450
|
+
return width, height
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
__all__ = [
|
|
454
|
+
"UnsupportedLatexError",
|
|
455
|
+
"estimate_equation_size",
|
|
456
|
+
"latex_to_eqedit",
|
|
457
|
+
]
|