any2md 1.0.4__tar.gz → 1.0.5__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 (124) hide show
  1. {any2md-1.0.4 → any2md-1.0.5}/CHANGELOG.md +37 -0
  2. {any2md-1.0.4 → any2md-1.0.5}/PKG-INFO +1 -1
  3. {any2md-1.0.4 → any2md-1.0.5}/any2md/__init__.py +1 -1
  4. {any2md-1.0.4 → any2md-1.0.5}/any2md/cli.py +11 -0
  5. {any2md-1.0.4 → any2md-1.0.5}/any2md/converters/docx.py +80 -6
  6. {any2md-1.0.4 → any2md-1.0.5}/any2md/pipeline/__init__.py +9 -0
  7. any2md-1.0.5/tests/integration/test_docx_fallback_on_warn.py +254 -0
  8. {any2md-1.0.4 → any2md-1.0.5}/.gitignore +0 -0
  9. {any2md-1.0.4 → any2md-1.0.5}/CODE_OF_CONDUCT.md +0 -0
  10. {any2md-1.0.4 → any2md-1.0.5}/CONTRIBUTING.md +0 -0
  11. {any2md-1.0.4 → any2md-1.0.5}/LICENSE +0 -0
  12. {any2md-1.0.4 → any2md-1.0.5}/README.md +0 -0
  13. {any2md-1.0.4 → any2md-1.0.5}/SECURITY.md +0 -0
  14. {any2md-1.0.4 → any2md-1.0.5}/any2md/__main__.py +0 -0
  15. {any2md-1.0.4 → any2md-1.0.5}/any2md/_docling.py +0 -0
  16. {any2md-1.0.4 → any2md-1.0.5}/any2md/config.py +0 -0
  17. {any2md-1.0.4 → any2md-1.0.5}/any2md/converters/__init__.py +0 -0
  18. {any2md-1.0.4 → any2md-1.0.5}/any2md/converters/html.py +0 -0
  19. {any2md-1.0.4 → any2md-1.0.5}/any2md/converters/pdf.py +0 -0
  20. {any2md-1.0.4 → any2md-1.0.5}/any2md/converters/txt.py +0 -0
  21. {any2md-1.0.4 → any2md-1.0.5}/any2md/frontmatter.py +0 -0
  22. {any2md-1.0.4 → any2md-1.0.5}/any2md/heuristics.py +0 -0
  23. {any2md-1.0.4 → any2md-1.0.5}/any2md/pipeline/cleanup.py +0 -0
  24. {any2md-1.0.4 → any2md-1.0.5}/any2md/pipeline/structured.py +0 -0
  25. {any2md-1.0.4 → any2md-1.0.5}/any2md/pipeline/text.py +0 -0
  26. {any2md-1.0.4 → any2md-1.0.5}/any2md/utils.py +0 -0
  27. {any2md-1.0.4 → any2md-1.0.5}/any2md/validators.py +0 -0
  28. {any2md-1.0.4 → any2md-1.0.5}/assets/logo-original.jpeg +0 -0
  29. {any2md-1.0.4 → any2md-1.0.5}/assets/logo.png +0 -0
  30. {any2md-1.0.4 → any2md-1.0.5}/assets/social-preview.jpg +0 -0
  31. {any2md-1.0.4 → any2md-1.0.5}/docs/architecture.md +0 -0
  32. {any2md-1.0.4 → any2md-1.0.5}/docs/cli-reference.md +0 -0
  33. {any2md-1.0.4 → any2md-1.0.5}/docs/output-format.md +0 -0
  34. {any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/plans/2026-04-26-any2md-v1-phase1-foundation.md +0 -0
  35. {any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/plans/2026-04-26-any2md-v1-phase2-docling-backend.md +0 -0
  36. {any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/plans/2026-04-26-any2md-v1-phase3-figures-ocr-text-lane.md +0 -0
  37. {any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/plans/2026-04-26-any2md-v1-phase4-polish-config-docs.md +0 -0
  38. {any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/plans/2026-04-26-any2md-v1.0.2-implementation.md +0 -0
  39. {any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/plans/2026-04-26-issue-17-toc-followups.md +0 -0
  40. {any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/specs/2026-04-26-any2md-v1-design.md +0 -0
  41. {any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md +0 -0
  42. {any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/specs/2026-04-26-issue-17-toc-followups-design.md +0 -0
  43. {any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/specs/2026-04-26-v1.0.3-empty-title-orphan-punct-design.md +0 -0
  44. {any2md-1.0.4 → any2md-1.0.5}/docs/troubleshooting.md +0 -0
  45. {any2md-1.0.4 → any2md-1.0.5}/docs/upgrading-from-0.7.md +0 -0
  46. {any2md-1.0.4 → any2md-1.0.5}/pyproject.toml +0 -0
  47. {any2md-1.0.4 → any2md-1.0.5}/requirements.txt +0 -0
  48. {any2md-1.0.4 → any2md-1.0.5}/scripts/audit-outputs.py +0 -0
  49. {any2md-1.0.4 → any2md-1.0.5}/scripts/validate-phase1.sh +0 -0
  50. {any2md-1.0.4 → any2md-1.0.5}/tests/__init__.py +0 -0
  51. {any2md-1.0.4 → any2md-1.0.5}/tests/cli/__init__.py +0 -0
  52. {any2md-1.0.4 → any2md-1.0.5}/tests/cli/test_cli_backend.py +0 -0
  53. {any2md-1.0.4 → any2md-1.0.5}/tests/cli/test_cli_existing_flags.py +0 -0
  54. {any2md-1.0.4 → any2md-1.0.5}/tests/cli/test_cli_exit_codes.py +0 -0
  55. {any2md-1.0.4 → any2md-1.0.5}/tests/cli/test_cli_high_fidelity.py +0 -0
  56. {any2md-1.0.4 → any2md-1.0.5}/tests/cli/test_cli_meta.py +0 -0
  57. {any2md-1.0.4 → any2md-1.0.5}/tests/cli/test_cli_no_arxiv_lookup.py +0 -0
  58. {any2md-1.0.4 → any2md-1.0.5}/tests/cli/test_cli_ocr_save_images.py +0 -0
  59. {any2md-1.0.4 → any2md-1.0.5}/tests/cli/test_cli_quiet_verbose.py +0 -0
  60. {any2md-1.0.4 → any2md-1.0.5}/tests/cli/test_cli_strict.py +0 -0
  61. {any2md-1.0.4 → any2md-1.0.5}/tests/conftest.py +0 -0
  62. {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/__init__.py +0 -0
  63. {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/docs/ligatures_and_softhyphens.txt +0 -0
  64. {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/docs/multi_column.pdf +0 -0
  65. {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/docs/table_heavy.docx +0 -0
  66. {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/docs/web_page.html +0 -0
  67. {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/make_fixtures.py +0 -0
  68. {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/snapshots/ligatures_and_softhyphens.md +0 -0
  69. {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/snapshots/multi_column.docling.md +0 -0
  70. {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/snapshots/multi_column.fallback.md +0 -0
  71. {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/snapshots/table_heavy.docling.md +0 -0
  72. {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/snapshots/table_heavy.fallback.md +0 -0
  73. {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/snapshots/web_page.md +0 -0
  74. {any2md-1.0.4 → any2md-1.0.5}/tests/integration/__init__.py +0 -0
  75. {any2md-1.0.4 → any2md-1.0.5}/tests/integration/test_docx_backend_override.py +0 -0
  76. {any2md-1.0.4 → any2md-1.0.5}/tests/integration/test_docx_docling.py +0 -0
  77. {any2md-1.0.4 → any2md-1.0.5}/tests/integration/test_docx_mammoth_fallback.py +0 -0
  78. {any2md-1.0.4 → any2md-1.0.5}/tests/integration/test_html_trafilatura.py +0 -0
  79. {any2md-1.0.4 → any2md-1.0.5}/tests/integration/test_pdf_backend_override.py +0 -0
  80. {any2md-1.0.4 → any2md-1.0.5}/tests/integration/test_pdf_docling.py +0 -0
  81. {any2md-1.0.4 → any2md-1.0.5}/tests/integration/test_pdf_pymupdf_fallback.py +0 -0
  82. {any2md-1.0.4 → any2md-1.0.5}/tests/integration/test_save_images.py +0 -0
  83. {any2md-1.0.4 → any2md-1.0.5}/tests/integration/test_snapshots.py +0 -0
  84. {any2md-1.0.4 → any2md-1.0.5}/tests/integration/test_txt.py +0 -0
  85. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/__init__.py +0 -0
  86. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/__init__.py +0 -0
  87. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_footnote_markers.py +0 -0
  88. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_html_entities.py +0 -0
  89. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_html_entities_double_encoded.py +0 -0
  90. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_ligatures.py +0 -0
  91. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_nfc.py +0 -0
  92. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_quotes_dashes.py +0 -0
  93. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_soft_hyphens.py +0 -0
  94. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_validate.py +0 -0
  95. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_whitespace.py +0 -0
  96. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_runner.py +0 -0
  97. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_strip_orphan_punctuation.py +0 -0
  98. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_structured_body_cleanup_stages.py +0 -0
  99. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_structured_compact_tables.py +0 -0
  100. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_structured_heading_hierarchy.py +0 -0
  101. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_structured_lift_figures.py +0 -0
  102. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_structured_normalize_cites.py +0 -0
  103. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_text_dedupe_paragraphs.py +0 -0
  104. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_text_dedupe_toc.py +0 -0
  105. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_text_dedupe_toc_table.py +0 -0
  106. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_text_dehyphenate.py +0 -0
  107. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_text_repair_line_wraps.py +0 -0
  108. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_text_restore_lists_code.py +0 -0
  109. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_text_strip_cover_artifacts.py +0 -0
  110. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_text_strip_headers_footers.py +0 -0
  111. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_text_strip_repeated_byline.py +0 -0
  112. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_text_strip_web_fragments.py +0 -0
  113. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/scripts/__init__.py +0 -0
  114. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/scripts/test_audit_outputs.py +0 -0
  115. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/test_auto_id.py +0 -0
  116. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/test_config.py +0 -0
  117. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/test_content_hash.py +0 -0
  118. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/test_docling_helper.py +0 -0
  119. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/test_frontmatter_compose.py +0 -0
  120. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/test_frontmatter_helpers.py +0 -0
  121. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/test_heuristics.py +0 -0
  122. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/test_meta_overrides.py +0 -0
  123. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/test_pdf_complexity.py +0 -0
  124. {any2md-1.0.4 → any2md-1.0.5}/tests/unit/test_validators.py +0 -0
@@ -4,6 +4,43 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [1.0.5] — 2026-04-27
8
+
9
+ Patch release. Recovers DOCX content that Docling's msword backend
10
+ silently drops on malformed list structures, by auto-retrying
11
+ affected files through the mammoth lane and forwarding the
12
+ captured Docling warnings into any2md's run-level warning bucket.
13
+ Real-world impact observed during validation: a 12,740-word
14
+ Docling output for a policy-template DOCX recovered to 15,566
15
+ words (+22%) when re-rendered via mammoth.
16
+
17
+ ### Added
18
+ - DOCX converter now auto-retries files through the mammoth lane
19
+ when Docling emits any warning from
20
+ `docling.backend.msword_backend` during conversion. Docling's
21
+ DOCX backend silently drops list items in a known
22
+ malformed-input path (`msword_backend.py:1377/1675` —
23
+ "Parent element of the list item is not a ListGroup. The list
24
+ item will be ignored."), so a warning means the Docling
25
+ Markdown is missing content. On fallback, `extracted_via` is
26
+ set to `"docling→mammoth (warning fallback)"`, the lane
27
+ switches to `text`, the captured Docling warnings are
28
+ forwarded into the run-level warning bucket (so `--strict`
29
+ still fails), and a `FALLBACK:` line is printed to stderr.
30
+ Default: enabled. Disable with `--no-docx-fallback-on-warn`
31
+ (e.g. when comparing backends explicitly).
32
+ - New `PipelineOptions.docx_fallback_on_warn: bool = True`.
33
+ - New CLI flag pair `--docx-fallback-on-warn` /
34
+ `--no-docx-fallback-on-warn`.
35
+
36
+ ### Tests
37
+ - New `tests/integration/test_docx_fallback_on_warn.py` covers
38
+ the warning-capture context manager, the default-on fallback
39
+ path (output swap, `extracted_via`, captured warning forwarded
40
+ into `collected_warnings()`, user-visible message), the
41
+ `--no-docx-fallback-on-warn` opt-out, the no-warning
42
+ fast-path, and the explicit `--backend docling` interaction.
43
+
7
44
  ## [1.0.4] — 2026-04-26
8
45
 
9
46
  Patch release. Two follow-ups deferred from v1.0.3 (#17): the T7
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: any2md
3
- Version: 1.0.4
3
+ Version: 1.0.5
4
4
  Summary: Convert PDF, DOCX, HTML, and TXT files — or web pages by URL — to clean, LLM-optimized Markdown with YAML frontmatter.
5
5
  Project-URL: Homepage, https://github.com/rocklambros/any2md
6
6
  Project-URL: Issues, https://github.com/rocklambros/any2md/issues
@@ -1,3 +1,3 @@
1
1
  """Convert PDF, DOCX, HTML, and TXT files to LLM-optimized Markdown."""
2
2
 
3
- __version__ = "1.0.4"
3
+ __version__ = "1.0.5"
@@ -169,6 +169,16 @@ def main():
169
169
  action="store_true",
170
170
  help="Promote pipeline validation warnings to errors (exit 3).",
171
171
  )
172
+ parser.add_argument(
173
+ "--docx-fallback-on-warn",
174
+ action=argparse.BooleanOptionalAction,
175
+ default=True,
176
+ help="DOCX-only. When Docling emits any msword_backend warning "
177
+ "during conversion, auto-retry the file through mammoth and use "
178
+ "that output instead. Docling silently drops list items on "
179
+ "malformed input, so a warning means content is missing. "
180
+ "Default: enabled. Disable with --no-docx-fallback-on-warn.",
181
+ )
172
182
  parser.add_argument(
173
183
  "--quiet",
174
184
  "-q",
@@ -272,6 +282,7 @@ def main():
272
282
  frontmatter_overrides=overrides or None,
273
283
  backend=args.backend,
274
284
  arxiv_lookup=not args.no_arxiv_lookup,
285
+ docx_fallback_on_warn=args.docx_fallback_on_warn,
275
286
  )
276
287
 
277
288
  # CLI-only output controls (not part of PipelineOptions).
@@ -9,6 +9,7 @@ produces the markdown body.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import logging
12
13
  import sys
13
14
  import xml.etree.ElementTree as ET
14
15
  import zipfile
@@ -26,6 +27,46 @@ from any2md.heuristics import filter_organization
26
27
  from any2md.pipeline import PipelineOptions
27
28
  from any2md.utils import sanitize_filename
28
29
 
30
+
31
+ _DOCLING_MSWORD_LOGGER = "docling.backend.msword_backend"
32
+
33
+
34
+ class _DoclingMswordWarningCapture:
35
+ """Buffer WARNING+ records from Docling's DOCX backend logger.
36
+
37
+ Used as a context manager around a Docling DOCX conversion to detect
38
+ upstream's silent list-item-drop guard (msword_backend.py:1377/1675).
39
+ Any captured records signal that Docling's Markdown output is
40
+ incomplete; ``convert_docx`` consults this to decide whether to
41
+ auto-retry with the mammoth lane.
42
+ """
43
+
44
+ def __init__(self) -> None:
45
+ self._records: list[logging.LogRecord] = []
46
+ self._handler: logging.Handler | None = None
47
+
48
+ def __enter__(self) -> _DoclingMswordWarningCapture:
49
+ records = self._records
50
+
51
+ class _BufferHandler(logging.Handler):
52
+ def emit(self, record: logging.LogRecord) -> None:
53
+ records.append(record)
54
+
55
+ handler = _BufferHandler(level=logging.WARNING)
56
+ logging.getLogger(_DOCLING_MSWORD_LOGGER).addHandler(handler)
57
+ self._handler = handler
58
+ return self
59
+
60
+ def __exit__(self, *_exc: object) -> None:
61
+ if self._handler is not None:
62
+ logging.getLogger(_DOCLING_MSWORD_LOGGER).removeHandler(self._handler)
63
+ self._handler = None
64
+
65
+ @property
66
+ def messages(self) -> list[str]:
67
+ return [r.getMessage() for r in self._records]
68
+
69
+
29
70
  _NS_CORE = {
30
71
  "dc": "http://purl.org/dc/elements/1.1/",
31
72
  "cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties",
@@ -90,13 +131,19 @@ def _read_docx_metadata(docx_path: Path) -> dict[str, object]:
90
131
  return out
91
132
 
92
133
 
93
- def _extract_via_docling(docx_path: Path) -> tuple[str, str]:
94
- """Returns (markdown, 'docling'). Raises on Docling errors."""
134
+ def _extract_via_docling(docx_path: Path) -> tuple[str, str, list[str]]:
135
+ """Returns (markdown, 'docling', captured_warnings).
136
+
137
+ ``captured_warnings`` is the list of WARNING+ messages emitted by
138
+ Docling's ``msword_backend`` logger during the conversion. It is
139
+ empty for clean runs. Raises on Docling errors.
140
+ """
95
141
  from docling.document_converter import DocumentConverter
96
142
 
97
143
  converter = DocumentConverter()
98
- result = converter.convert(str(docx_path))
99
- return result.document.export_to_markdown(), "docling"
144
+ with _DoclingMswordWarningCapture() as cap:
145
+ result = converter.convert(str(docx_path))
146
+ return result.document.export_to_markdown(), "docling", list(cap.messages)
100
147
 
101
148
 
102
149
  def _extract_via_mammoth(docx_path: Path, options: PipelineOptions) -> tuple[str, str]:
@@ -139,15 +186,18 @@ def convert_docx(
139
186
  )
140
187
  return False
141
188
 
189
+ docling_warnings: list[str] = []
142
190
  if options.backend == "mammoth":
143
191
  md_text, extracted_via = _extract_via_mammoth(docx_path, options)
144
192
  lane = "text"
145
193
  elif options.backend == "docling":
146
- md_text, extracted_via = _extract_via_docling(docx_path)
194
+ md_text, extracted_via, docling_warnings = _extract_via_docling(docx_path)
147
195
  lane = "structured"
148
196
  elif has_docling():
149
197
  try:
150
- md_text, extracted_via = _extract_via_docling(docx_path)
198
+ md_text, extracted_via, docling_warnings = _extract_via_docling(
199
+ docx_path
200
+ )
151
201
  lane = "structured"
152
202
  except Exception as e: # noqa: BLE001 — fall back rather than fail
153
203
  print(
@@ -157,10 +207,34 @@ def convert_docx(
157
207
  )
158
208
  md_text, extracted_via = _extract_via_mammoth(docx_path, options)
159
209
  lane = "text"
210
+ docling_warnings = []
160
211
  else:
161
212
  md_text, extracted_via = _extract_via_mammoth(docx_path, options)
162
213
  lane = "text"
163
214
 
215
+ # v1.0.5: Docling's DOCX backend silently drops list items in a
216
+ # known malformed-input path. When any msword_backend warning
217
+ # fires, swap the Docling output for the mammoth lane (different
218
+ # parser, generally more permissive). Captured warnings are
219
+ # forwarded into the run-level warning bucket so --strict still
220
+ # fails on them.
221
+ if (
222
+ docling_warnings
223
+ and options.docx_fallback_on_warn
224
+ and extracted_via == "docling"
225
+ ):
226
+ print(
227
+ f" FALLBACK: {docx_path.name} -- Docling emitted "
228
+ f"{len(docling_warnings)} msword_backend warning(s); "
229
+ f"re-running via mammoth (content may have been dropped "
230
+ f"by Docling).",
231
+ file=sys.stderr,
232
+ )
233
+ md_text, _ = _extract_via_mammoth(docx_path, options)
234
+ extracted_via = "docling→mammoth (warning fallback)"
235
+ lane = "text"
236
+ add_warnings([f"docling.msword_backend: {m}" for m in docling_warnings])
237
+
164
238
  md_text, warnings = pipeline.run(md_text, lane, options)
165
239
  add_warnings(warnings)
166
240
 
@@ -49,6 +49,15 @@ class PipelineOptions:
49
49
  # ``--no-arxiv-lookup`` to disable the network call (airgapped envs
50
50
  # or when offline behavior is required).
51
51
  arxiv_lookup: bool = True
52
+ # v1.0.5: DOCX-only. When True (default) and Docling emits any
53
+ # WARNING from ``docling.backend.msword_backend`` during conversion,
54
+ # any2md re-runs the file through the mammoth+markdownify lane and
55
+ # uses that output. Docling silently drops list items in a known
56
+ # malformed-input path (msword_backend.py:1377/1675), so a Docling
57
+ # warning means the Markdown is missing content. Disable via
58
+ # ``--no-docx-fallback-on-warn`` to keep Docling output even when
59
+ # warnings fire (e.g. when comparing backends explicitly).
60
+ docx_fallback_on_warn: bool = True
52
61
 
53
62
 
54
63
  Stage = Callable[[str, PipelineOptions], str]
@@ -0,0 +1,254 @@
1
+ """Integration tests for the Docling→mammoth auto-retry on msword warnings.
2
+
3
+ Docling's DOCX backend silently drops list items when their tracked
4
+ parent isn't a `ListGroup` (msword_backend.py:1377/1675). When that
5
+ warning fires we want any2md to auto-retry the file with mammoth, which
6
+ uses a different parsing path. These tests stub `_extract_via_docling`
7
+ so they don't depend on a real DOCX that reproduces the upstream bug.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import zipfile
14
+ from pathlib import Path
15
+
16
+ import yaml
17
+
18
+ import any2md.converters.docx as docx_mod
19
+ from any2md.converters import collected_warnings, reset_warnings
20
+ from any2md.converters.docx import (
21
+ _DoclingMswordWarningCapture,
22
+ convert_docx,
23
+ )
24
+ from any2md.pipeline import PipelineOptions
25
+
26
+
27
+ _CONTENT_TYPES = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
28
+ <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
29
+ <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
30
+ <Default Extension="xml" ContentType="application/xml"/>
31
+ <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
32
+ <Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
33
+ <Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
34
+ </Types>
35
+ """
36
+
37
+ _RELS = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
38
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
39
+ <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
40
+ <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
41
+ <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
42
+ </Relationships>
43
+ """
44
+
45
+ _CORE = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
46
+ <cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
47
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
48
+ xmlns:dcterms="http://purl.org/dc/terms/">
49
+ <dc:title>Listy Doc</dc:title>
50
+ <dc:creator>Test Author</dc:creator>
51
+ <dcterms:modified xsi:type="dcterms:W3CDTF" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">2026-04-26T00:00:00Z</dcterms:modified>
52
+ </cp:coreProperties>
53
+ """
54
+
55
+ _APP = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
56
+ <Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties">
57
+ </Properties>
58
+ """
59
+
60
+ # Minimal document with one heading, one paragraph, one (mammoth-parseable)
61
+ # bullet list — enough that mammoth produces a non-trivial Markdown body.
62
+ _DOCUMENT = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
63
+ <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
64
+ <w:body>
65
+ <w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Listy Doc</w:t></w:r></w:p>
66
+ <w:p><w:r><w:t>Body before list.</w:t></w:r></w:p>
67
+ <w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>Item one</w:t></w:r></w:p>
68
+ <w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>Item two</w:t></w:r></w:p>
69
+ </w:body>
70
+ </w:document>
71
+ """
72
+
73
+
74
+ def _build_listy_docx(out_path: Path) -> None:
75
+ with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as z:
76
+ z.writestr("[Content_Types].xml", _CONTENT_TYPES)
77
+ z.writestr("_rels/.rels", _RELS)
78
+ z.writestr("docProps/core.xml", _CORE)
79
+ z.writestr("docProps/app.xml", _APP)
80
+ z.writestr("word/document.xml", _DOCUMENT)
81
+
82
+
83
+ # ---------- _DoclingMswordWarningCapture (unit-ish behavior) -------------
84
+
85
+
86
+ def test_capture_collects_warning_records():
87
+ cap = _DoclingMswordWarningCapture()
88
+ log = logging.getLogger("docling.backend.msword_backend")
89
+ with cap:
90
+ log.warning("Parent element of the list item is not a ListGroup. Ignored.")
91
+ log.info("not captured (below WARNING)")
92
+ assert len(cap.messages) == 1
93
+ assert "ListGroup" in cap.messages[0]
94
+
95
+
96
+ def test_capture_handler_detached_after_exit():
97
+ cap = _DoclingMswordWarningCapture()
98
+ log = logging.getLogger("docling.backend.msword_backend")
99
+ before = list(log.handlers)
100
+ with cap:
101
+ pass
102
+ assert log.handlers == before
103
+
104
+
105
+ def test_capture_records_from_child_loggers():
106
+ """Child-logger records propagate up; capture must see them."""
107
+ cap = _DoclingMswordWarningCapture()
108
+ child = logging.getLogger("docling.backend.msword_backend.sub")
109
+ with cap:
110
+ child.warning("nested warning")
111
+ assert any("nested warning" in m for m in cap.messages)
112
+
113
+
114
+ # ---------- convert_docx fallback behavior --------------------------------
115
+
116
+
117
+ def _stub_docling_with_warning(
118
+ monkeypatch,
119
+ message: str = "Parent element of the list item is not a ListGroup. The list item will be ignored.",
120
+ ):
121
+ """Make `_extract_via_docling` emit a docling msword warning and return stub MD."""
122
+ log = logging.getLogger("docling.backend.msword_backend")
123
+
124
+ def _fake(docx_path):
125
+ # Use the real capture machinery: emit through the same logger
126
+ # the capture is attached to.
127
+ log.warning(message)
128
+ return "# Docling output\n", "docling", [message]
129
+
130
+ monkeypatch.setattr(docx_mod, "_extract_via_docling", _fake)
131
+
132
+
133
+ def test_fallback_fires_by_default(tmp_path, tmp_output_dir, monkeypatch, capsys):
134
+ monkeypatch.setattr(docx_mod, "has_docling", lambda: True)
135
+
136
+ def _fake(docx_path):
137
+ return (
138
+ "# Docling output\n",
139
+ "docling",
140
+ ["Parent element of the list item is not a ListGroup. Ignored."],
141
+ )
142
+
143
+ monkeypatch.setattr(docx_mod, "_extract_via_docling", _fake)
144
+
145
+ docx = tmp_path / "listy.docx"
146
+ _build_listy_docx(docx)
147
+
148
+ reset_warnings()
149
+ ok = convert_docx(docx, tmp_output_dir, options=PipelineOptions(), force=True)
150
+ assert ok
151
+
152
+ out = next(tmp_output_dir.glob("*.md")).read_text(encoding="utf-8")
153
+ end = out.index("\n---\n", 4)
154
+ fm = yaml.safe_load(out[4:end])
155
+ body = out[end + 5 :]
156
+
157
+ # Frontmatter reflects the fallback explicitly.
158
+ assert fm["extracted_via"] == "docling→mammoth (warning fallback)"
159
+ # Mammoth output reached the body — Docling's "# Docling output" is gone.
160
+ assert "Docling output" not in body
161
+ assert "Listy Doc" in body or "Item" in body
162
+
163
+ # Warning surfaced into collected_warnings so --strict catches it.
164
+ warns = collected_warnings()
165
+ assert any("ListGroup" in w for w in warns)
166
+
167
+ # Stderr (or stdout via OK line suffix) carries a user-visible note.
168
+ captured = capsys.readouterr()
169
+ combined = captured.out + captured.err
170
+ assert "fallback" in combined.lower() or "fell back" in combined.lower()
171
+
172
+
173
+ def test_fallback_disabled_keeps_docling(tmp_path, tmp_output_dir, monkeypatch):
174
+ monkeypatch.setattr(docx_mod, "has_docling", lambda: True)
175
+
176
+ def _fake(docx_path):
177
+ return (
178
+ "# Docling output\n",
179
+ "docling",
180
+ ["Parent element of the list item is not a ListGroup. Ignored."],
181
+ )
182
+
183
+ monkeypatch.setattr(docx_mod, "_extract_via_docling", _fake)
184
+
185
+ docx = tmp_path / "listy.docx"
186
+ _build_listy_docx(docx)
187
+
188
+ reset_warnings()
189
+ options = PipelineOptions(docx_fallback_on_warn=False)
190
+ ok = convert_docx(docx, tmp_output_dir, options=options, force=True)
191
+ assert ok
192
+
193
+ out = next(tmp_output_dir.glob("*.md")).read_text(encoding="utf-8")
194
+ end = out.index("\n---\n", 4)
195
+ fm = yaml.safe_load(out[4:end])
196
+ body = out[end + 5 :]
197
+ # Fallback disabled: Docling output retained, no fallback marker.
198
+ assert fm["extracted_via"] == "docling"
199
+ assert "Docling output" in body
200
+
201
+
202
+ def test_no_warnings_no_fallback(tmp_path, tmp_output_dir, monkeypatch):
203
+ monkeypatch.setattr(docx_mod, "has_docling", lambda: True)
204
+
205
+ def _fake(docx_path):
206
+ return "# Clean Docling output\n", "docling", []
207
+
208
+ monkeypatch.setattr(docx_mod, "_extract_via_docling", _fake)
209
+
210
+ docx = tmp_path / "listy.docx"
211
+ _build_listy_docx(docx)
212
+
213
+ reset_warnings()
214
+ ok = convert_docx(docx, tmp_output_dir, options=PipelineOptions(), force=True)
215
+ assert ok
216
+
217
+ out = next(tmp_output_dir.glob("*.md")).read_text(encoding="utf-8")
218
+ end = out.index("\n---\n", 4)
219
+ fm = yaml.safe_load(out[4:end])
220
+ body = out[end + 5 :]
221
+ assert fm["extracted_via"] == "docling"
222
+ assert "Clean Docling output" in body
223
+
224
+
225
+ def test_explicit_docling_backend_still_falls_back(
226
+ tmp_path, tmp_output_dir, monkeypatch
227
+ ):
228
+ """User passed --backend docling but Docling emitted warnings: still retry.
229
+
230
+ The fallback flag is independent of backend selection; explicit
231
+ `--backend docling` doesn't pin the user to a lossy result.
232
+ """
233
+ monkeypatch.setattr(docx_mod, "has_docling", lambda: True)
234
+
235
+ def _fake(docx_path):
236
+ return (
237
+ "# Docling output\n",
238
+ "docling",
239
+ ["Parent element of the list item is not a ListGroup. Ignored."],
240
+ )
241
+
242
+ monkeypatch.setattr(docx_mod, "_extract_via_docling", _fake)
243
+
244
+ docx = tmp_path / "listy.docx"
245
+ _build_listy_docx(docx)
246
+
247
+ options = PipelineOptions(backend="docling")
248
+ reset_warnings()
249
+ ok = convert_docx(docx, tmp_output_dir, options=options, force=True)
250
+ assert ok
251
+ out = next(tmp_output_dir.glob("*.md")).read_text(encoding="utf-8")
252
+ end = out.index("\n---\n", 4)
253
+ fm = yaml.safe_load(out[4:end])
254
+ assert fm["extracted_via"] == "docling→mammoth (warning fallback)"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes