adeu 3.2.1__tar.gz → 3.2.2__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 (273) hide show
  1. {adeu-3.2.1 → adeu-3.2.2}/PKG-INFO +1 -1
  2. {adeu-3.2.1 → adeu-3.2.2}/pyproject.toml +1 -1
  3. {adeu-3.2.1 → adeu-3.2.2}/server.json +2 -2
  4. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/cli.py +50 -10
  5. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/compare.py +14 -4
  6. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/disk_cache.py +5 -0
  7. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/ingest.py +47 -2
  8. adeu-3.2.2/src/adeu/mcp_components/build_info.json +1 -0
  9. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/mcp_components/tools/document.py +10 -2
  10. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/outline.py +48 -28
  11. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/redline/engine.py +59 -7
  12. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/redline/mapper.py +144 -36
  13. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/sanitize/core.py +3 -3
  14. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/text_revision.py +40 -1
  15. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/utils/docx.py +441 -80
  16. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_qa_2026_07_18.py +1 -1
  17. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_qa_report_v5.py +2 -2
  18. adeu-3.2.2/tests/test_resolved_numbering_and_extract_output.py +438 -0
  19. {adeu-3.2.1 → adeu-3.2.2}/uv.lock +1 -1
  20. adeu-3.2.1/src/adeu/mcp_components/build_info.json +0 -1
  21. {adeu-3.2.1 → adeu-3.2.2}/.gitignore +0 -0
  22. {adeu-3.2.1 → adeu-3.2.2}/Dockerfile +0 -0
  23. {adeu-3.2.1 → adeu-3.2.2}/LICENSE +0 -0
  24. {adeu-3.2.1 → adeu-3.2.2}/README.md +0 -0
  25. {adeu-3.2.1 → adeu-3.2.2}/scripts/abstract_xml.py +0 -0
  26. {adeu-3.2.1 → adeu-3.2.2}/scripts/debug_rels.py +0 -0
  27. {adeu-3.2.1 → adeu-3.2.2}/scripts/debug_structure.py +0 -0
  28. {adeu-3.2.1 → adeu-3.2.2}/scripts/debug_styles.py +0 -0
  29. {adeu-3.2.1 → adeu-3.2.2}/scripts/debug_xml.py +0 -0
  30. {adeu-3.2.1 → adeu-3.2.2}/scripts/generate_goldens.py +0 -0
  31. {adeu-3.2.1 → adeu-3.2.2}/scripts/golden_projection.py +0 -0
  32. {adeu-3.2.1 → adeu-3.2.2}/scripts/inspect_test_docx.py +0 -0
  33. {adeu-3.2.1 → adeu-3.2.2}/scripts/login_staging.py +0 -0
  34. {adeu-3.2.1 → adeu-3.2.2}/scripts/reproduce_issue.py +0 -0
  35. {adeu-3.2.1 → adeu-3.2.2}/scripts/test_numbered_list.py +0 -0
  36. {adeu-3.2.1 → adeu-3.2.2}/scripts/verify_staging_polling.py +0 -0
  37. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/__init__.py +0 -0
  38. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/assets/adeu.svg +0 -0
  39. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/assets/logo.png +0 -0
  40. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/assets/marked.min.js +0 -0
  41. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/diff.py +0 -0
  42. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/domain.py +0 -0
  43. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/fields.py +0 -0
  44. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/markup.py +0 -0
  45. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/mcp_components/__init__.py +0 -0
  46. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/mcp_components/_response_builders.py +0 -0
  47. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/mcp_components/doc_cache.py +0 -0
  48. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/mcp_components/resources/markdown_ui.py +0 -0
  49. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/mcp_components/shared.py +0 -0
  50. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/mcp_components/tools/live_word.py +0 -0
  51. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/mcp_components/tools/live_word_ops.py +0 -0
  52. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/mcp_components/tools/sanitize.py +0 -0
  53. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/models.py +0 -0
  54. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/pagination.py +0 -0
  55. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/payloads.py +0 -0
  56. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/redline/comments.py +0 -0
  57. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/redline/gates.py +0 -0
  58. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/sanitize/__init__.py +0 -0
  59. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/sanitize/report.py +0 -0
  60. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/sanitize/transforms.py +0 -0
  61. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/serve.py +0 -0
  62. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/server.py +0 -0
  63. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/templates/markdown_ui.html +0 -0
  64. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/utils/console.py +0 -0
  65. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/utils/content_controls.py +0 -0
  66. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/utils/field_write.py +0 -0
  67. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/utils/long_hex_number.py +0 -0
  68. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/utils/opc.py +0 -0
  69. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/utils/protection.py +0 -0
  70. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/utils/safe_regex.py +0 -0
  71. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/utils/schema_order.py +0 -0
  72. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/utils/text.py +0 -0
  73. {adeu-3.2.1 → adeu-3.2.2}/src/adeu/utils/xml_debug.py +0 -0
  74. {adeu-3.2.1 → adeu-3.2.2}/tests/__init__.py +0 -0
  75. {adeu-3.2.1 → adeu-3.2.2}/tests/cc_fixture.py +0 -0
  76. {adeu-3.2.1 → adeu-3.2.2}/tests/conftest.py +0 -0
  77. {adeu-3.2.1 → adeu-3.2.2}/tests/docx_fixtures.py +0 -0
  78. {adeu-3.2.1 → adeu-3.2.2}/tests/fixtures_synth.py +0 -0
  79. {adeu-3.2.1 → adeu-3.2.2}/tests/golden_manifest.txt +0 -0
  80. {adeu-3.2.1 → adeu-3.2.2}/tests/repro_golden.py +0 -0
  81. {adeu-3.2.1 → adeu-3.2.2}/tests/sdt_fixtures.py +0 -0
  82. {adeu-3.2.1 → adeu-3.2.2}/tests/test_atomic_batch_pipeline.py +0 -0
  83. {adeu-3.2.1 → adeu-3.2.2}/tests/test_author_impersonation_gate.py +0 -0
  84. {adeu-3.2.1 → adeu-3.2.2}/tests/test_batch_ergonomics_and_receipts.py +0 -0
  85. {adeu-3.2.1 → adeu-3.2.2}/tests/test_batch_reliability.py +0 -0
  86. {adeu-3.2.1 → adeu-3.2.2}/tests/test_batch_validation.py +0 -0
  87. {adeu-3.2.1 → adeu-3.2.2}/tests/test_cc_anchor_chrome_protection.py +0 -0
  88. {adeu-3.2.1 → adeu-3.2.2}/tests/test_cc_anchor_fabrication_refusal.py +0 -0
  89. {adeu-3.2.1 → adeu-3.2.2}/tests/test_cc_checkboxes.py +0 -0
  90. {adeu-3.2.1 → adeu-3.2.2}/tests/test_cc_fields_ledger.py +0 -0
  91. {adeu-3.2.1 → adeu-3.2.2}/tests/test_cc_fields_surfaces.py +0 -0
  92. {adeu-3.2.1 → adeu-3.2.2}/tests/test_cc_gate_surfaces.py +0 -0
  93. {adeu-3.2.1 → adeu-3.2.2}/tests/test_cc_gates.py +0 -0
  94. {adeu-3.2.1 → adeu-3.2.2}/tests/test_cc_projection_inline.py +0 -0
  95. {adeu-3.2.1 → adeu-3.2.2}/tests/test_cc_protection_state.py +0 -0
  96. {adeu-3.2.1 → adeu-3.2.2}/tests/test_cc_run_control_identity.py +0 -0
  97. {adeu-3.2.1 → adeu-3.2.2}/tests/test_cc_set_field.py +0 -0
  98. {adeu-3.2.1 → adeu-3.2.2}/tests/test_cc_span_control_identity.py +0 -0
  99. {adeu-3.2.1 → adeu-3.2.2}/tests/test_cc_widening_walls.py +0 -0
  100. {adeu-3.2.1 → adeu-3.2.2}/tests/test_changes_ledger.py +0 -0
  101. {adeu-3.2.1 → adeu-3.2.2}/tests/test_clean_view.py +0 -0
  102. {adeu-3.2.1 → adeu-3.2.2}/tests/test_cli_bug_repro.py +0 -0
  103. {adeu-3.2.1 → adeu-3.2.2}/tests/test_cli_encoding.py +0 -0
  104. {adeu-3.2.1 → adeu-3.2.2}/tests/test_cli_features.py +0 -0
  105. {adeu-3.2.1 → adeu-3.2.2}/tests/test_cli_init.py +0 -0
  106. {adeu-3.2.1 → adeu-3.2.2}/tests/test_comment_list.py +0 -0
  107. {adeu-3.2.1 → adeu-3.2.2}/tests/test_comment_xml_structure.py +0 -0
  108. {adeu-3.2.1 → adeu-3.2.2}/tests/test_comments.py +0 -0
  109. {adeu-3.2.1 → adeu-3.2.2}/tests/test_comments_threading.py +0 -0
  110. {adeu-3.2.1 → adeu-3.2.2}/tests/test_compact_diff_json.py +0 -0
  111. {adeu-3.2.1 → adeu-3.2.2}/tests/test_content_control_classification.py +0 -0
  112. {adeu-3.2.1 → adeu-3.2.2}/tests/test_context_trimming.py +0 -0
  113. {adeu-3.2.1 → adeu-3.2.2}/tests/test_corpus_validation.py +0 -0
  114. {adeu-3.2.1 → adeu-3.2.2}/tests/test_cross_platform_consistency.py +0 -0
  115. {adeu-3.2.1 → adeu-3.2.2}/tests/test_cross_platform_integrity.py +0 -0
  116. {adeu-3.2.1 → adeu-3.2.2}/tests/test_diff_logic.py +0 -0
  117. {adeu-3.2.1 → adeu-3.2.2}/tests/test_diff_subalignment_and_balance.py +0 -0
  118. {adeu-3.2.1 → adeu-3.2.2}/tests/test_disk_projection_cache.py +0 -0
  119. {adeu-3.2.1 → adeu-3.2.2}/tests/test_doc_cache.py +0 -0
  120. {adeu-3.2.1 → adeu-3.2.2}/tests/test_document_settings_warnings.py +0 -0
  121. {adeu-3.2.1 → adeu-3.2.2}/tests/test_domain_semantics.py +0 -0
  122. {adeu-3.2.1 → adeu-3.2.2}/tests/test_edit_report_index.py +0 -0
  123. {adeu-3.2.1 → adeu-3.2.2}/tests/test_explicit_salvage.py +0 -0
  124. {adeu-3.2.1 → adeu-3.2.2}/tests/test_failure_envelope.py +0 -0
  125. {adeu-3.2.1 → adeu-3.2.2}/tests/test_failure_recovery_protocol.py +0 -0
  126. {adeu-3.2.1 → adeu-3.2.2}/tests/test_fastmcp3_compat.py +0 -0
  127. {adeu-3.2.1 → adeu-3.2.2}/tests/test_file_hints_and_id_discovery.py +0 -0
  128. {adeu-3.2.1 → adeu-3.2.2}/tests/test_fixtures_synth.py +0 -0
  129. {adeu-3.2.1 → adeu-3.2.2}/tests/test_fixtures_verification.py +0 -0
  130. {adeu-3.2.1 → adeu-3.2.2}/tests/test_flat_opc_to_docx.py +0 -0
  131. {adeu-3.2.1 → adeu-3.2.2}/tests/test_formatting.py +0 -0
  132. {adeu-3.2.1 → adeu-3.2.2}/tests/test_fused_json_hint.py +0 -0
  133. {adeu-3.2.1 → adeu-3.2.2}/tests/test_fuzzy_matching.py +0 -0
  134. {adeu-3.2.1 → adeu-3.2.2}/tests/test_fuzzy_whitespace_context.py +0 -0
  135. {adeu-3.2.1 → adeu-3.2.2}/tests/test_gemini_schema_compat.py +0 -0
  136. {adeu-3.2.1 → adeu-3.2.2}/tests/test_guard_message.py +0 -0
  137. {adeu-3.2.1 → adeu-3.2.2}/tests/test_header_block_guard.py +0 -0
  138. {adeu-3.2.1 → adeu-3.2.2}/tests/test_ingest_comments.py +0 -0
  139. {adeu-3.2.1 → adeu-3.2.2}/tests/test_json_unicode.py +0 -0
  140. {adeu-3.2.1 → adeu-3.2.2}/tests/test_lazy_snapshot.py +0 -0
  141. {adeu-3.2.1 → adeu-3.2.2}/tests/test_libreoffice_probe_isolation.py +0 -0
  142. {adeu-3.2.1 → adeu-3.2.2}/tests/test_live_word.py +0 -0
  143. {adeu-3.2.1 → adeu-3.2.2}/tests/test_live_word_content_controls.py +0 -0
  144. {adeu-3.2.1 → adeu-3.2.2}/tests/test_live_word_dispatch.py +0 -0
  145. {adeu-3.2.1 → adeu-3.2.2}/tests/test_live_word_gate_agreement.py +0 -0
  146. {adeu-3.2.1 → adeu-3.2.2}/tests/test_live_word_para_id_signed_int32.py +0 -0
  147. {adeu-3.2.1 → adeu-3.2.2}/tests/test_live_word_structured_insertion.py +0 -0
  148. {adeu-3.2.1 → adeu-3.2.2}/tests/test_local_script_paths.py +0 -0
  149. {adeu-3.2.1 → adeu-3.2.2}/tests/test_manual_markup_mode.py +0 -0
  150. {adeu-3.2.1 → adeu-3.2.2}/tests/test_marker_boundary_insert.py +0 -0
  151. {adeu-3.2.1 → adeu-3.2.2}/tests/test_markup.py +0 -0
  152. {adeu-3.2.1 → adeu-3.2.2}/tests/test_markup_fixes.py +0 -0
  153. {adeu-3.2.1 → adeu-3.2.2}/tests/test_mcp_apply_text_revision.py +0 -0
  154. {adeu-3.2.1 → adeu-3.2.2}/tests/test_mcp_diff_output.py +0 -0
  155. {adeu-3.2.1 → adeu-3.2.2}/tests/test_mcp_reasoning_optional.py +0 -0
  156. {adeu-3.2.1 → adeu-3.2.2}/tests/test_modern_comments.py +0 -0
  157. {adeu-3.2.1 → adeu-3.2.2}/tests/test_multi_run_comment_coalescing.py +0 -0
  158. {adeu-3.2.1 → adeu-3.2.2}/tests/test_natural_diffs.py +0 -0
  159. {adeu-3.2.1 → adeu-3.2.2}/tests/test_nested_markdown.py +0 -0
  160. {adeu-3.2.1 → adeu-3.2.2}/tests/test_no_chrome.py +0 -0
  161. {adeu-3.2.1 → adeu-3.2.2}/tests/test_onestep_compare.py +0 -0
  162. {adeu-3.2.1 → adeu-3.2.2}/tests/test_ooxml_schema_compliance.py +0 -0
  163. {adeu-3.2.1 → adeu-3.2.2}/tests/test_opc_document_types.py +0 -0
  164. {adeu-3.2.1 → adeu-3.2.2}/tests/test_outline_fast_equivalence.py +0 -0
  165. {adeu-3.2.1 → adeu-3.2.2}/tests/test_page_ranges.py +0 -0
  166. {adeu-3.2.1 → adeu-3.2.2}/tests/test_pagination_scanner_equivalence.py +0 -0
  167. {adeu-3.2.1 → adeu-3.2.2}/tests/test_paragraph_split_reject_parity.py +0 -0
  168. {adeu-3.2.1 → adeu-3.2.2}/tests/test_projection_goldens.py +0 -0
  169. {adeu-3.2.1 → adeu-3.2.2}/tests/test_properties.py +0 -0
  170. {adeu-3.2.1 → adeu-3.2.2}/tests/test_property_invariants.py +0 -0
  171. {adeu-3.2.1 → adeu-3.2.2}/tests/test_pure_comment_disk.py +0 -0
  172. {adeu-3.2.1 → adeu-3.2.2}/tests/test_read_ergonomics.py +0 -0
  173. {adeu-3.2.1 → adeu-3.2.2}/tests/test_regressions.py +0 -0
  174. {adeu-3.2.1 → adeu-3.2.2}/tests/test_reject_and_nested_redline.py +0 -0
  175. {adeu-3.2.1 → adeu-3.2.2}/tests/test_report_minimal.py +0 -0
  176. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_accept_all_changes_leak.py +0 -0
  177. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_accept_all_table_row_loss.py +0 -0
  178. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_accept_data_loss.py +0 -0
  179. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_batch_5.py +0 -0
  180. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_benchmark_schema_failures.py +0 -0
  181. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_block_marker_paragraph_parity.py +0 -0
  182. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_boilerplate_structure.py +0 -0
  183. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_chg_id_coalescing.py +0 -0
  184. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_comment_fragment_reject.py +0 -0
  185. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_comment_reference_style.py +0 -0
  186. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_comment_threading_anchoring_typography.py +0 -0
  187. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_comments_cleanup.py +0 -0
  188. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_comments_namespace_fix.py +0 -0
  189. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_customxml_missing_part.py +0 -0
  190. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_feedback_layer.py +0 -0
  191. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_feedback_observations.py +0 -0
  192. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_foreign_comment_range_modify.py +0 -0
  193. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_formatting_bugs.py +0 -0
  194. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_fte_2026_07_16.py +0 -0
  195. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_fte_2026_07_16_run2.py +0 -0
  196. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_fuzzy_regex_timeout.py +0 -0
  197. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_heading_bug.py +0 -0
  198. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_heading_section_insert.py +0 -0
  199. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_id_pool_renumber.py +0 -0
  200. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_indexed_edits.py +0 -0
  201. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_insertion_anchor.py +0 -0
  202. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_invisible_replies.py +0 -0
  203. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_issue157_tab_preservation.py +0 -0
  204. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_issue158_paragraph_delete_reject.py +0 -0
  205. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_issue159_comment_timestamp.py +0 -0
  206. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_issue23.py +0 -0
  207. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_issue_114_cross_part_revisions.py +0 -0
  208. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_list_matching.py +0 -0
  209. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_live_run_fragmentation.py +0 -0
  210. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_markdown_boundary.py +0 -0
  211. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_markdown_newline.py +0 -0
  212. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_marker_preserving_edits.py +0 -0
  213. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_multilevel_lists.py +0 -0
  214. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_multirun_deletion_ids.py +0 -0
  215. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_nested_corruption.py +0 -0
  216. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_nested_insertions.py +0 -0
  217. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_nested_table_row_cell_leakage.py +0 -0
  218. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_para_id_signed_int32.py +0 -0
  219. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_projection_parity_gaps.py +0 -0
  220. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_qa_customer_assessment_2026_07_23.py +0 -0
  221. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_qa_issues_v2.py +0 -0
  222. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_qa_mcp_2026_07_23_engine.py +0 -0
  223. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_qa_mcp_2026_07_23_mcp.py +0 -0
  224. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_qa_mcp_2026_07_23_rendering.py +0 -0
  225. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_qa_mcp_2026_07_23_reports.py +0 -0
  226. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_qa_mcp_issues_2026_07_20.py +0 -0
  227. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_qa_report.py +0 -0
  228. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_qa_report_cli_2026_07_22.py +0 -0
  229. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_qa_report_v2.py +0 -0
  230. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_qa_report_v3.py +0 -0
  231. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_qa_report_v4.py +0 -0
  232. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_qa_report_v6.py +0 -0
  233. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_qa_report_v7.py +0 -0
  234. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_qa_report_v8.py +0 -0
  235. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_qa_report_v9.py +0 -0
  236. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_qa_round3_2026_07_24.py +0 -0
  237. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_raw_ooxml_in_projection.py +0 -0
  238. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_redline_replay_wrong_document.py +0 -0
  239. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_rejected_batch_action_leak.py +0 -0
  240. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_report_boundary_failures.py +0 -0
  241. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_resolved_comment_marker_parity.py +0 -0
  242. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_round16_bugs.py +0 -0
  243. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_sdt_table_row_cell_invisibility.py +0 -0
  244. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_stranded_comment_anchor.py +0 -0
  245. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_surgical_word_diff.py +0 -0
  246. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_table_regressions.py +0 -0
  247. {adeu-3.2.1 → adeu-3.2.2}/tests/test_repro_v4_report.py +0 -0
  248. {adeu-3.2.1 → adeu-3.2.2}/tests/test_response_budget_guard.py +0 -0
  249. {adeu-3.2.1 → adeu-3.2.2}/tests/test_roundtrip.py +0 -0
  250. {adeu-3.2.1 → adeu-3.2.2}/tests/test_run_fusion_equivalence.py +0 -0
  251. {adeu-3.2.1 → adeu-3.2.2}/tests/test_safety.py +0 -0
  252. {adeu-3.2.1 → adeu-3.2.2}/tests/test_sanitize.py +0 -0
  253. {adeu-3.2.1 → adeu-3.2.2}/tests/test_search_paging.py +0 -0
  254. {adeu-3.2.1 → adeu-3.2.2}/tests/test_search_write_engine.py +0 -0
  255. {adeu-3.2.1 → adeu-3.2.2}/tests/test_serve_daemon.py +0 -0
  256. {adeu-3.2.1 → adeu-3.2.2}/tests/test_server.py +0 -0
  257. {adeu-3.2.1 → adeu-3.2.2}/tests/test_source_encoding.py +0 -0
  258. {adeu-3.2.1 → adeu-3.2.2}/tests/test_stringified_json_search_page.py +0 -0
  259. {adeu-3.2.1 → adeu-3.2.2}/tests/test_structural_edits.py +0 -0
  260. {adeu-3.2.1 → adeu-3.2.2}/tests/test_style_cache_optimization.py +0 -0
  261. {adeu-3.2.1 → adeu-3.2.2}/tests/test_styling_and_lists.py +0 -0
  262. {adeu-3.2.1 → adeu-3.2.2}/tests/test_substitution_cluster.py +0 -0
  263. {adeu-3.2.1 → adeu-3.2.2}/tests/test_table_creation.py +0 -0
  264. {adeu-3.2.1 → adeu-3.2.2}/tests/test_tables.py +0 -0
  265. {adeu-3.2.1 → adeu-3.2.2}/tests/test_terse_errors.py +0 -0
  266. {adeu-3.2.1 → adeu-3.2.2}/tests/test_tool_description_workflows.py +0 -0
  267. {adeu-3.2.1 → adeu-3.2.2}/tests/test_twin_projection_parity.py +0 -0
  268. {adeu-3.2.1 → adeu-3.2.2}/tests/test_whole_paragraph_delete_reject.py +0 -0
  269. {adeu-3.2.1 → adeu-3.2.2}/tests/test_workflow_blocking.py +0 -0
  270. {adeu-3.2.1 → adeu-3.2.2}/tests/test_xml_debug.py +0 -0
  271. {adeu-3.2.1 → adeu-3.2.2}/tests/utils.py +0 -0
  272. {adeu-3.2.1 → adeu-3.2.2}/tests/verify_sanitized.py +0 -0
  273. {adeu-3.2.1 → adeu-3.2.2}/tests/word_com.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: adeu
