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.
Files changed (196) hide show
  1. {doc_control-0.3.0 → doc_control-0.4.0}/PKG-INFO +4 -2
  2. {doc_control-0.3.0 → doc_control-0.4.0}/README.md +3 -1
  3. {doc_control-0.3.0 → doc_control-0.4.0}/pyproject.toml +1 -1
  4. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/__init__.py +1 -1
  5. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/errors.py +4 -1
  6. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/document.py +8 -1
  7. doc_control-0.4.0/src/doc_control/ooxml/plaintext.py +301 -0
  8. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ooxml/zipio.py +19 -0
  9. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/compare.py +6 -0
  10. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/find.py +6 -1
  11. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/locate.py +170 -12
  12. doc_control-0.4.0/src/doc_control/recipes/bundled/empty-headings.json +28 -0
  13. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/registry.py +4 -0
  14. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/safety/store.py +4 -1
  15. doc_control-0.4.0/tests/ooxml/test_plaintext.py +241 -0
  16. doc_control-0.4.0/tests/ops/test_locate_lazy.py +81 -0
  17. {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/test_context.py +6 -1
  18. doc_control-0.3.0/src/doc_control/recipes/bundled/empty-headings.json +0 -17
  19. {doc_control-0.3.0 → doc_control-0.4.0}/.gitignore +0 -0
  20. {doc_control-0.3.0 → doc_control-0.4.0}/LICENSE +0 -0
  21. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/__main__.py +0 -0
  22. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/api.py +0 -0
  23. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/cli.py +0 -0
  24. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/envelope.py +0 -0
  25. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/inprocess.py +0 -0
  26. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/mcp.py +0 -0
  27. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/__init__.py +0 -0
  28. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/address.py +0 -0
  29. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/align.py +0 -0
  30. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/anchor.py +0 -0
  31. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/annotations.py +0 -0
  32. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/format.py +0 -0
  33. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/index.py +0 -0
  34. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/layout.py +0 -0
  35. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/normalized.py +0 -0
  36. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/pages.py +0 -0
  37. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/parts.py +0 -0
  38. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/search.py +0 -0
  39. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/segment.py +0 -0
  40. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/structure.py +0 -0
  41. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/model/styles.py +0 -0
  42. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ooxml/__init__.py +0 -0
  43. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ooxml/commentparts.py +0 -0
  44. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ooxml/formatting.py +0 -0
  45. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ooxml/resolve.py +0 -0
  46. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ooxml/revisions.py +0 -0
  47. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ooxml/splice.py +0 -0
  48. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ooxml/styledef.py +0 -0
  49. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/__init__.py +0 -0
  50. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/anchor.py +0 -0
  51. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/batch.py +0 -0
  52. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/bulk.py +0 -0
  53. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/comments.py +0 -0
  54. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/diff.py +0 -0
  55. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/dotx.py +0 -0
  56. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/history.py +0 -0
  57. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/layout.py +0 -0
  58. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/meta.py +0 -0
  59. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/page.py +0 -0
  60. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/paging.py +0 -0
  61. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/read.py +0 -0
  62. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/recipe.py +0 -0
  63. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/rename.py +0 -0
  64. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/revision.py +0 -0
  65. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/segment.py +0 -0
  66. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/styleset.py +0 -0
  67. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/stylewrite.py +0 -0
  68. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/text.py +0 -0
  69. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/ops/tracked.py +0 -0
  70. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/py.typed +0 -0
  71. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/blank-paragraphs.json +0 -0
  72. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/chapter-heading-period.json +0 -0
  73. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/chapter-heading-style.json +0 -0
  74. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/collapse-spaces.json +0 -0
  75. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/dashes-em.json +0 -0
  76. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/dashes-spaced-en.json +0 -0
  77. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/ellipsis-char.json +0 -0
  78. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/empty-paragraph-runs.json +0 -0
  79. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-aside-dash.json +0 -0
  80. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-dash-close-pair-space.json +0 -0
  81. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-dash-close-space.json +0 -0
  82. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-dash-first-aside.json +0 -0
  83. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-dash-open-space.json +0 -0
  84. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-dialogue-dash.json +0 -0
  85. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-quotes-close.json +0 -0
  86. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-quotes-open.json +0 -0
  87. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-space-after-opener.json +0 -0
  88. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-space-after-punctuation.json +0 -0
  89. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/es-space-before-punctuation.json +0 -0
  90. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/fake-centring.json +0 -0
  91. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/fake-tabs.json +0 -0
  92. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/leading-spaces.json +0 -0
  93. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/leftover-markers.json +0 -0
  94. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/possible-chapter-break.json +0 -0
  95. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/quotes-de.json +0 -0
  96. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/quotes-en-single.json +0 -0
  97. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/quotes-en.json +0 -0
  98. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/quotes-fr.json +0 -0
  99. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/scene-break.json +0 -0
  100. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/tab-indent.json +0 -0
  101. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/trim-trailing-spaces.json +0 -0
  102. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/bundled/typographic-apostrophe.json +0 -0
  103. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/engine.py +0 -0
  104. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/schema.py +0 -0
  105. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/recipes/store.py +0 -0
  106. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/reference.py +0 -0
  107. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/safety/__init__.py +0 -0
  108. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/safety/history.py +0 -0
  109. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/safety/lock.py +0 -0
  110. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/safety/verify.py +0 -0
  111. {doc_control-0.3.0 → doc_control-0.4.0}/src/doc_control/schema.py +0 -0
  112. {doc_control-0.3.0 → doc_control-0.4.0}/tests/__init__.py +0 -0
  113. {doc_control-0.3.0 → doc_control-0.4.0}/tests/bench/__init__.py +0 -0
  114. {doc_control-0.3.0 → doc_control-0.4.0}/tests/bench/test_cold_read.py +0 -0
  115. {doc_control-0.3.0 → doc_control-0.4.0}/tests/bench/test_perf_gate.py +0 -0
  116. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ci/__init__.py +0 -0
  117. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ci/test_hygiene.py +0 -0
  118. {doc_control-0.3.0 → doc_control-0.4.0}/tests/conftest.py +0 -0
  119. {doc_control-0.3.0 → doc_control-0.4.0}/tests/corpus/__init__.py +0 -0
  120. {doc_control-0.3.0 → doc_control-0.4.0}/tests/corpus/generator.py +0 -0
  121. {doc_control-0.3.0 → doc_control-0.4.0}/tests/corpus/private.py +0 -0
  122. {doc_control-0.3.0 → doc_control-0.4.0}/tests/corpus/structural.py +0 -0
  123. {doc_control-0.3.0 → doc_control-0.4.0}/tests/corpus/test_generator.py +0 -0
  124. {doc_control-0.3.0 → doc_control-0.4.0}/tests/corpus/test_validator.py +0 -0
  125. {doc_control-0.3.0 → doc_control-0.4.0}/tests/corpus/validator.py +0 -0
  126. {doc_control-0.3.0 → doc_control-0.4.0}/tests/helpers.py +0 -0
  127. {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/__init__.py +0 -0
  128. {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/data/segment_gold.txt +0 -0
  129. {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_address.py +0 -0
  130. {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_anchor.py +0 -0
  131. {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_anchor_exact.py +0 -0
  132. {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_anchor_normalised.py +0 -0
  133. {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_index.py +0 -0
  134. {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_normalized.py +0 -0
  135. {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_pages_rendered.py +0 -0
  136. {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_segment.py +0 -0
  137. {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_structure.py +0 -0
  138. {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_structure_parts.py +0 -0
  139. {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_structure_plain.py +0 -0
  140. {doc_control-0.3.0 → doc_control-0.4.0}/tests/model/test_structure_scene_styles.py +0 -0
  141. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ooxml/__init__.py +0 -0
  142. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ooxml/docgen.py +0 -0
  143. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ooxml/test_splice.py +0 -0
  144. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ooxml/test_zipio.py +0 -0
  145. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/__init__.py +0 -0
  146. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/bigdoc.py +0 -0
  147. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/revsim.py +0 -0
  148. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_batch.py +0 -0
  149. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_comments.py +0 -0
  150. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_compare.py +0 -0
  151. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_diff.py +0 -0
  152. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_dotx.py +0 -0
  153. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_find.py +0 -0
  154. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_find_hardening.py +0 -0
  155. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_format.py +0 -0
  156. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_layout.py +0 -0
  157. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_locate.py +0 -0
  158. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_page.py +0 -0
  159. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_page_facts.py +0 -0
  160. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_read.py +0 -0
  161. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_rename.py +0 -0
  162. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_revisions.py +0 -0
  163. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_segment_parts.py +0 -0
  164. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_segment_plain.py +0 -0
  165. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_segment_ranges.py +0 -0
  166. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_segment_scene_styles.py +0 -0
  167. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_stylesets.py +0 -0
  168. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_tracked_markup.py +0 -0
  169. {doc_control-0.3.0 → doc_control-0.4.0}/tests/ops/test_tracked_text.py +0 -0
  170. {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/__init__.py +0 -0
  171. {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/docx_fixer_cases.py +0 -0
  172. {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/house/recipes/house-author-notes.json +0 -0
  173. {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/house/recipes/house-note-lines.json +0 -0
  174. {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/house/recipes/house-scene-separator.json +0 -0
  175. {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/house/stylesets/house-novel.json +0 -0
  176. {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/recipe_harness.py +0 -0
  177. {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/test_common_recipes.py +0 -0
  178. {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/test_engine.py +0 -0
  179. {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/test_es_typography.py +0 -0
  180. {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/test_inventory.py +0 -0
  181. {doc_control-0.3.0 → doc_control-0.4.0}/tests/recipes/test_user_expressibility.py +0 -0
  182. {doc_control-0.3.0 → doc_control-0.4.0}/tests/safety/__init__.py +0 -0
  183. {doc_control-0.3.0 → doc_control-0.4.0}/tests/safety/test_history.py +0 -0
  184. {doc_control-0.3.0 → doc_control-0.4.0}/tests/safety/test_lock.py +0 -0
  185. {doc_control-0.3.0 → doc_control-0.4.0}/tests/safety/test_store.py +0 -0
  186. {doc_control-0.3.0 → doc_control-0.4.0}/tests/safety/test_verify.py +0 -0
  187. {doc_control-0.3.0 → doc_control-0.4.0}/tests/safety/test_writer_safety.py +0 -0
  188. {doc_control-0.3.0 → doc_control-0.4.0}/tests/scenarios/__init__.py +0 -0
  189. {doc_control-0.3.0 → doc_control-0.4.0}/tests/scenarios/test_writer_notes.py +0 -0
  190. {doc_control-0.3.0 → doc_control-0.4.0}/tests/test_api.py +0 -0
  191. {doc_control-0.3.0 → doc_control-0.4.0}/tests/test_api_polish.py +0 -0
  192. {doc_control-0.3.0 → doc_control-0.4.0}/tests/test_docs.py +0 -0
  193. {doc_control-0.3.0 → doc_control-0.4.0}/tests/test_mcp.py +0 -0
  194. {doc_control-0.3.0 → doc_control-0.4.0}/tests/test_package.py +0 -0
  195. {doc_control-0.3.0 → doc_control-0.4.0}/tests/test_registry.py +0 -0
  196. {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.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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "doc-control"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Deterministic, LLM-first control of Word documents: addresses, anchors, tracked-change batches and a safety layer."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -1,4 +1,4 @@
1
1
  """doc-control: deterministic, LLM-first control of Word documents."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
4
4
  API_VERSION = "1.0"
@@ -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: "Pass a Word .docx file; .doc and other formats are not supported.",
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
- return {"path": self.path, "sha256": self.sha256, "paragraphs": len(self.index)}
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={"path": request["doc"], "sha256": pkg.sha256, "paragraphs": len(index)},
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
  )