any2md 1.0.3__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.3 → any2md-1.0.5}/CHANGELOG.md +83 -0
  2. {any2md-1.0.3 → any2md-1.0.5}/PKG-INFO +1 -1
  3. {any2md-1.0.3 → any2md-1.0.5}/any2md/__init__.py +1 -1
  4. {any2md-1.0.3 → any2md-1.0.5}/any2md/cli.py +11 -0
  5. {any2md-1.0.3 → any2md-1.0.5}/any2md/converters/docx.py +80 -6
  6. {any2md-1.0.3 → any2md-1.0.5}/any2md/pipeline/__init__.py +9 -0
  7. {any2md-1.0.3 → any2md-1.0.5}/any2md/pipeline/text.py +7 -2
  8. any2md-1.0.5/docs/superpowers/plans/2026-04-26-issue-17-toc-followups.md +981 -0
  9. any2md-1.0.5/docs/superpowers/specs/2026-04-26-issue-17-toc-followups-design.md +239 -0
  10. any2md-1.0.5/scripts/audit-outputs.py +202 -0
  11. any2md-1.0.5/tests/integration/test_docx_fallback_on_warn.py +254 -0
  12. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_text_dedupe_toc_table.py +41 -0
  13. any2md-1.0.5/tests/unit/scripts/__init__.py +0 -0
  14. any2md-1.0.5/tests/unit/scripts/test_audit_outputs.py +81 -0
  15. {any2md-1.0.3 → any2md-1.0.5}/.gitignore +0 -0
  16. {any2md-1.0.3 → any2md-1.0.5}/CODE_OF_CONDUCT.md +0 -0
  17. {any2md-1.0.3 → any2md-1.0.5}/CONTRIBUTING.md +0 -0
  18. {any2md-1.0.3 → any2md-1.0.5}/LICENSE +0 -0
  19. {any2md-1.0.3 → any2md-1.0.5}/README.md +0 -0
  20. {any2md-1.0.3 → any2md-1.0.5}/SECURITY.md +0 -0
  21. {any2md-1.0.3 → any2md-1.0.5}/any2md/__main__.py +0 -0
  22. {any2md-1.0.3 → any2md-1.0.5}/any2md/_docling.py +0 -0
  23. {any2md-1.0.3 → any2md-1.0.5}/any2md/config.py +0 -0
  24. {any2md-1.0.3 → any2md-1.0.5}/any2md/converters/__init__.py +0 -0
  25. {any2md-1.0.3 → any2md-1.0.5}/any2md/converters/html.py +0 -0
  26. {any2md-1.0.3 → any2md-1.0.5}/any2md/converters/pdf.py +0 -0
  27. {any2md-1.0.3 → any2md-1.0.5}/any2md/converters/txt.py +0 -0
  28. {any2md-1.0.3 → any2md-1.0.5}/any2md/frontmatter.py +0 -0
  29. {any2md-1.0.3 → any2md-1.0.5}/any2md/heuristics.py +0 -0
  30. {any2md-1.0.3 → any2md-1.0.5}/any2md/pipeline/cleanup.py +0 -0
  31. {any2md-1.0.3 → any2md-1.0.5}/any2md/pipeline/structured.py +0 -0
  32. {any2md-1.0.3 → any2md-1.0.5}/any2md/utils.py +0 -0
  33. {any2md-1.0.3 → any2md-1.0.5}/any2md/validators.py +0 -0
  34. {any2md-1.0.3 → any2md-1.0.5}/assets/logo-original.jpeg +0 -0
  35. {any2md-1.0.3 → any2md-1.0.5}/assets/logo.png +0 -0
  36. {any2md-1.0.3 → any2md-1.0.5}/assets/social-preview.jpg +0 -0
  37. {any2md-1.0.3 → any2md-1.0.5}/docs/architecture.md +0 -0
  38. {any2md-1.0.3 → any2md-1.0.5}/docs/cli-reference.md +0 -0
  39. {any2md-1.0.3 → any2md-1.0.5}/docs/output-format.md +0 -0
  40. {any2md-1.0.3 → any2md-1.0.5}/docs/superpowers/plans/2026-04-26-any2md-v1-phase1-foundation.md +0 -0
  41. {any2md-1.0.3 → any2md-1.0.5}/docs/superpowers/plans/2026-04-26-any2md-v1-phase2-docling-backend.md +0 -0
  42. {any2md-1.0.3 → any2md-1.0.5}/docs/superpowers/plans/2026-04-26-any2md-v1-phase3-figures-ocr-text-lane.md +0 -0
  43. {any2md-1.0.3 → any2md-1.0.5}/docs/superpowers/plans/2026-04-26-any2md-v1-phase4-polish-config-docs.md +0 -0
  44. {any2md-1.0.3 → any2md-1.0.5}/docs/superpowers/plans/2026-04-26-any2md-v1.0.2-implementation.md +0 -0
  45. {any2md-1.0.3 → any2md-1.0.5}/docs/superpowers/specs/2026-04-26-any2md-v1-design.md +0 -0
  46. {any2md-1.0.3 → any2md-1.0.5}/docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md +0 -0
  47. {any2md-1.0.3 → any2md-1.0.5}/docs/superpowers/specs/2026-04-26-v1.0.3-empty-title-orphan-punct-design.md +0 -0
  48. {any2md-1.0.3 → any2md-1.0.5}/docs/troubleshooting.md +0 -0
  49. {any2md-1.0.3 → any2md-1.0.5}/docs/upgrading-from-0.7.md +0 -0
  50. {any2md-1.0.3 → any2md-1.0.5}/pyproject.toml +0 -0
  51. {any2md-1.0.3 → any2md-1.0.5}/requirements.txt +0 -0
  52. {any2md-1.0.3 → any2md-1.0.5}/scripts/validate-phase1.sh +0 -0
  53. {any2md-1.0.3 → any2md-1.0.5}/tests/__init__.py +0 -0
  54. {any2md-1.0.3 → any2md-1.0.5}/tests/cli/__init__.py +0 -0
  55. {any2md-1.0.3 → any2md-1.0.5}/tests/cli/test_cli_backend.py +0 -0
  56. {any2md-1.0.3 → any2md-1.0.5}/tests/cli/test_cli_existing_flags.py +0 -0
  57. {any2md-1.0.3 → any2md-1.0.5}/tests/cli/test_cli_exit_codes.py +0 -0
  58. {any2md-1.0.3 → any2md-1.0.5}/tests/cli/test_cli_high_fidelity.py +0 -0
  59. {any2md-1.0.3 → any2md-1.0.5}/tests/cli/test_cli_meta.py +0 -0
  60. {any2md-1.0.3 → any2md-1.0.5}/tests/cli/test_cli_no_arxiv_lookup.py +0 -0
  61. {any2md-1.0.3 → any2md-1.0.5}/tests/cli/test_cli_ocr_save_images.py +0 -0
  62. {any2md-1.0.3 → any2md-1.0.5}/tests/cli/test_cli_quiet_verbose.py +0 -0
  63. {any2md-1.0.3 → any2md-1.0.5}/tests/cli/test_cli_strict.py +0 -0
  64. {any2md-1.0.3 → any2md-1.0.5}/tests/conftest.py +0 -0
  65. {any2md-1.0.3 → any2md-1.0.5}/tests/fixtures/__init__.py +0 -0
  66. {any2md-1.0.3 → any2md-1.0.5}/tests/fixtures/docs/ligatures_and_softhyphens.txt +0 -0
  67. {any2md-1.0.3 → any2md-1.0.5}/tests/fixtures/docs/multi_column.pdf +0 -0
  68. {any2md-1.0.3 → any2md-1.0.5}/tests/fixtures/docs/table_heavy.docx +0 -0
  69. {any2md-1.0.3 → any2md-1.0.5}/tests/fixtures/docs/web_page.html +0 -0
  70. {any2md-1.0.3 → any2md-1.0.5}/tests/fixtures/make_fixtures.py +0 -0
  71. {any2md-1.0.3 → any2md-1.0.5}/tests/fixtures/snapshots/ligatures_and_softhyphens.md +0 -0
  72. {any2md-1.0.3 → any2md-1.0.5}/tests/fixtures/snapshots/multi_column.docling.md +0 -0
  73. {any2md-1.0.3 → any2md-1.0.5}/tests/fixtures/snapshots/multi_column.fallback.md +0 -0
  74. {any2md-1.0.3 → any2md-1.0.5}/tests/fixtures/snapshots/table_heavy.docling.md +0 -0
  75. {any2md-1.0.3 → any2md-1.0.5}/tests/fixtures/snapshots/table_heavy.fallback.md +0 -0
  76. {any2md-1.0.3 → any2md-1.0.5}/tests/fixtures/snapshots/web_page.md +0 -0
  77. {any2md-1.0.3 → any2md-1.0.5}/tests/integration/__init__.py +0 -0
  78. {any2md-1.0.3 → any2md-1.0.5}/tests/integration/test_docx_backend_override.py +0 -0
  79. {any2md-1.0.3 → any2md-1.0.5}/tests/integration/test_docx_docling.py +0 -0
  80. {any2md-1.0.3 → any2md-1.0.5}/tests/integration/test_docx_mammoth_fallback.py +0 -0
  81. {any2md-1.0.3 → any2md-1.0.5}/tests/integration/test_html_trafilatura.py +0 -0
  82. {any2md-1.0.3 → any2md-1.0.5}/tests/integration/test_pdf_backend_override.py +0 -0
  83. {any2md-1.0.3 → any2md-1.0.5}/tests/integration/test_pdf_docling.py +0 -0
  84. {any2md-1.0.3 → any2md-1.0.5}/tests/integration/test_pdf_pymupdf_fallback.py +0 -0
  85. {any2md-1.0.3 → any2md-1.0.5}/tests/integration/test_save_images.py +0 -0
  86. {any2md-1.0.3 → any2md-1.0.5}/tests/integration/test_snapshots.py +0 -0
  87. {any2md-1.0.3 → any2md-1.0.5}/tests/integration/test_txt.py +0 -0
  88. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/__init__.py +0 -0
  89. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/__init__.py +0 -0
  90. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_footnote_markers.py +0 -0
  91. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_html_entities.py +0 -0
  92. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_html_entities_double_encoded.py +0 -0
  93. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_ligatures.py +0 -0
  94. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_nfc.py +0 -0
  95. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_quotes_dashes.py +0 -0
  96. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_soft_hyphens.py +0 -0
  97. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_validate.py +0 -0
  98. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_whitespace.py +0 -0
  99. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_runner.py +0 -0
  100. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_strip_orphan_punctuation.py +0 -0
  101. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_structured_body_cleanup_stages.py +0 -0
  102. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_structured_compact_tables.py +0 -0
  103. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_structured_heading_hierarchy.py +0 -0
  104. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_structured_lift_figures.py +0 -0
  105. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_structured_normalize_cites.py +0 -0
  106. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_text_dedupe_paragraphs.py +0 -0
  107. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_text_dedupe_toc.py +0 -0
  108. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_text_dehyphenate.py +0 -0
  109. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_text_repair_line_wraps.py +0 -0
  110. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_text_restore_lists_code.py +0 -0
  111. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_text_strip_cover_artifacts.py +0 -0
  112. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_text_strip_headers_footers.py +0 -0
  113. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_text_strip_repeated_byline.py +0 -0
  114. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/pipeline/test_text_strip_web_fragments.py +0 -0
  115. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/test_auto_id.py +0 -0
  116. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/test_config.py +0 -0
  117. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/test_content_hash.py +0 -0
  118. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/test_docling_helper.py +0 -0
  119. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/test_frontmatter_compose.py +0 -0
  120. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/test_frontmatter_helpers.py +0 -0
  121. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/test_heuristics.py +0 -0
  122. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/test_meta_overrides.py +0 -0
  123. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/test_pdf_complexity.py +0 -0
  124. {any2md-1.0.3 → any2md-1.0.5}/tests/unit/test_validators.py +0 -0