3
- Version: 3.2.1
3
+ Version: 3.2.2
4
4
  Summary: Automated DOCX Redlining Engine
5
5
  Project-URL: Homepage, https://adeu.ai
6
6
  Project-URL: Repository, https://github.com/dealfluence/adeu
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adeu"
3
- version = "3.2.1"
3
+ version = "3.2.2"
4
4
  description = "Automated DOCX Redlining Engine"
5
5
  authors = [
6
6
  { name = "Mikko Korpela" },
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/dealfluence/adeu",
7
7
  "source": "github"
8
8
  },
9
- "version": "3.2.1",
9
+ "version": "3.2.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@adeu/mcp-server",
14
- "version": "3.2.1",
14
+ "version": "3.2.2",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }
@@ -830,7 +830,13 @@ def handle_extract(args):
830
830
  # modes it must be a positive integer; anything else is a hard error,
831
831
  # never a silent fallback to page 1 (QA L1).
832
832
  page_num = 1
833
- want_all_pages = False
833
+ # Writing to a file (`-o <path>`, where `-o -` was already normalized to
834
+ # None above) defaults to the full document when `--page` is omitted:
835
+ # synthetic pagination protects stdout/LLM context windows, whereas file
836
+ # extraction is expected to contain the whole document.
837
+ want_all_pages = bool(
838
+ args.page is None and args.output and args.mode == "full" and not getattr(args, "search_query", None)
839
+ )
834
840
  is_page_range = False
