doc-control 0.3.0__tar.gz → 0.4.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.
- {doc_control-0.3.0 → doc_control-0.4.0}/PKG-INFO +4 -2
- {doc_control-0.3.0 → doc_control-0.4.0}/README.md +3 -1
- {doc_control-0.3.0 → doc_control-0.4.0}/pyproject.toml +1 -1
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/__init__.py +1 -1
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/errors.py +4 -1
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/document.py +8 -1
- doc_control-0.4.0/src/doc_control/ooxml/plaintext.py +301 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ooxml/zipio.py +19 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/compare.py +6 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/find.py +6 -1
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/locate.py +170 -12
- doc_control-0.4.0/src/doc_control/recipes/bundled/empty-headings.json +28 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/registry.py +4 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/safety/store.py +4 -1
- doc_control-0.4.0/tests/ooxml/test_plaintext.py +241 -0
- doc_control-0.4.0/tests/ops/test_locate_lazy.py +81 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/test_context.py +6 -1
- doc_control-0.3.0/src/doc_control/recipes/bundled/empty-headings.json +0 -17
- {doc_control-0.3.0 → doc_control-0.4.0}/.gitignore +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/LICENSE +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/__main__.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/api.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/cli.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/envelope.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/inprocess.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/mcp.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/__init__.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/address.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/align.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/anchor.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/annotations.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/format.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/index.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/layout.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/normalized.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/pages.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/parts.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/search.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/segment.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/structure.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/styles.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ooxml/__init__.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ooxml/commentparts.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ooxml/formatting.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ooxml/resolve.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ooxml/revisions.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ooxml/splice.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ooxml/styledef.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/__init__.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/anchor.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/batch.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/bulk.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/comments.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/diff.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/dotx.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/history.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/layout.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/meta.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/page.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/paging.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/read.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/recipe.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/rename.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/revision.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/segment.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/styleset.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/stylewrite.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/text.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/tracked.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/py.typed +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/blank-paragraphs.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/chapter-heading-period.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/chapter-heading-style.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/collapse-spaces.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/dashes-em.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/dashes-spaced-en.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/ellipsis-char.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/empty-paragraph-runs.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-aside-dash.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-dash-close-pair-space.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-dash-close-space.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-dash-first-aside.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-dash-open-space.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-dialogue-dash.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-quotes-close.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-quotes-open.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-space-after-opener.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-space-after-punctuation.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-space-before-punctuation.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/fake-centring.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/fake-tabs.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/leading-spaces.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/leftover-markers.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/possible-chapter-break.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/quotes-de.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/quotes-en-single.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/quotes-en.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/quotes-fr.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/scene-break.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/tab-indent.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/trim-trailing-spaces.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/typographic-apostrophe.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/engine.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/schema.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/store.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/reference.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/safety/__init__.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/safety/history.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/safety/lock.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/safety/verify.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/schema.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/__init__.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/bench/__init__.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/bench/test_cold_read.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/bench/test_perf_gate.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ci/__init__.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ci/test_hygiene.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/conftest.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/corpus/__init__.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/corpus/generator.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/corpus/private.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/corpus/structural.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/corpus/test_generator.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/corpus/test_validator.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/corpus/validator.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/helpers.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/__init__.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/data/segment_gold.txt +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_address.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_anchor.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_anchor_exact.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_anchor_normalised.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_index.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_normalized.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_pages_rendered.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_segment.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_structure.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_structure_parts.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_structure_plain.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_structure_scene_styles.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ooxml/__init__.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ooxml/docgen.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ooxml/test_splice.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ooxml/test_zipio.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/__init__.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/bigdoc.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/revsim.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_batch.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_comments.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_compare.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_diff.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_dotx.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_find.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_find_hardening.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_format.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_layout.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_locate.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_page.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_page_facts.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_read.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_rename.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_revisions.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_segment_parts.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_segment_plain.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_segment_ranges.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_segment_scene_styles.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_stylesets.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_tracked_markup.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_tracked_text.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/__init__.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/docx_fixer_cases.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/house/recipes/house-author-notes.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/house/recipes/house-note-lines.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/house/recipes/house-scene-separator.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/house/stylesets/house-novel.json +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/recipe_harness.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/test_common_recipes.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/test_engine.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/test_es_typography.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/test_inventory.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/test_user_expressibility.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/safety/__init__.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/safety/test_history.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/safety/test_lock.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/safety/test_store.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/safety/test_verify.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/safety/test_writer_safety.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/scenarios/__init__.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/scenarios/test_writer_notes.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/test_api.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/test_api_polish.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/test_docs.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/test_mcp.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/test_package.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/test_registry.py +0 -0
- {doc_control-0.3.0 → doc_control-0.4.0}/tests/test_text_op.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: doc-control
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Deterministic, LLM-first control of Word documents: addresses, anchors, tracked-change batches and a safety layer.
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
License-File: LICENSE
|
|
@@ -63,7 +63,9 @@ References: [operations](docs/operations.md) · [errors](docs/errors.md).
|
|
|
63
63
|
|
|
64
64
|
- **One core, two engines, one API**
|
|
65
65
|
- *File engine*: edits a closed `.docx` with the Python standard library only,
|
|
66
|
-
rewriting only the parts it touches.
|
|
66
|
+
rewriting only the parts it touches. Read operations also take a `.txt` or `.md`
|
|
67
|
+
file (hard-wrapped text detected; Markdown `#` headings become Heading 1-6); writes
|
|
68
|
+
refuse it.
|
|
67
69
|
- *Live engine*: controls the open Word (Windows/COM first, Mac later).
|
|
68
70
|
- **Two front doors**: a CLI with JSON in/out, and an MCP server
|
|
69
71
|
(`doc-control mcp`, stdio) for Claude Desktop / Cowork and other MCP clients.
|
|
@@ -54,7 +54,9 @@ References: [operations](docs/operations.md) · [errors](docs/errors.md).
|
|
|
54
54
|
|
|
55
55
|
- **One core, two engines, one API**
|
|
56
56
|
- *File engine*: edits a closed `.docx` with the Python standard library only,
|
|
57
|
-
rewriting only the parts it touches.
|
|
57
|
+
rewriting only the parts it touches. Read operations also take a `.txt` or `.md`
|
|
58
|
+
file (hard-wrapped text detected; Markdown `#` headings become Heading 1-6); writes
|
|
59
|
+
refuse it.
|
|
58
60
|
- *Live engine*: controls the open Word (Windows/COM first, Mac later).
|
|
59
61
|
- **Two front doors**: a CLI with JSON in/out, and an MCP server
|
|
60
62
|
(`doc-control mcp`, stdio) for Claude Desktop / Cowork and other MCP clients.
|
|
@@ -29,7 +29,10 @@ class ErrorCode(StrEnum):
|
|
|
29
29
|
|
|
30
30
|
HINTS: dict[ErrorCode, str] = {
|
|
31
31
|
ErrorCode.DOC_NOT_FOUND: "Check the path; it is resolved relative to the current directory.",
|
|
32
|
-
ErrorCode.NOT_A_DOCX:
|
|
32
|
+
ErrorCode.NOT_A_DOCX: (
|
|
33
|
+
"Pass a Word .docx file (or UTF-8 .txt/.md, read operations only); .doc and other"
|
|
34
|
+
" formats are not supported."
|
|
35
|
+
),
|
|
33
36
|
ErrorCode.DOC_LOCKED: "An editor (Word, LibreOffice) has the file open: close it, then retry.",
|
|
34
37
|
ErrorCode.DOC_CHANGED_ON_DISK: "The file changed since you read it: re-read, then retry.",
|
|
35
38
|
ErrorCode.SCHEMA_INVALID: "Run `doc-control help <op>` to see the request schema.",
|
|
@@ -87,7 +87,14 @@ class Document:
|
|
|
87
87
|
|
|
88
88
|
def envelope_doc(self) -> dict[str, Any]:
|
|
89
89
|
"""The ``doc`` block of a response."""
|
|
90
|
-
|
|
90
|
+
doc: dict[str, Any] = {
|
|
91
|
+
"path": self.path,
|
|
92
|
+
"sha256": self.sha256,
|
|
93
|
+
"paragraphs": len(self.index),
|
|
94
|
+
}
|
|
95
|
+
if self.pkg.text_source is not None:
|
|
96
|
+
doc["source"] = self.pkg.text_source
|
|
97
|
+
return doc
|
|
91
98
|
|
|
92
99
|
def memo(self, key: Hashable, build: Callable[[], _T]) -> _T:
|
|
93
100
|
"""``build()`` once per document and ``key``: derived tables shared by every
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Plain text and Markdown read as a .docx: the parts a :class:`Package` needs, built in memory.
|
|
2
|
+
|
|
3
|
+
Read operations accept a ``.txt`` or ``.md`` path and see the text as a minimal Word
|
|
4
|
+
document; nothing is written next to it, and write operations refuse it.
|
|
5
|
+
|
|
6
|
+
Encoding
|
|
7
|
+
UTF-32 or UTF-16 with a byte order mark; else UTF-8 (with or without one), any line
|
|
8
|
+
that is not UTF-8 read as Windows-1252; UTF-16 without a mark is refused. Line ends
|
|
9
|
+
are ``\\n``, ``\\r\\n`` or ``\\r``. The ``doc`` block of a reply names the
|
|
10
|
+
``source``: format, encoding (``utf-8+cp1252`` when lines mix them) and mode.
|
|
11
|
+
|
|
12
|
+
Paragraphs (``.txt``), by mode
|
|
13
|
+
``blocks`` (an empty line between two non-empty ones): a block of lines between empty lines is
|
|
14
|
+
a paragraph, its lines joined by one space (hard-wrapped prose, as in Project
|
|
15
|
+
Gutenberg files). One empty line separates two paragraphs; each further empty line
|
|
16
|
+
in a run is an empty paragraph, so ``chapter_blank_lines`` in ``segment`` counts the
|
|
17
|
+
extra ones. ``lines`` (otherwise): every line is a paragraph.
|
|
18
|
+
|
|
19
|
+
Markdown (``.md``)
|
|
20
|
+
Blocks as in ``blocks`` (soft-wrapped lines joined); a front matter block at the
|
|
21
|
+
start (``---``, a ``key:`` line, ..., ``---``) is skipped. A line ``# Title`` to
|
|
22
|
+
``###### Title`` is its own paragraph in style Heading 1-6. A line made only of
|
|
23
|
+
``* _ - ~ #`` and spaces, three or more of them (``***``, ``* * *``, ``---``), is its
|
|
24
|
+
own paragraph and keeps its characters, which scene detection reads as a break. A
|
|
25
|
+
bullet item starts a paragraph, and so does a numbered one that is ``1.`` or follows
|
|
26
|
+
another item. Emphasis markers (``*``, ``**``, and ``_``, ``__`` outside words) are
|
|
27
|
+
dropped; a backslash-escaped character is kept, literal. Lines inside a backtick
|
|
28
|
+
fence are kept as written, one paragraph each. Anything else (setext headings, hard
|
|
29
|
+
breaks, inline code, tables, quotes) is kept as written.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import io
|
|
35
|
+
import re
|
|
36
|
+
import zipfile
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from xml.sax.saxutils import escape
|
|
39
|
+
|
|
40
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
41
|
+
|
|
42
|
+
TEXT_SUFFIXES = (".txt", ".md", ".markdown")
|
|
43
|
+
|
|
44
|
+
_W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
45
|
+
_ATX = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$")
|
|
46
|
+
_BREAK = re.compile(r"^ {0,3}[-*_~#](?:[ \t]*[-*_~#]){2,}[ \t]*$")
|
|
47
|
+
_FENCE = re.compile(r"^ {0,3}(`{3,})")
|
|
48
|
+
_BULLET = re.compile(r"^ {0,3}[-*+][ \t]")
|
|
49
|
+
_ORDERED = re.compile(r"^ {0,3}(\d{1,9})[.)][ \t]")
|
|
50
|
+
_YAML_KEY = re.compile(r"^[A-Za-z_][\w-]*[ \t]*:")
|
|
51
|
+
# A span holds no marker of its own kind, so an unmatched marker scans only to the next
|
|
52
|
+
# one (linear time); nesting (``***x***``, ``**a *b* c**``) unwinds over passes.
|
|
53
|
+
_STRONG = re.compile(
|
|
54
|
+
r"\*\*(?=[^\s*])([^*]+?)(?<=\S)\*\*|(?<!\w)__(?=[^\s_])([^_]+?)(?<=\S)__(?!\w)"
|
|
55
|
+
)
|
|
56
|
+
_EMPH = re.compile(
|
|
57
|
+
r"(?<![\w*])\*(?=[^\s*])([^*]+?)(?<=\S)\*(?![\w*])|(?<!\w)_(?=[^\s_])([^_]+?)(?<=\S)_(?!\w)"
|
|
58
|
+
)
|
|
59
|
+
_ESCAPE = re.compile(r"(\\[!-/:-@\[-`{-~])")
|
|
60
|
+
_CONTROL = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f]")
|
|
61
|
+
_BLANK = " \t"
|
|
62
|
+
|
|
63
|
+
_CONTENT_TYPES = (
|
|
64
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
65
|
+
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
|
66
|
+
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.'
|
|
67
|
+
'relationships+xml"/>'
|
|
68
|
+
'<Default Extension="xml" ContentType="application/xml"/>'
|
|
69
|
+
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-'
|
|
70
|
+
'officedocument.wordprocessingml.document.main+xml"/>'
|
|
71
|
+
'<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-'
|
|
72
|
+
'officedocument.wordprocessingml.styles+xml"/>'
|
|
73
|
+
"</Types>"
|
|
74
|
+
)
|
|
75
|
+
_RELS = (
|
|
76
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
77
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
|
78
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/'
|
|
79
|
+
'relationships/officeDocument" Target="word/document.xml"/>'
|
|
80
|
+
"</Relationships>"
|
|
81
|
+
)
|
|
82
|
+
_DOC_RELS = (
|
|
83
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
84
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
|
85
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/'
|
|
86
|
+
'relationships/styles" Target="styles.xml"/>'
|
|
87
|
+
"</Relationships>"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _styles() -> str:
|
|
92
|
+
headings = "".join(
|
|
93
|
+
f'<w:style w:type="paragraph" w:styleId="Heading{n}"><w:name w:val="heading {n}"/>'
|
|
94
|
+
f'<w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>'
|
|
95
|
+
f'<w:pPr><w:outlineLvl w:val="{n - 1}"/></w:pPr></w:style>'
|
|
96
|
+
for n in range(1, 7)
|
|
97
|
+
)
|
|
98
|
+
return (
|
|
99
|
+
f'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="{_W}">'
|
|
100
|
+
'<w:style w:type="paragraph" w:default="1" w:styleId="Normal">'
|
|
101
|
+
'<w:name w:val="Normal"/><w:qFormat/></w:style>'
|
|
102
|
+
f"{headings}</w:styles>"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def refuse_write(path: Path) -> None:
|
|
107
|
+
"""``UNSUPPORTED_CONTENT`` when ``path`` is a text file: it is read, never written."""
|
|
108
|
+
if path.suffix.lower() in TEXT_SUFFIXES:
|
|
109
|
+
raise DocControlError(
|
|
110
|
+
ErrorCode.UNSUPPORTED_CONTENT,
|
|
111
|
+
f"{path} is plain text: only read operations accept it; write to a .docx",
|
|
112
|
+
path=str(path),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _line_bytes(raw: bytes) -> tuple[str, str]:
|
|
117
|
+
"""UTF-8, with any line that is not UTF-8 read as Windows-1252 (Latin-1 for its five
|
|
118
|
+
unassigned bytes), so one stray byte does not garble every accent of the file."""
|
|
119
|
+
try:
|
|
120
|
+
return raw.decode("utf-8"), "utf-8"
|
|
121
|
+
except UnicodeDecodeError:
|
|
122
|
+
pass
|
|
123
|
+
out: list[str] = []
|
|
124
|
+
utf8 = False # a line with non-ASCII characters read as UTF-8
|
|
125
|
+
for line in raw.splitlines(keepends=True):
|
|
126
|
+
try:
|
|
127
|
+
out.append(line.decode("utf-8"))
|
|
128
|
+
utf8 = utf8 or not line.isascii()
|
|
129
|
+
except UnicodeDecodeError:
|
|
130
|
+
try:
|
|
131
|
+
out.append(line.decode("cp1252"))
|
|
132
|
+
except UnicodeDecodeError:
|
|
133
|
+
out.append(line.decode("latin-1"))
|
|
134
|
+
return "".join(out), "utf-8+cp1252" if utf8 else "cp1252"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def decode(raw: bytes) -> tuple[str, str]:
|
|
138
|
+
"""``(text, encoding)``, line ends made ``\\n``; ``ValueError`` names an unreadable file.
|
|
139
|
+
|
|
140
|
+
UTF-32 or UTF-16 with a byte order mark; else UTF-8 (with or without one), any line
|
|
141
|
+
that is not UTF-8 read as Windows-1252.
|
|
142
|
+
"""
|
|
143
|
+
if raw.startswith((b"\xff\xfe\x00\x00", b"\x00\x00\xfe\xff")):
|
|
144
|
+
text, encoding = raw.decode("utf-32"), "utf-32"
|
|
145
|
+
elif raw.startswith((b"\xff\xfe", b"\xfe\xff")):
|
|
146
|
+
text, encoding = raw.decode("utf-16"), "utf-16"
|
|
147
|
+
elif b"\x00" in raw[:4096]:
|
|
148
|
+
raise ValueError("UTF-16 without a byte order mark (or binary): save it as UTF-8")
|
|
149
|
+
else:
|
|
150
|
+
bom = raw.startswith(b"\xef\xbb\xbf")
|
|
151
|
+
text, encoding = _line_bytes(raw[3:] if bom else raw)
|
|
152
|
+
if bom and encoding == "utf-8":
|
|
153
|
+
encoding = "utf-8-sig"
|
|
154
|
+
return text.replace("\r\n", "\n").replace("\r", "\n"), encoding
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _emphasis(text: str) -> str:
|
|
158
|
+
for _ in range(3): # nested emphasis, e.g. ***x***
|
|
159
|
+
stripped = _EMPH.sub(lambda m: m.group(1) or m.group(2), text)
|
|
160
|
+
stripped = _STRONG.sub(lambda m: m.group(1) or m.group(2), stripped)
|
|
161
|
+
if stripped == text:
|
|
162
|
+
break
|
|
163
|
+
text = stripped
|
|
164
|
+
return text
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _inline(text: str) -> str:
|
|
168
|
+
"""Emphasis markers dropped; a backslash-escaped character kept, bare and literal.
|
|
169
|
+
|
|
170
|
+
Escaped characters are hidden from the emphasis patterns as private-use characters
|
|
171
|
+
from a plane the text does not use, then put back.
|
|
172
|
+
"""
|
|
173
|
+
planes = {ord(c) >> 16 for c in text if ord(c) >= 0xF0000}
|
|
174
|
+
plane = next((p for p in (0xF, 0x10) if p not in planes), None)
|
|
175
|
+
if plane is None: # both private planes in use: escapes split the text instead
|
|
176
|
+
parts = _ESCAPE.split(text)
|
|
177
|
+
return "".join(part[1] if i % 2 else _emphasis(part) for i, part in enumerate(parts))
|
|
178
|
+
base = plane << 16
|
|
179
|
+
hidden = _ESCAPE.sub(lambda m: chr(base + ord(m.group(1)[1])), text)
|
|
180
|
+
shown = _emphasis(hidden)
|
|
181
|
+
return re.sub(
|
|
182
|
+
f"[{chr(base + 0x21)}-{chr(base + 0x7E)}]", lambda m: chr(ord(m.group()) - base), shown
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _blank(line: str) -> bool:
|
|
187
|
+
return not line.strip(_BLANK)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _starts_item(line: str, pending: list[str]) -> bool:
|
|
191
|
+
"""A list item starts a paragraph: a bullet always, a numbered item when it is ``1.``
|
|
192
|
+
or follows another item (``1914. was`` inside a sentence does not)."""
|
|
193
|
+
if _BULLET.match(line):
|
|
194
|
+
return True
|
|
195
|
+
number = _ORDERED.match(line)
|
|
196
|
+
if number is None:
|
|
197
|
+
return False
|
|
198
|
+
in_list = bool(pending) and bool(_BULLET.match(pending[0]) or _ORDERED.match(pending[0]))
|
|
199
|
+
return not pending or number.group(1) == "1" or in_list
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _front_matter(lines: list[str]) -> int:
|
|
203
|
+
"""How many lines a YAML front matter block at the start takes (0: none)."""
|
|
204
|
+
if len(lines) < 3 or lines[0].rstrip(_BLANK) != "---" or not _YAML_KEY.match(lines[1]):
|
|
205
|
+
return 0
|
|
206
|
+
for n, line in enumerate(lines[1:], start=1):
|
|
207
|
+
if line.rstrip(_BLANK) in ("---", "..."):
|
|
208
|
+
return n + 1
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def paragraphs(text: str, markdown: bool) -> tuple[list[tuple[str, int | None]], str]:
|
|
213
|
+
"""``(text, heading level or None)`` per paragraph, and the mode: ``markdown``,
|
|
214
|
+
``blocks`` or ``lines``; see the module docstring."""
|
|
215
|
+
lines = text.split("\n")
|
|
216
|
+
if lines and lines[-1] == "":
|
|
217
|
+
lines.pop() # the final line end closes the last line, it does not open one
|
|
218
|
+
if markdown:
|
|
219
|
+
lines = lines[_front_matter(lines) :]
|
|
220
|
+
mode = "markdown"
|
|
221
|
+
else:
|
|
222
|
+
inner = [n for n, line in enumerate(lines) if not _blank(line)]
|
|
223
|
+
between = lines[inner[0] : inner[-1] + 1] if inner else []
|
|
224
|
+
mode = "blocks" if any(_blank(line) for line in between) else "lines"
|
|
225
|
+
out: list[tuple[str, int | None]] = []
|
|
226
|
+
if mode == "lines":
|
|
227
|
+
return [(line, None) for line in lines], mode
|
|
228
|
+
pending: list[str] = []
|
|
229
|
+
blanks = 0
|
|
230
|
+
fence = "" # the opening fence while inside a code block
|
|
231
|
+
|
|
232
|
+
def flush() -> None:
|
|
233
|
+
if pending:
|
|
234
|
+
joined = " ".join(part.strip(_BLANK) for part in pending)
|
|
235
|
+
out.append((_inline(joined) if markdown else joined, None))
|
|
236
|
+
pending.clear()
|
|
237
|
+
|
|
238
|
+
for line in lines:
|
|
239
|
+
opening = _FENCE.match(line) if markdown else None
|
|
240
|
+
if fence or opening:
|
|
241
|
+
flush()
|
|
242
|
+
out.append((line, None)) # code: one literal paragraph per line
|
|
243
|
+
if not fence and opening:
|
|
244
|
+
fence = opening.group(1)
|
|
245
|
+
elif fence and line.strip(_BLANK).startswith(fence) and not line.strip(_BLANK + "`"):
|
|
246
|
+
fence = ""
|
|
247
|
+
blanks = 0
|
|
248
|
+
continue
|
|
249
|
+
if _blank(line):
|
|
250
|
+
flush()
|
|
251
|
+
# The first empty line of a run separates; each further one is a paragraph.
|
|
252
|
+
if blanks or not out:
|
|
253
|
+
out.append(("", None))
|
|
254
|
+
blanks += 1
|
|
255
|
+
continue
|
|
256
|
+
blanks = 0
|
|
257
|
+
if markdown and _BREAK.match(line):
|
|
258
|
+
flush()
|
|
259
|
+
out.append((line.strip(_BLANK), None))
|
|
260
|
+
continue
|
|
261
|
+
heading = _ATX.match(line) if markdown else None
|
|
262
|
+
if heading:
|
|
263
|
+
flush()
|
|
264
|
+
out.append((_inline(heading.group(2) or ""), len(heading.group(1))))
|
|
265
|
+
continue
|
|
266
|
+
if markdown and _starts_item(line, pending):
|
|
267
|
+
flush()
|
|
268
|
+
pending.append(line)
|
|
269
|
+
flush()
|
|
270
|
+
return out, mode
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _paragraph_xml(text: str, level: int | None) -> str:
|
|
274
|
+
ppr = f'<w:pPr><w:pStyle w:val="Heading{level}"/></w:pPr>' if level else ""
|
|
275
|
+
if not text:
|
|
276
|
+
return f"<w:p>{ppr}</w:p>"
|
|
277
|
+
runs = []
|
|
278
|
+
for i, part in enumerate(_CONTROL.sub("", text).split("\t")):
|
|
279
|
+
if i:
|
|
280
|
+
runs.append("<w:tab/>")
|
|
281
|
+
if part:
|
|
282
|
+
runs.append(f'<w:t xml:space="preserve">{escape(part)}</w:t>')
|
|
283
|
+
return f"<w:p>{ppr}<w:r>{''.join(runs)}</w:r></w:p>"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def package_bytes(text: str, markdown: bool) -> tuple[bytes, str]:
|
|
287
|
+
"""A .docx (zip bytes) whose paragraphs are those of ``text``, and the mode used."""
|
|
288
|
+
paras, mode = paragraphs(text, markdown)
|
|
289
|
+
body = "".join(_paragraph_xml(t, level) for t, level in paras)
|
|
290
|
+
document = (
|
|
291
|
+
f'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
292
|
+
f'<w:document xmlns:w="{_W}"><w:body>{body}<w:sectPr/></w:body></w:document>'
|
|
293
|
+
).encode()
|
|
294
|
+
buf = io.BytesIO()
|
|
295
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
296
|
+
zf.writestr("[Content_Types].xml", _CONTENT_TYPES)
|
|
297
|
+
zf.writestr("_rels/.rels", _RELS)
|
|
298
|
+
zf.writestr("word/document.xml", document)
|
|
299
|
+
zf.writestr("word/_rels/document.xml.rels", _DOC_RELS)
|
|
300
|
+
zf.writestr("word/styles.xml", _styles())
|
|
301
|
+
return buf.getvalue(), mode
|
|
@@ -29,6 +29,7 @@ from collections.abc import Callable, Mapping
|
|
|
29
29
|
from pathlib import Path
|
|
30
30
|
|
|
31
31
|
from doc_control.errors import DocControlError, ErrorCode
|
|
32
|
+
from doc_control.ooxml.plaintext import TEXT_SUFFIXES, decode, package_bytes, refuse_write
|
|
32
33
|
|
|
33
34
|
REQUIRED_PARTS = ("[Content_Types].xml", "word/document.xml")
|
|
34
35
|
|
|
@@ -91,7 +92,12 @@ class Package:
|
|
|
91
92
|
raise DocControlError(ErrorCode.DOC_NOT_FOUND, f"no such file: {path}", path=str(path))
|
|
92
93
|
raw = path.read_bytes()
|
|
93
94
|
self.sha256 = hashlib.sha256(raw).hexdigest()
|
|
95
|
+
#: For a .txt/.md file read as a document (readable, never written): its
|
|
96
|
+
#: ``format``, ``encoding`` and paragraph ``mode``; None for a .docx.
|
|
97
|
+
self.text_source: dict[str, str] | None = None
|
|
94
98
|
try:
|
|
99
|
+
if path.suffix.lower() in TEXT_SUFFIXES:
|
|
100
|
+
raw, self.text_source = _text_package(path, raw)
|
|
95
101
|
self._load(raw)
|
|
96
102
|
except DocControlError:
|
|
97
103
|
raise
|
|
@@ -199,6 +205,7 @@ def write_package(
|
|
|
199
205
|
for name, data in replacements.items():
|
|
200
206
|
if not isinstance(data, bytes):
|
|
201
207
|
raise TypeError(f"replacement for {name} must be bytes")
|
|
208
|
+
refuse_write(dest) # a text file is read as a document, never written
|
|
202
209
|
payload = build_package(pkg, replacements)
|
|
203
210
|
commit_bytes(payload, dest, stage)
|
|
204
211
|
return hashlib.sha256(payload).hexdigest()
|
|
@@ -275,6 +282,18 @@ def _claim(tmp: Path, dest: Path) -> None:
|
|
|
275
282
|
tmp.unlink()
|
|
276
283
|
|
|
277
284
|
|
|
285
|
+
def _text_package(path: Path, raw: bytes) -> tuple[bytes, dict[str, str]]:
|
|
286
|
+
try:
|
|
287
|
+
text, encoding = decode(raw)
|
|
288
|
+
except ValueError as exc: # UnicodeDecodeError too
|
|
289
|
+
raise DocControlError(
|
|
290
|
+
ErrorCode.NOT_A_DOCX, f"{path} is not readable text: {exc}", path=str(path)
|
|
291
|
+
) from exc
|
|
292
|
+
fmt = "txt" if path.suffix.lower() == ".txt" else "md"
|
|
293
|
+
data, mode = package_bytes(text, markdown=fmt == "md")
|
|
294
|
+
return data, {"format": fmt, "encoding": encoding, "mode": mode}
|
|
295
|
+
|
|
296
|
+
|
|
278
297
|
def _build(pkg: Package, replacements: Mapping[str, bytes]) -> bytes:
|
|
279
298
|
out = bytearray()
|
|
280
299
|
central = bytearray()
|
|
@@ -55,6 +55,7 @@ from doc_control.model.align import (
|
|
|
55
55
|
align,
|
|
56
56
|
)
|
|
57
57
|
from doc_control.model.index import DocumentIndex, Paragraph, build_index
|
|
58
|
+
from doc_control.ooxml.plaintext import refuse_write
|
|
58
59
|
from doc_control.ooxml.revisions import (
|
|
59
60
|
DEFAULT_AUTHOR,
|
|
60
61
|
ParagraphEditor,
|
|
@@ -460,6 +461,7 @@ def _accepted_texts(data: bytes, ids: set[str]) -> tuple[list[str], bool]:
|
|
|
460
461
|
|
|
461
462
|
|
|
462
463
|
def _check_output(output: Path, inputs: list[Path]) -> None:
|
|
464
|
+
refuse_write(output)
|
|
463
465
|
for source in inputs:
|
|
464
466
|
same = output.exists() and source.exists() and output.samefile(source)
|
|
465
467
|
if same or output.resolve() == source.resolve():
|
|
@@ -506,7 +508,11 @@ def compare_documents(
|
|
|
506
508
|
index_new = DocumentIndex.from_package(pkg_new)
|
|
507
509
|
alignment = align(index_old.paragraphs, index_new.paragraphs)
|
|
508
510
|
info_old = {"path": str(old), "sha256": pkg_old.sha256, "paragraphs": len(index_old)}
|
|
511
|
+
if pkg_old.text_source is not None:
|
|
512
|
+
info_old["source"] = pkg_old.text_source
|
|
509
513
|
info_new = {"path": str(new), "sha256": pkg_new.sha256, "paragraphs": len(index_new)}
|
|
514
|
+
if pkg_new.text_source is not None:
|
|
515
|
+
info_new["source"] = pkg_new.text_source
|
|
510
516
|
plan = _plan(index_old, index_new, alignment)
|
|
511
517
|
edits = _edits(plan)
|
|
512
518
|
|
|
@@ -160,7 +160,12 @@ def find(request: dict[str, Any]) -> Reply:
|
|
|
160
160
|
)
|
|
161
161
|
return Reply(
|
|
162
162
|
{"matches": matches, **total},
|
|
163
|
-
doc={
|
|
163
|
+
doc={
|
|
164
|
+
"path": request["doc"],
|
|
165
|
+
"sha256": pkg.sha256,
|
|
166
|
+
"paragraphs": len(index),
|
|
167
|
+
**({"source": pkg.text_source} if pkg.text_source is not None else {}),
|
|
168
|
+
},
|
|
164
169
|
warnings=warnings,
|
|
165
170
|
next_cursor=next_cursor,
|
|
166
171
|
)
|