@@ -4,6 +4,89 @@ 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
+
44
+ ## [1.0.4] — 2026-04-26
45
+
46
+ Patch release. Two follow-ups deferred from v1.0.3 (#17): the T7
47
+ `dedupe_toc_table` now normalizes leader-dot padding from TOC
48
+ cells before matching against body headings, so Docling-rendered
49
+ TOC tables (e.g., `Purpose..........Page`) get properly stripped;
50
+ and the audit script's `leading-toc-table` check no longer
51
+ false-flags documents whose only GFM table sits at the end (skip
52
+ when the body contains no H2).
53
+
54
+ ### Changed
55
+ - `pipeline/text.py::dedupe_toc_table` strips leader-dot padding
56
+ (`re.sub(r'\.{3,}.*$', '', cell)`) from each TOC cell before
57
+ lower-casing and matching against body H2/H3 titles. (The
58
+ text-block TOC variant already handles this via `_TOC_LINE_RE`'s
59
+ capture group — only the table-variant site needed the fix.)
60
+ - `scripts/audit-outputs.py` `leading-toc-table` check is now
61
+ gated on `"\n## "` being present in the body — when no H2
62
+ exists, no "leading-TOC region" is defined, so the check is
63
+ skipped instead of matching the entire document.
64
+
65
+ ### Fixes
66
+ - TOC tables with dotted-page-number padding (typical Docling
67
+ output for SafeBreach-style PDFs — e.g., `Backup.pdf`,
68
+ `Password.pdf`) now get stripped by T7 in aggressive/maximum
69
+ profiles. Previously they survived because the cell content
70
+ `"1.1. Purpose............."` failed string-equality with the
71
+ body heading `"1.1. Purpose"`, dropping overlap below the 70%
72
+ threshold.
73
+ - Audit script no longer reports false-positive
74
+ `leading-toc-table` flags on documents whose only GFM table is
75
+ at the end and the body contains no `## ` headings.
76
+
77
+ ### Tests
78
+ - New `test_strips_toc_table_with_leader_dot_padding` in
79
+ `tests/unit/pipeline/test_text_dedupe_toc_table.py` — TOC
80
+ table whose cells carry leader-dot padding mirroring later H2
81
+ headings still triggers the strip.
82
+ - New `tests/unit/scripts/test_audit_outputs.py` covering the
83
+ audit-script `leading-toc-table` check: no-H2-with-trailing-
84
+ table is NOT flagged; leading-table-before-H2 IS still flagged.
85
+
86
+ ### Other
87
+ - `scripts/audit-outputs.py` is now tracked in version control
88
+ (was previously local-only).
89
+
7
90
  ## [1.0.3] — 2026-04-26
8
91
 
9
92
  Patch release. Audit on the v1.0.2-regenerated corpus surfaced two
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: any2md
3
- Version: 1.0.3
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.3"
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]
@@ -199,6 +199,7 @@ def dedupe_toc_block(text: str, options: "PipelineOptions") -> str:
199
199
  return "\n".join(lines[end:]).lstrip("\n")