835
841
  range_start = 1
836
842
  range_end = 1
@@ -1064,7 +1070,29 @@ def handle_extract(args):
1064
1070
  # extracted text otherwise. stdout stays quiet either way — printing
1065
1071
  # the full payload again surprised pipeline users (QA 2026-07-18 L4).
1066
1072
  _write_output_or_exit(args.output, json_output if json_output is not None else output_text)
1067
- print(f"Extracted {'JSON' if json_output is not None else 'text'} to {args.output}", file=sys.stderr)
1073
+ status_msg = f"Extracted {'JSON' if json_output is not None else 'text'} to {args.output}"
1074
+ if not getattr(args, "search_query", None) and args.mode == "full" and not want_all_pages:
1075
+ from adeu.pagination import PAGE_RANGE_MAX_PAGES, paginate, split_structural_appendix
1076
+
1077
+ total_pages = paginate(split_structural_appendix(text)[0], "").total_pages
1078
+ if is_page_range:
1079
+ shown_end = min(range_end, range_start + PAGE_RANGE_MAX_PAGES - 1, total_pages)
1080
+ if range_start > 1 or shown_end < total_pages:
1081
+ status_msg = (
1082
+ f"Extracted pages {range_start}-{shown_end} of {total_pages} to {args.output} "
1083
+ "(partial extract; pass --page all for the full document)"
1084
+ )
1085
+ elif total_pages > 1:
1086
+ status_msg = (
1087
+ f"Extracted page {page_num} of {total_pages} to {args.output} "
1088
+ "(partial extract; pass --page all for the full document)"
1089
+ )
1090
+ elif args.mode == "appendix" and "Continues on appendix page " in output_text:
1091
+ status_msg = (
1092
+ f"Extracted appendix page {page_num} to {args.output} "
1093
+ "(partial appendix extract; pass --page N for subsequent pages)"
1094
+ )
1095
+ print(status_msg, file=sys.stderr)
1068
1096
  elif json_output is not None:
1069
1097
  print(json_output)
1070
1098
  else:
@@ -1203,11 +1231,19 @@ def handle_diff(args):
1203
1231
  try:
1204
1232
  doc_orig = _load_docx_or_exit(args.original)
1205
1233
  text_orig, struct_orig = _extract_text_from_doc(
1206
- doc_orig, clean_view=compare_clean, include_appendix=False, return_structure=True
1234
+ doc_orig,
1235
+ clean_view=compare_clean,
1236
+ include_appendix=False,
1237
+ return_structure=True,
1238
+ resolve_numbering=False,
1207
1239
  )
1208
1240
  doc_mod = _load_docx_or_exit(args.modified)
1209
1241
  text_mod, struct_mod = _extract_text_from_doc(
1210
- doc_mod, clean_view=compare_clean, include_appendix=False, return_structure=True
1242
+ doc_mod,
1243
+ clean_view=compare_clean,
1244
+ include_appendix=False,
1245
+ return_structure=True,
1246
+ resolve_numbering=False,
1211
1247
  )
1212
1248
  except SystemExit:
1213
1249
  raise
@@ -2114,7 +2150,12 @@ def _main_impl():
2114
2150
  action="store_true",
2115
2151
  help=f"{live_help_prefix}Extract text from live active Word document",
