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.
- {any2md-1.0.4 → any2md-1.0.5}/CHANGELOG.md +37 -0
- {any2md-1.0.4 → any2md-1.0.5}/PKG-INFO +1 -1
- {any2md-1.0.4 → any2md-1.0.5}/any2md/__init__.py +1 -1
- {any2md-1.0.4 → any2md-1.0.5}/any2md/cli.py +11 -0
- {any2md-1.0.4 → any2md-1.0.5}/any2md/converters/docx.py +80 -6
- {any2md-1.0.4 → any2md-1.0.5}/any2md/pipeline/__init__.py +9 -0
- any2md-1.0.5/tests/integration/test_docx_fallback_on_warn.py +254 -0
- {any2md-1.0.4 → any2md-1.0.5}/.gitignore +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/CODE_OF_CONDUCT.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/CONTRIBUTING.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/LICENSE +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/README.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/SECURITY.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/any2md/__main__.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/any2md/_docling.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/any2md/config.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/any2md/converters/__init__.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/any2md/converters/html.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/any2md/converters/pdf.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/any2md/converters/txt.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/any2md/frontmatter.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/any2md/heuristics.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/any2md/pipeline/cleanup.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/any2md/pipeline/structured.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/any2md/pipeline/text.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/any2md/utils.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/any2md/validators.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/assets/logo-original.jpeg +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/assets/logo.png +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/assets/social-preview.jpg +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/docs/architecture.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/docs/cli-reference.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/docs/output-format.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/plans/2026-04-26-any2md-v1-phase1-foundation.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/plans/2026-04-26-any2md-v1-phase2-docling-backend.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/plans/2026-04-26-any2md-v1-phase3-figures-ocr-text-lane.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/plans/2026-04-26-any2md-v1-phase4-polish-config-docs.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/plans/2026-04-26-any2md-v1.0.2-implementation.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/plans/2026-04-26-issue-17-toc-followups.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/specs/2026-04-26-any2md-v1-design.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/specs/2026-04-26-issue-17-toc-followups-design.md +0 -0
- {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
- {any2md-1.0.4 → any2md-1.0.5}/docs/troubleshooting.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/docs/upgrading-from-0.7.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/pyproject.toml +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/requirements.txt +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/scripts/audit-outputs.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/scripts/validate-phase1.sh +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/__init__.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/cli/__init__.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/cli/test_cli_backend.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/cli/test_cli_existing_flags.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/cli/test_cli_exit_codes.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/cli/test_cli_high_fidelity.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/cli/test_cli_meta.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/cli/test_cli_no_arxiv_lookup.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/cli/test_cli_ocr_save_images.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/cli/test_cli_quiet_verbose.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/cli/test_cli_strict.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/conftest.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/__init__.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/docs/ligatures_and_softhyphens.txt +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/docs/multi_column.pdf +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/docs/table_heavy.docx +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/docs/web_page.html +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/make_fixtures.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/snapshots/ligatures_and_softhyphens.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/snapshots/multi_column.docling.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/snapshots/multi_column.fallback.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/snapshots/table_heavy.docling.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/snapshots/table_heavy.fallback.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/fixtures/snapshots/web_page.md +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/integration/__init__.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/integration/test_docx_backend_override.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/integration/test_docx_docling.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/integration/test_docx_mammoth_fallback.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/integration/test_html_trafilatura.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/integration/test_pdf_backend_override.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/integration/test_pdf_docling.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/integration/test_pdf_pymupdf_fallback.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/integration/test_save_images.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/integration/test_snapshots.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/integration/test_txt.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/__init__.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/__init__.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_footnote_markers.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_html_entities.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_html_entities_double_encoded.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_ligatures.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_nfc.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_quotes_dashes.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_soft_hyphens.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_validate.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_whitespace.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_runner.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_strip_orphan_punctuation.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_structured_body_cleanup_stages.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_structured_compact_tables.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_structured_heading_hierarchy.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_structured_lift_figures.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_structured_normalize_cites.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_text_dedupe_paragraphs.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_text_dedupe_toc.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_text_dedupe_toc_table.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_text_dehyphenate.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_text_repair_line_wraps.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_text_restore_lists_code.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_text_strip_cover_artifacts.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_text_strip_headers_footers.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_text_strip_repeated_byline.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_text_strip_web_fragments.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/scripts/__init__.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/scripts/test_audit_outputs.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/test_auto_id.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/test_config.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/test_content_hash.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/test_docling_helper.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/test_frontmatter_compose.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/test_frontmatter_helpers.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/test_heuristics.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/test_meta_overrides.py +0 -0
- {any2md-1.0.4 → any2md-1.0.5}/tests/unit/test_pdf_complexity.py +0 -0
- {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.
|
|
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
|
|
@@ -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').
|
|
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
|
-
|
|
99
|
-
|
|
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(
|
|
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
|
{any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/plans/2026-04-26-any2md-v1-phase1-foundation.md
RENAMED
|
File without changes
|
{any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/plans/2026-04-26-any2md-v1-phase2-docling-backend.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/plans/2026-04-26-any2md-v1.0.2-implementation.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{any2md-1.0.4 → any2md-1.0.5}/docs/superpowers/specs/2026-04-26-issue-17-toc-followups-design.md
RENAMED
|
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
|
|
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
|
{any2md-1.0.4 → any2md-1.0.5}/tests/unit/pipeline/test_cleanup_html_entities_double_encoded.py
RENAMED
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|