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