2116
2152
  )
2117
- p_extract.add_argument("-o", "--output", type=Path, help="Output file ('-' or omitted: stdout)")
2153
+ p_extract.add_argument(
2154
+ "-o",
2155
+ "--output",
2156
+ type=Path,
2157
+ help="Output file ('-' or omitted: stdout; defaults --page to 'all' in full mode when omitted)",
2158
+ )
2118
2159
  p_extract.add_argument(
2119
2160
  "--force",
2120
2161
  action="store_true",
@@ -2159,11 +2200,10 @@ def _main_impl():
2159
2200
  type=str,
2160
2201
  default=None,
2161
2202
  help=(
2162
- "Page number (1-indexed) for 'full' and 'appendix' modes (defaults to 1), "
2163
- "or 'all' to emit the entire document in one output (mode 'full' and search). "
2164
- "Use '--page all' when producing a text file for `adeu apply`/`adeu diff` — "
2165
- "a single page of a multi-page document cannot round-trip. Note: pages are "
2166
- "synthetic, length-based content chunks sized for LLM consumption — they do "
2203
+ "Page number (1-indexed) for 'full' and 'appendix' modes (defaults to 1 on stdout and 'all' "
2204
+ "when writing to -o <file>), or 'all' to emit the entire document in one output (mode 'full' "
2205
+ "and search). A single page of a multi-page document cannot round-trip into `adeu apply`/`adeu diff`. "
2206
+ "Note: pages are synthetic, length-based content chunks sized for LLM consumption — they do "
2167
2207
  "NOT correspond to printed Word pages or explicit page breaks."
2168
2208
  ),
2169
2209
  )
@@ -72,9 +72,11 @@ def refuse_input_alias(output_path: PathLike, original_path: PathLike, modified_
72
72
  )
73
73
 
74
74
 
75
- def apply_comparison_edits(original: bytes, edits: Sequence[DiffEdit], author: str) -> "tuple[bytes, int]":
75
+ def apply_comparison_edits(
76
+ original: bytes, edits: Sequence[DiffEdit], author: str, resolve_numbering: bool = False
77
+ ) -> "tuple[bytes, int]":
76
78
  """Applies diff-generated edits to `original` as tracked changes; all of them or none."""
77
- engine = RedlineEngine(BytesIO(original), author=author)
79
+ engine = RedlineEngine(BytesIO(original), author=author, resolve_numbering=resolve_numbering)
78
80
  applied, skipped = engine.apply_edits(list(edits)) # type: ignore[arg-type]
79
81
  if skipped:
80
82
  raise ComparisonError(
@@ -96,10 +98,18 @@ def build_redline_comparison(
96
98
  """Diffs two DOCX packages part by part and writes the result as a redline of `original`."""
97
99
  # Appendix excluded: its generated text is not writable content (QA 2026-07-18 H1).
98
100
  text_o, struct_o = _extract_text_from_doc(
99
- Document(BytesIO(original)), clean_view=compare_clean, include_appendix=False, return_structure=True
101
+ Document(BytesIO(original)),
102
+ clean_view=compare_clean,
103
+ include_appendix=False,
104
+ return_structure=True,
105
+ resolve_numbering=False,
100
106
  )
101
107
  text_m, struct_m = _extract_text_from_doc(
102
- Document(BytesIO(modified)), clean_view=compare_clean, include_appendix=False, return_structure=True
108
+ Document(BytesIO(modified)),
109
+ clean_view=compare_clean,
110
+ include_appendix=False,
111
+ return_structure=True,
112
+ resolve_numbering=False,
103
113
  )
104
114
  edits, warnings = generate_structured_edits(text_o, struct_o, text_m, struct_m) # type: ignore[arg-type]
105
115
  warnings = list(warnings) + collect_media_difference_warnings(original, modified)
@@ -9,6 +9,9 @@ from typing import Any, Dict, List, Optional, Tuple, Union
9
9
  from adeu import __version__
10
10
  from adeu.outline import OutlineNode
11
11
 
12
+ # Increment whenever Virtual Text projection rules change without a package version bump.
13
+ _CACHE_SCHEMA_VERSION = 2
14
+
12
15
 
13
16
  def get_default_cache_dir() -> Path:
14
17
  if env_dir := os.environ.get("ADEU_CACHE_DIR"):
@@ -125,6 +128,7 @@ class DiskProjectionCache:
125
128
  or key.get("mtime_ns") != mtime_ns
126
129
  or key.get("size") != size
127
130
  or key.get("version") != __version__
131
+ or key.get("schema") != _CACHE_SCHEMA_VERSION
128
132
  ):
129
133
  self._misses += 1
130
134
  return None
@@ -165,6 +169,7 @@ class DiskProjectionCache:
165
169
  "mtime_ns": mtime_ns,
166
170
  "size": size,
167
171
  "version": __version__,
172
+ "schema": _CACHE_SCHEMA_VERSION,
168
173
  }