200
200
 
201
201
 
202
+ _LEADER_DOT_RE = re.compile(r"\.{3,}.*$")
202
203
  _TABLE_ROW_RE = re.compile(r"^\s*\|.*\|\s*$")
203
204
  _TABLE_SEP_RE = re.compile(r"^\s*\|[\s\-:|]+\|\s*$")
204
205
 
@@ -260,12 +261,16 @@ def dedupe_toc_table(text: str, options: "PipelineOptions") -> str:
260
261
  continue
261
262
 
262
263
  # Extract title text from non-numeric cells.
264
+ # Strip leader-dot padding ('Purpose............') before matching —
265
+ # Docling renders TOC entries with dotted page-number padding and
266
+ # the body-heading equivalent does not contain the dots.
263
267
  toc_titles: set[str] = set()
264
268
  for row in entry_rows:
265
269
  cells = _split_table_cells(row)
266
270
  for cell in cells:
267
- if cell and not cell.replace(".", "").isdigit():
268
- toc_titles.add(cell.lower())
271
+ normalized = _LEADER_DOT_RE.sub("", cell).strip()
272
+ if normalized and not normalized.replace(".", "").isdigit():
273
+ toc_titles.add(normalized.lower())
269
274
  if not toc_titles:
270
275
  i = tbl_end if tbl_end > i else i + 1
271
276
  continue