169
174
 
170
175
  data: Dict[str, Any] = {"key": key_dict, "views": {}}
@@ -25,6 +25,7 @@ from adeu.utils.content_controls import (
25
25
  )
26
26
  from adeu.utils.docx import (
27
27
  DocxEvent,
28
+ NumberingTracker,
28
29
  ProjectedRun,
29
30
  _get_style_cache,
30
31
  apply_formatting_to_segments,
@@ -95,6 +96,7 @@ def extract_text_from_stream(
95
96
  filename: str = "document.docx",
96
97
  clean_view: bool = False,
97
98
  include_appendix: bool = True,
99
+ resolve_numbering: bool = True,
98
100
  ) -> str:
99
101
  """
100
102
  Extracts text from a file stream using raw run concatenation.
@@ -106,6 +108,9 @@ def extract_text_from_stream(
106
108
  include_appendix: If False, omits the generated read-only structural
107
109
  appendix — required whenever the text feeds a comparison
108
110
  (QA 2026-07-18 H1).
111
+ resolve_numbering: If True (default), emits resolved OOXML clause/list
112
+ numbers; if False, emits canonical '1. ' markers for
113
+ DOCX-to-DOCX comparison.
109
114
 
110
115
  CRITICAL: This must match DocumentMapper._build_map logic exactly.
111
116
  """
@@ -114,7 +119,12 @@ def extract_text_from_stream(
114
119
 
115
120
  sanitized_bytes = strip_bom_from_docx_bytes(file_stream.read())
116
121
  doc = Document(io.BytesIO(sanitized_bytes))
117
- return _extract_text_from_doc(doc, clean_view, include_appendix=include_appendix)
122
+ return _extract_text_from_doc(
123
+ doc,
124
+ clean_view,
125
+ include_appendix=include_appendix,
126
+ resolve_numbering=resolve_numbering,
127
+ )
118
128
  except Exception as e:
119
129
  logger.error(f"Text extraction failed: {e}", exc_info=True)
120
130
  raise ValueError(f"Could not extract text: {str(e)}") from e
@@ -126,6 +136,7 @@ def _extract_text_from_doc(
126
136
  include_appendix: bool = True,
127
137
  return_paragraph_offsets: bool = False,
128
138
  return_structure: bool = False,
139
+ resolve_numbering: bool = True,
129
140
  ):
130
141
  """
131
142
  Extracts text from an already-loaded python-docx Document.
@@ -177,6 +188,11 @@ def _extract_text_from_doc(
177
188
  # part_cursor accounts for the \n\n separator that will precede this part
178
189
  # in the final join, ensuring internal offsets align exactly.
179
190
  part_cursor = cursor + 2 if full_text else cursor
191
+ part_ref = getattr(part, "part", part)
192
+ part_style_cache, part_default_pstyle = _get_style_cache(part_ref)
193
+ numbering_tracker = NumberingTracker(
194
+ part_ref, style_cache=part_style_cache, resolve_numbering=resolve_numbering
195
+ )
180
196
  part_text = _extract_blocks(
181
197
  part,
182
198
  comments_map,
@@ -184,7 +200,12 @@ def _extract_text_from_doc(
184
200
  offset_map=offset_map,
185
201
  cursor=part_cursor,
186
202
  table_acc=structure.tables if structure is not None else None,
203
+ style_cache=part_style_cache,
204
+ default_pstyle=part_default_pstyle,
205
+ part=part_ref,
187
206
  sdt_infos=sdt_infos,
207
+ numbering_tracker=numbering_tracker,
208
+ resolve_numbering=resolve_numbering,
188
209
  )
189
210
  if part_text:
190
211
  if full_text:
@@ -223,6 +244,8 @@ def _extract_blocks(
223
244
  part: Any = None,
224
245
  sdt_infos: dict | None = None,
225
246
  in_cell: bool = False,
247
+ numbering_tracker: NumberingTracker | None = None,
248
+ resolve_numbering: bool = True,
226
249
  ) -> str:
227
250
  """
228
251
  Recursively extracts text from a container (Document, Cell, Header, etc.)
@@ -236,6 +259,8 @@ def _extract_blocks(
236
259
  part = getattr(container, "part", container)
237
260
  if style_cache is None:
238
261
  style_cache, default_pstyle = _get_style_cache(part)
262
+ if numbering_tracker is None:
263
+ numbering_tracker = NumberingTracker(part, style_cache=style_cache, resolve_numbering=resolve_numbering)
239
264
 
240
265
  blocks = []
241
266
  local_cursor = cursor
@@ -290,6 +315,8 @@ def _extract_blocks(
290
315
  default_pstyle=default_pstyle,
291
316
  part=part,
292
317
  in_cell=in_cell,
318
+ numbering_tracker=numbering_tracker,
319
+ resolve_numbering=resolve_numbering,
293
320
  )
294
321
  if inner:
295
322
  full = f"{open_tok}{inner}{close_tok}"
@@ -312,6 +339,9 @@ def _extract_blocks(
312
339
  sdt_infos=sdt_infos,
313
340
  style_cache=style_cache,
314
341
  default_pstyle=default_pstyle,
342
+ part=part,
343
+ numbering_tracker=numbering_tracker,
344
+ resolve_numbering=resolve_numbering,
315
345
  )
316
346
  if fn_text:
317
347
  blocks.append(fn_text)
@@ -324,7 +354,15 @@ def _extract_blocks(
324
354
  local_cursor -= 2
325
355
  elif isinstance(item, Paragraph):
326
356
  p_elem = item._element
327
- style_prefix = get_paragraph_prefix(p_elem, style_cache, default_pstyle, part=part)
357
+ num_snap = numbering_tracker.snapshot()
358
+ style_prefix = get_paragraph_prefix(
359
+ p_elem,
360
+ style_cache,
361
+ default_pstyle,
362
+ part=part,
363
+ numbering_tracker=numbering_tracker,
364
+ resolve_numbering=resolve_numbering,
365
+ )
328
366
  prefix = style_prefix
329
367
  if is_first_para and c_type == "FootnoteItem":
330
368
  prefix = f"[^{container.note_type}-{container.id}]: " + prefix
@@ -345,6 +383,7 @@ def _extract_blocks(
345
383
  # paragraph away; when nothing visible survives inside it,
346
384
  # the accepted view must not render an empty container
347
385
  # (QA round 3, finding 2.4).
386
+ numbering_tracker.restore(num_snap)
348
387
  if not is_first_block:
349
388
  local_cursor -= 2
350
389
  is_first_para = False
@@ -375,6 +414,8 @@ def _extract_blocks(
375
414
  style_cache=style_cache,
376
415
  default_pstyle=default_pstyle,
377
416
  part=part,
417
+ numbering_tracker=numbering_tracker,
418
+ resolve_numbering=resolve_numbering,
378
419
  )
379
420
  if table_text:
380
421
  blocks.append(table_text)
@@ -402,6 +443,8 @@ def extract_table(
402
443
  default_pstyle: str | None = None,
403
444
  part: Any = None,
404
445
  sdt_infos: dict | None = None,
446
+ numbering_tracker: NumberingTracker | None = None,
447
+ resolve_numbering: bool = True,
405
448
  ) -> str:
406
449
  """
407
450
  Args:
@@ -463,6 +506,8 @@ def extract_table(
463
506
  default_pstyle=default_pstyle,
464
507
  part=part,
465
508
  in_cell=True,
509
+ numbering_tracker=numbering_tracker,
510
+ resolve_numbering=resolve_numbering,
466
511
  )
467
512
  if cell_info is not None:
468
513
  # Cell-level control (sdtContent > w:tc): anchors render inline
@@ -0,0 +1 @@
1
+ {"git_sha": "03500b9", "build_timestamp": "20261111103826"}
@@ -1004,10 +1004,18 @@ async def diff_docx_files(
1004
1004
  # diagnostics) is not document content — diffing it produces phantom
1005
1005
  # changes no apply can consume (QA 2026-07-18 H1).
1006
1006
  text_orig = extract_text_from_stream(
1007
- stream_orig, filename=Path(original_path).name, clean_view=compare_clean, include_appendix=False
1007
+ stream_orig,
1008
+ filename=Path(original_path).name,
1009
+ clean_view=compare_clean,
1010
+ include_appendix=False,
1011
+ resolve_numbering=False,
1008
1012
  )
1009
1013
  text_mod = extract_text_from_stream(
1010
- stream_mod, filename=Path(modified_path).name, clean_view=compare_clean, include_appendix=False
1014
+ stream_mod,
1015
+ filename=Path(modified_path).name,
1016
+ clean_view=compare_clean,
1017
+ include_appendix=False,
1018
+ resolve_numbering=False,
1011
1019
  )
1012
1020
  # atomic_criticmarkup: diff-display only — hunks must never cut into a
1013
1021
  # {--/{++/{>>/{== block (QA 2026-07-23 F15). No-op on clean text.
@@ -16,6 +16,8 @@ from adeu.utils.docx import (
16
16
  QN_W_FOOTNOTEREFERENCE,
17
17
  QN_W_P,
18
18
  DocxEvent,
19
+ NumberingTracker,
20
+ _get_style_cache,
19
21
  get_paragraph_prefix,
20
22
  iter_block_items,
21
23
  iter_document_parts,
@@ -50,6 +52,7 @@ class _BlockRecord:
50
52
  is_table: bool
51
53
  start_offset: int # offset in projected body where this block starts
52
54
  projected_length: int # length of the projected text for this block
55
+ prefix: str = ""
53
56
 
54
57
 
55
58
  # ---------------------------------------------------------------------------
@@ -145,7 +148,7 @@ def extract_outline(
145
148
  paragraph = rec.item
146
149
 
147
150
  level = _heading_level(paragraph)
148
- text = _heading_text(paragraph, comments_map)
151
+ text = _heading_text(paragraph, comments_map, prefix=rec.prefix)
149
152
  style = _determine_heading_style(paragraph)
150
153
 
151
154
  # Owned range: blocks strictly between this heading and the next equal-or-
@@ -476,10 +479,19 @@ def _walk_doc_body(doc: DocumentObject, comments_map: dict) -> List[_BlockRecord
476
479
  records: List[_BlockRecord] = []
477
480
  cursor = body_start_offset
478
481
  is_first_block = True
482
+ body_part_ref = getattr(body_part, "part", body_part)
483
+ body_style_cache, body_default_pstyle = _get_style_cache(body_part_ref)
484
+ body_tracker = NumberingTracker(body_part_ref, style_cache=body_style_cache)
479
485
 
480
486
  for item in iter_block_items(body_part):
481
487
  if isinstance(item, Paragraph):
482
- prefix = get_paragraph_prefix(item)
488
+ prefix = get_paragraph_prefix(
489
+ item,
490
+ style_cache=body_style_cache,
491
+ default_pstyle=body_default_pstyle,
492
+ part=body_part_ref,
493
+ numbering_tracker=body_tracker,
494
+ )
483
495
  p_text = build_paragraph_text(item, comments_map, clean_view=False)
484
496
  block_len = len(prefix + p_text)
485
497
 
@@ -493,13 +505,15 @@ def _walk_doc_body(doc: DocumentObject, comments_map: dict) -> List[_BlockRecord
493
505
  is_table=False,
494
506
  start_offset=cursor,
495
507
  projected_length=block_len,
508
+ prefix=prefix,
496
509
  )
497
510
  )
498
511
  cursor += block_len
499
512
  is_first_block = False
500
513
 
501
514
  elif isinstance(item, Table):
502
- table_text = extract_table(item, comments_map, clean_view=False)
515
+ snap = body_tracker.snapshot()
516
+ table_text = extract_table(item, comments_map, clean_view=False, numbering_tracker=body_tracker)
503
517
  block_len = len(table_text) if table_text else 0
504
518
 
505
519
  if not is_first_block:
@@ -515,9 +529,12 @@ def _walk_doc_body(doc: DocumentObject, comments_map: dict) -> List[_BlockRecord
515
529
  projected_length=block_len,
516
530
  )
517
531
  )
518
- # Record inner blocks for heading detection — but DO NOT call
519
- # build_paragraph_text on them (we don't need their lengths).
520
- _record_table_inner_blocks_lite(item, table_start, records, comments_map)
532
+ # Record inner blocks for heading detection — rewind a copy of
533
+ # body_tracker so inner heading prefixes match extract_table's pass.
534
+ after_table = body_tracker.snapshot()
535
+ body_tracker.restore(snap)
536
+ _record_table_inner_blocks_lite(item, table_start, records, comments_map, body_tracker)
537
+ body_tracker.restore(after_table)
521
538
  cursor += block_len
522
539
  is_first_block = False
523
540
 
@@ -678,17 +695,12 @@ def _record_table_inner_blocks_lite(
678
695
  inherited_offset: int,
679
696
  records: List[_BlockRecord],
680
697
  comments_map: dict,
698
+ numbering_tracker: Optional[NumberingTracker] = None,
681
699
  ) -> None:
682
700
  """
683
701
  PERF-optimized version of _record_table_inner_blocks: records inner
684
702
  paragraphs/tables for heading detection without paying the full projection
685
703
  cost for non-headings.
686
-
687
- Headings inside tables get TRUE offsets computed via _compute_inner_block_offset
688
- (so outline.page resolves correctly when a table spans page boundaries).
689
- Non-heading inner blocks keep projected_length=0 and inherit the table's
690
- start_offset — they are never used to resolve a page, only to scan for
691
- nested headings/tables.
692
704
  """
693
705
  seen_cells: set = set()
694
706
  for row in iter_table_rows(table):
@@ -700,10 +712,14 @@ def _record_table_inner_blocks_lite(
700
712
 
701
713
  for inner_item in iter_block_items(cell):
702
714
  if isinstance(inner_item, Paragraph):
703
- # Only pay the projection cost when the inner paragraph is
704
- # actually a heading — non-heading inner paragraphs do not
705
- # need a true offset (they're never assigned a page).
715
+ prefix = (
716
+ get_paragraph_prefix(inner_item, numbering_tracker=numbering_tracker)
717
+ if numbering_tracker is not None
718
+ else ""
719
+ )
706
720
  if _is_heading(inner_item):
721
+ if not prefix:
722
+ prefix = get_paragraph_prefix(inner_item)
707
723
  true_offset = _compute_inner_block_offset(table, inner_item, inherited_offset, comments_map)
708
724
  else:
709
725
  true_offset = inherited_offset
@@ -715,6 +731,7 @@ def _record_table_inner_blocks_lite(
715
731
  is_table=False,
716
732
  start_offset=true_offset,
717
733
  projected_length=0,
734
+ prefix=prefix,
718
735
  )
719
736
  )
720
737
  elif isinstance(inner_item, Table):
@@ -727,20 +744,22 @@ def _record_table_inner_blocks_lite(
727
744
  projected_length=0,
728
745
  )
729
746
  )
730
- _record_table_inner_blocks_lite(inner_item, inherited_offset, records, comments_map)
747
+ _record_table_inner_blocks_lite(
748
+ inner_item, inherited_offset, records, comments_map, numbering_tracker
749
+ )
731
750
 
732
751
 
733
- def _project_part(part, comments_map: dict) -> str:
752
+ def _project_part(part, comments_map: dict, numbering_tracker: Optional[NumberingTracker] = None) -> str:
734
753
  """
735
754
  Renders a non-body document part (header/footer/notes) to measure its
736
- projected length. Mirrors ingest._extract_blocks but inlined here so we
737
- don't reach into ingest's private helpers.
738
-
739
- Headers and footers iterate as Paragraphs/Tables. NotesPart is iterated
740
- via FootnoteItem children.
755
+ projected length. Mirrors ingest._extract_blocks.
741
756
  """
742
757
  blocks: List[str] = []
743
758
  c_type = type(part).__name__
759
+ part_ref = getattr(part, "part", part)
760
+ if numbering_tracker is None:
761
+ style_cache, _ = _get_style_cache(part_ref)
762
+ numbering_tracker = NumberingTracker(part_ref, style_cache=style_cache)
744
763
 
745
764
  if c_type == "NotesPart":
746
765
  header = "## Footnotes" if part.note_type == "fn" else "## Endnotes"
@@ -751,18 +770,18 @@ def _project_part(part, comments_map: dict) -> str:
751
770
  i_type = type(item).__name__
752
771
 
753
772
  if i_type == "FootnoteItem":
754
- fn_text = _project_part(item, comments_map)
773
+ fn_text = _project_part(item, comments_map, numbering_tracker=numbering_tracker)
755
774
  if fn_text:
756
775
  blocks.append(fn_text)
757
776
  elif isinstance(item, Paragraph):
758
- prefix = get_paragraph_prefix(item)
777
+ prefix = get_paragraph_prefix(item, part=part_ref, numbering_tracker=numbering_tracker)
759
778
  if is_first_para and c_type == "FootnoteItem":
760
779
  prefix = f"[^{part.note_type}-{part.id}]: " + prefix
761
780
  p_text = build_paragraph_text(item, comments_map, clean_view=False)
762
781
  blocks.append(prefix + p_text)
763
782
  is_first_para = False
764
783
  elif isinstance(item, Table):
765
- table_text = extract_table(item, comments_map, clean_view=False)
784
+ table_text = extract_table(item, comments_map, clean_view=False, numbering_tracker=numbering_tracker)
766
785
  if table_text:
767
786
  blocks.append(table_text)
768
787
  is_first_para = False
@@ -907,12 +926,12 @@ def _heading_level(paragraph: Paragraph) -> int:
907
926
  # ---------------------------------------------------------------------------
908
927
 
909
928
 
910
- def _heading_text(paragraph: Paragraph, comments_map: dict) -> str:
929
+ def _heading_text(paragraph: Paragraph, comments_map: dict, prefix: str = "") -> str:
911
930
  """
912
931
  Returns the heading text as the user would see it:
913
932
  - same projection as build_paragraph_text (so tracked changes / formatting
914
933
  look the same as in the body)
915
- - heading prefix removed
934
+ - heading '#' prefix removed (while preserving any resolved heading clause number)
916
935
  - CriticMarkup tags stripped (insertions kept as plain text, deletions
917
936
  and meta-blocks dropped)
918
937
  - internal newlines collapsed to a single space BEFORE marker stripping —
@@ -927,7 +946,8 @@ def _heading_text(paragraph: Paragraph, comments_map: dict) -> str:
927
946
  (QA 2026-07-23 F13b).
928
947
  """
929
948
  p_text = build_paragraph_text(paragraph, comments_map, clean_view=False)
930
- cleaned = _strip_critic_markup(p_text)
949
+ heading_num_label = re.sub(r"^#+\s+", "", prefix) if prefix else ""
950
+ cleaned = _strip_critic_markup(heading_num_label + p_text)
931
951
  cleaned = re.sub(r"\s*\n\s*", " ", cleaned)
932
952
  cleaned = _strip_inline_formatting(cleaned)
933
953
  return _truncate_outline_text(cleaned.strip())