any2md 1.0.0__tar.gz → 1.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. {any2md-1.0.0 → any2md-1.0.2}/.gitignore +1 -0
  2. {any2md-1.0.0 → any2md-1.0.2}/CHANGELOG.md +83 -0
  3. {any2md-1.0.0 → any2md-1.0.2}/PKG-INFO +72 -24
  4. {any2md-1.0.0 → any2md-1.0.2}/README.md +71 -23
  5. {any2md-1.0.0 → any2md-1.0.2}/any2md/__init__.py +1 -1
  6. {any2md-1.0.0 → any2md-1.0.2}/any2md/cli.py +27 -2
  7. {any2md-1.0.0 → any2md-1.0.2}/any2md/converters/docx.py +36 -2
  8. {any2md-1.0.0 → any2md-1.0.2}/any2md/converters/pdf.py +30 -3
  9. {any2md-1.0.0 → any2md-1.0.2}/any2md/frontmatter.py +33 -3
  10. any2md-1.0.2/any2md/heuristics.py +580 -0
  11. {any2md-1.0.0 → any2md-1.0.2}/any2md/pipeline/__init__.py +13 -0
  12. {any2md-1.0.0 → any2md-1.0.2}/any2md/pipeline/cleanup.py +22 -0
  13. {any2md-1.0.0 → any2md-1.0.2}/any2md/pipeline/text.py +187 -0
  14. any2md-1.0.2/assets/logo-original.jpeg +0 -0
  15. any2md-1.0.2/assets/logo.png +0 -0
  16. any2md-1.0.2/assets/social-preview.png +0 -0
  17. {any2md-1.0.0 → any2md-1.0.2}/docs/architecture.md +174 -59
  18. {any2md-1.0.0 → any2md-1.0.2}/docs/cli-reference.md +76 -1
  19. {any2md-1.0.0 → any2md-1.0.2}/docs/output-format.md +45 -2
  20. {any2md-1.0.0 → any2md-1.0.2}/docs/superpowers/plans/2026-04-26-any2md-v1-phase1-foundation.md +10 -2
  21. any2md-1.0.2/docs/superpowers/plans/2026-04-26-any2md-v1-phase2-docling-backend.md +1426 -0
  22. any2md-1.0.2/docs/superpowers/plans/2026-04-26-any2md-v1-phase3-figures-ocr-text-lane.md +1053 -0
  23. any2md-1.0.2/docs/superpowers/plans/2026-04-26-any2md-v1-phase4-polish-config-docs.md +936 -0
  24. any2md-1.0.2/docs/superpowers/plans/2026-04-26-any2md-v1.0.2-implementation.md +307 -0
  25. any2md-1.0.2/docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md +694 -0
  26. {any2md-1.0.0 → any2md-1.0.2}/docs/troubleshooting.md +11 -0
  27. {any2md-1.0.0 → any2md-1.0.2}/docs/upgrading-from-0.7.md +1 -0
  28. any2md-1.0.2/tests/cli/test_cli_backend.py +45 -0
  29. any2md-1.0.2/tests/cli/test_cli_no_arxiv_lookup.py +41 -0
  30. any2md-1.0.2/tests/integration/test_docx_backend_override.py +57 -0
  31. any2md-1.0.2/tests/integration/test_docx_mammoth_fallback.py +174 -0
  32. any2md-1.0.2/tests/integration/test_pdf_backend_override.py +57 -0
  33. {any2md-1.0.0 → any2md-1.0.2}/tests/integration/test_pdf_docling.py +3 -0
  34. any2md-1.0.2/tests/integration/test_pdf_pymupdf_fallback.py +79 -0
  35. any2md-1.0.2/tests/unit/pipeline/test_cleanup_html_entities.py +51 -0
  36. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/pipeline/test_runner.py +18 -0
  37. any2md-1.0.2/tests/unit/pipeline/test_text_dedupe_toc_table.py +106 -0
  38. any2md-1.0.2/tests/unit/pipeline/test_text_strip_cover_artifacts.py +79 -0
  39. any2md-1.0.2/tests/unit/pipeline/test_text_strip_repeated_byline.py +57 -0
  40. any2md-1.0.2/tests/unit/pipeline/test_text_strip_web_fragments.py +63 -0
  41. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/test_frontmatter_compose.py +69 -0
  42. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/test_frontmatter_helpers.py +1 -1
  43. any2md-1.0.2/tests/unit/test_heuristics.py +507 -0
  44. any2md-1.0.0/tests/integration/test_docx_mammoth_fallback.py +0 -42
  45. any2md-1.0.0/tests/integration/test_pdf_pymupdf_fallback.py +0 -37
  46. {any2md-1.0.0 → any2md-1.0.2}/CONTRIBUTING.md +0 -0
  47. {any2md-1.0.0 → any2md-1.0.2}/LICENSE +0 -0
  48. {any2md-1.0.0 → any2md-1.0.2}/any2md/__main__.py +0 -0
  49. {any2md-1.0.0 → any2md-1.0.2}/any2md/_docling.py +0 -0
  50. {any2md-1.0.0 → any2md-1.0.2}/any2md/config.py +0 -0
  51. {any2md-1.0.0 → any2md-1.0.2}/any2md/converters/__init__.py +0 -0
  52. {any2md-1.0.0 → any2md-1.0.2}/any2md/converters/html.py +0 -0
  53. {any2md-1.0.0 → any2md-1.0.2}/any2md/converters/txt.py +0 -0
  54. {any2md-1.0.0 → any2md-1.0.2}/any2md/pipeline/structured.py +0 -0
  55. {any2md-1.0.0 → any2md-1.0.2}/any2md/utils.py +0 -0
  56. {any2md-1.0.0 → any2md-1.0.2}/any2md/validators.py +0 -0
  57. {any2md-1.0.0 → any2md-1.0.2}/docs/superpowers/specs/2026-04-26-any2md-v1-design.md +0 -0
  58. {any2md-1.0.0 → any2md-1.0.2}/pyproject.toml +0 -0
  59. {any2md-1.0.0 → any2md-1.0.2}/requirements.txt +0 -0
  60. {any2md-1.0.0 → any2md-1.0.2}/scripts/validate-phase1.sh +0 -0
  61. {any2md-1.0.0 → any2md-1.0.2}/tests/__init__.py +0 -0
  62. {any2md-1.0.0 → any2md-1.0.2}/tests/cli/__init__.py +0 -0
  63. {any2md-1.0.0 → any2md-1.0.2}/tests/cli/test_cli_existing_flags.py +0 -0
  64. {any2md-1.0.0 → any2md-1.0.2}/tests/cli/test_cli_exit_codes.py +0 -0
  65. {any2md-1.0.0 → any2md-1.0.2}/tests/cli/test_cli_high_fidelity.py +0 -0
  66. {any2md-1.0.0 → any2md-1.0.2}/tests/cli/test_cli_meta.py +0 -0
  67. {any2md-1.0.0 → any2md-1.0.2}/tests/cli/test_cli_ocr_save_images.py +0 -0
  68. {any2md-1.0.0 → any2md-1.0.2}/tests/cli/test_cli_quiet_verbose.py +0 -0
  69. {any2md-1.0.0 → any2md-1.0.2}/tests/cli/test_cli_strict.py +0 -0
  70. {any2md-1.0.0 → any2md-1.0.2}/tests/conftest.py +0 -0
  71. {any2md-1.0.0 → any2md-1.0.2}/tests/fixtures/__init__.py +0 -0
  72. {any2md-1.0.0 → any2md-1.0.2}/tests/fixtures/docs/ligatures_and_softhyphens.txt +0 -0
  73. {any2md-1.0.0 → any2md-1.0.2}/tests/fixtures/docs/multi_column.pdf +0 -0
  74. {any2md-1.0.0 → any2md-1.0.2}/tests/fixtures/docs/table_heavy.docx +0 -0
  75. {any2md-1.0.0 → any2md-1.0.2}/tests/fixtures/docs/web_page.html +0 -0
  76. {any2md-1.0.0 → any2md-1.0.2}/tests/fixtures/make_fixtures.py +0 -0
  77. {any2md-1.0.0 → any2md-1.0.2}/tests/fixtures/snapshots/ligatures_and_softhyphens.md +0 -0
  78. {any2md-1.0.0 → any2md-1.0.2}/tests/fixtures/snapshots/multi_column.docling.md +0 -0
  79. {any2md-1.0.0 → any2md-1.0.2}/tests/fixtures/snapshots/multi_column.fallback.md +0 -0
  80. {any2md-1.0.0 → any2md-1.0.2}/tests/fixtures/snapshots/table_heavy.docling.md +0 -0
  81. {any2md-1.0.0 → any2md-1.0.2}/tests/fixtures/snapshots/table_heavy.fallback.md +0 -0
  82. {any2md-1.0.0 → any2md-1.0.2}/tests/fixtures/snapshots/web_page.md +0 -0
  83. {any2md-1.0.0 → any2md-1.0.2}/tests/integration/__init__.py +0 -0
  84. {any2md-1.0.0 → any2md-1.0.2}/tests/integration/test_docx_docling.py +0 -0
  85. {any2md-1.0.0 → any2md-1.0.2}/tests/integration/test_html_trafilatura.py +0 -0
  86. {any2md-1.0.0 → any2md-1.0.2}/tests/integration/test_save_images.py +0 -0
  87. {any2md-1.0.0 → any2md-1.0.2}/tests/integration/test_snapshots.py +0 -0
  88. {any2md-1.0.0 → any2md-1.0.2}/tests/integration/test_txt.py +0 -0
  89. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/__init__.py +0 -0
  90. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/pipeline/__init__.py +0 -0
  91. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/pipeline/test_cleanup_footnote_markers.py +0 -0
  92. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/pipeline/test_cleanup_ligatures.py +0 -0
  93. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/pipeline/test_cleanup_nfc.py +0 -0
  94. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/pipeline/test_cleanup_quotes_dashes.py +0 -0
  95. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/pipeline/test_cleanup_soft_hyphens.py +0 -0
  96. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/pipeline/test_cleanup_validate.py +0 -0
  97. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/pipeline/test_cleanup_whitespace.py +0 -0
  98. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/pipeline/test_structured_compact_tables.py +0 -0
  99. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/pipeline/test_structured_heading_hierarchy.py +0 -0
  100. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/pipeline/test_structured_lift_figures.py +0 -0
  101. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/pipeline/test_structured_normalize_cites.py +0 -0
  102. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/pipeline/test_text_dedupe_paragraphs.py +0 -0
  103. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/pipeline/test_text_dedupe_toc.py +0 -0
  104. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/pipeline/test_text_dehyphenate.py +0 -0
  105. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/pipeline/test_text_repair_line_wraps.py +0 -0
  106. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/pipeline/test_text_restore_lists_code.py +0 -0
  107. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/pipeline/test_text_strip_headers_footers.py +0 -0
  108. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/test_auto_id.py +0 -0
  109. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/test_config.py +0 -0
  110. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/test_content_hash.py +0 -0
  111. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/test_docling_helper.py +0 -0
  112. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/test_meta_overrides.py +0 -0
  113. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/test_pdf_complexity.py +0 -0
  114. {any2md-1.0.0 → any2md-1.0.2}/tests/unit/test_validators.py +0 -0
@@ -38,6 +38,7 @@ input/
38
38
  template/
39
39
  test_docs/
40
40
  .worktrees/
41
+ logo/
41
42
 
42
43
  # ZERG / dev tooling
43
44
  .gsd/
@@ -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.2] — 2026-04-26
8
+
9
+ Patch release. Closes issue #15 plus 8 additional quality issues
10
+ discovered during deep investigation against four real-world inputs:
11
+ arxiv academic paper, ISO/IEC 27002 standard, COMP4441 academic
12
+ DOCX, and a Wikipedia article via URL.
13
+
14
+ ### Added
15
+ - New `any2md/heuristics.py` module — pure functions for frontmatter
16
+ field refinement: `refine_title`, `refine_abstract`, `extract_authors`,
17
+ `filter_organization`, `arxiv_lookup`, `is_arxiv_filename`. Called
18
+ from `frontmatter.compose()` and from converter modules.
19
+ - New `produced_by` extension field on `SourceMeta` and frontmatter
20
+ (between `extracted_via` and `pages`). Records the software that
21
+ produced the source file (PDF `Creator`, DOCX `Application`).
22
+ Distinct from `extracted_via` which records the any2md backend
23
+ that produced the markdown.
24
+ - New shared cleanup stage **C8 `decode_html_entities`** — universal
25
+ removal of `&`, `>`, `<`, numeric entities (`—`,
26
+ `—`) from body. Code-block aware (skips fenced ` ``` ` blocks).
27
+ - New text-lane stages T7-T10 (aggressive/maximum profile only):
28
+ - **T7 `dedupe_toc_table`** — strips table-formatted TOCs that T4's
29
+ text-formatted-TOC heuristic doesn't catch. Common in academic PDFs.
30
+ - **T8 `strip_cover_artifacts`** — drops cover-page noise (QR-code
31
+ blurbs, version stamps like "Third edition 2022-02") in the first
32
+ ~30 lines, before the first H2.
33
+ - **T9 `strip_repeated_byline`** — removes "Author's Contact
34
+ Information:" and similar lines that duplicate a byline.
35
+ - **T10 `strip_web_fragments`** — drops trafilatura extraction
36
+ fragments (orphan `|` / `>` lines, short incomplete sentences
37
+ surrounded by blank lines).
38
+ - New CLI flag `--no-arxiv-lookup` to disable the arxiv API metadata
39
+ enrichment. Arxiv enrichment is on by default for filenames matching
40
+ `\d{4}\.\d{4,5}` (e.g., `2501.17755v1.pdf`); SSRF-guarded with 5s
41
+ timeout; failures emit non-blocking warnings.
42
+ - Project logo and brand assets under `assets/`.
43
+
44
+ ### Fixes
45
+ The eleven quality issues — see issue #15 and
46
+ `docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md`:
47
+ - A1: authors not extracted from PDF body byline (academic PDFs).
48
+ - A2: abstract picked the byline / cover blurb / TOC line.
49
+ - A3: organization populated with PDF Creator software junk.
50
+ - B1: HTML entities leaked to body universally.
51
+ - C1: ISO/TR titles detected as "INTERNATIONAL STANDARD" cover header.
52
+ - C3: DOCX titles concatenated course code + project title.
53
+ - C4: Wikipedia titles kept "Wikipedia:" namespace prefix.
54
+ - D1: TOCs dumped as markdown tables in academic PDFs.
55
+ - D2: trafilatura fragments leaked into web outputs.
56
+ - D3: ISO cover-page QR-code blurb leaked into body.
57
+ - E1: "Author's Contact Information:" line duplicated byline.
58
+
59
+ ### Changed
60
+ - `frontmatter.compose()` now consults `heuristics.py` to refine
61
+ title / abstract / authors before YAML emission.
62
+ - `--profile conservative` now also gates the new heuristic
63
+ aggressiveness (skip-lists active; speculative inferences off).
64
+ - PDF `Creator` software values (LaTeX, acmart, Adobe InDesign,
65
+ Microsoft Word, etc.) no longer populate `organization`. They
66
+ go to the new `produced_by` field instead.
67
+
68
+ ## [1.0.1] — 2026-04-26
69
+
70
+ Patch release. Adds an explicit backend-selection CLI flag.
71
+
72
+ ### Added
73
+ - `--backend {docling,pymupdf4llm,mammoth}` CLI flag and corresponding
74
+ `PipelineOptions.backend` field. Lets users override the automatic
75
+ backend selection. Useful when Docling extracts incorrectly on a
76
+ specific input and the user wants to fall back to pymupdf4llm
77
+ without uninstalling Docling. Mismatched format/backend combinations
78
+ (e.g., `--backend pymupdf4llm` on a DOCX) error out per file.
79
+
80
+ ### Fixes (workaround)
81
+ - Refs #13 — Docling drops list items on certain academic PDFs with
82
+ the "Parent element of the list item is not a ListGroup" warning.
83
+ No upstream fix yet; users hitting this can now `any2md --backend
84
+ pymupdf4llm doc.pdf` to use the lightweight backend instead.
85
+
86
+ ### Note
87
+ - `--backend docling` is equivalent to the existing `--high-fidelity`
88
+ / `-H` flag (both kept). Future releases may consolidate.
89
+
7
90
  ## [1.0.0] — 2026-04-26
8
91
 
9
92
  First stable release of any2md v1.0. Validated end-to-end against
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: any2md
3
- Version: 1.0.0
3
+ Version: 1.0.2
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
@@ -28,10 +28,30 @@ Provides-Extra: high-fidelity
28
28
  Requires-Dist: docling>=2.0.0; extra == 'high-fidelity'
29
29
  Description-Content-Type: text/markdown
30
30
 
31
+ <p align="center">
32
+ <img src="assets/logo.png" alt="any2md" width="500"/>
33
+ </p>
34
+
31
35
  # any2md
32
36
 
33
37
  Convert PDFs, DOCX, HTML, URLs, and plain text into structured, machine-consumable Markdown for downstream RAG pipelines.
34
38
 
39
+ ## What's new in 1.0.2
40
+
41
+ A patch release focused on frontmatter and body-content quality, validated against four representative inputs (an arxiv academic PDF, an ISO/IEC standard, an academic DOCX, a Wikipedia URL). Eleven distinct artifacts are addressed:
42
+
43
+ - New `any2md/heuristics.py` module — pure-functions library that `frontmatter.compose()` consults to refine `title`, `abstract_for_rag`, `authors`, and `organization` before emission.
44
+ - New `produced_by` extension field, sitting between `extracted_via` and `pages`. Records the software that produced the source file (PDF `Creator`, DOCX `Application`). Distinct from `extracted_via`, which records the any2md backend.
45
+ - New shared cleanup stage **C8 `decode_html_entities`** — universal removal of `&amp;`, `&lt;`, `&gt;`, and numeric entities (`&#x2014;`, `&#8212;`) from body text. Code-block aware.
46
+ - Four new text-lane stages (aggressive/maximum profile only):
47
+ - **T7 `dedupe_toc_table`** — strips table-formatted TOCs that the existing T4 text-formatted heuristic doesn't catch. Common on academic PDFs.
48
+ - **T8 `strip_cover_artifacts`** — drops cover-page noise (QR-code blurbs, "Third edition 2022-02"-style version stamps) that appears before the first H2.
49
+ - **T9 `strip_repeated_byline`** — removes "Author's Contact Information:" and similar lines that duplicate a byline.
50
+ - **T10 `strip_web_fragments`** — drops trafilatura extraction fragments (orphan `|` and `>` lines, short incomplete sentences surrounded by blank lines).
51
+ - New CLI flag `--no-arxiv-lookup`. Arxiv enrichment for filenames matching `\d{4}\.\d{4,5}` is **on by default**; this flag disables it for airgapped environments.
52
+
53
+ The full design rationale lives in [docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md](docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md). Per-fix detail is in the [CHANGELOG](CHANGELOG.md).
54
+
35
55
  ## What this is
36
56
 
37
57
  any2md ingests heterogeneous source documents and emits Markdown with a fixed YAML frontmatter contract. The frontmatter shape is SSRM-compatible (Structured Security Reasoning Markdown — a documented schema for LLM-consumable documents), so retrieval pipelines, embeddings jobs, and chunking utilities downstream can rely on field names, types, and a deterministic `content_hash` for cache invalidation. The body is NFC-normalized with LF line endings, and the heading hierarchy is guaranteed to start at H1 and not skip levels. The goal is one stable shape, regardless of whether a document started life as a scanned PDF, a Word doc, a Wikipedia page, or a plain text dump.
@@ -144,6 +164,7 @@ source_file: "safety-alignment.docx" # original filename (any2md e
144
164
  type: "docx" # source format (any2md extension field)
145
165
  word_count: 14289 # any2md extension field
146
166
  extracted_via: "docling" # which backend produced the markdown
167
+ produced_by: "Microsoft® Word for Microsoft 365" # software that produced the source (new in v1.0.2)
147
168
  ---
148
169
  ```
149
170
 
@@ -226,7 +247,7 @@ You'd run this when your corpus has multi-column PDFs, complex tables, or scanne
226
247
  pip install "any2md[high-fidelity]"
227
248
  ```
228
249
 
229
- After install, the CLI auto-detects Docling and uses it for PDF and DOCX. To force it explicitly (and fail fast when it isn't installed), pass `-H` / `--high-fidelity`. To require it (treat fallback as an error), combine with `--strict`.
250
+ After install, the CLI auto-detects Docling and uses it for PDF and DOCX. To force it explicitly (and fail fast when it isn't installed), pass `-H` / `--high-fidelity`. To require it (treat fallback as an error), combine with `--strict`. Use `--backend pymupdf4llm` to force the lightweight backend even when Docling is installed (useful when Docling produces extraction artifacts on a specific input).
230
251
 
231
252
  ### When do I need high-fidelity?
232
253
 
@@ -408,6 +429,10 @@ type_code = "GD"
408
429
 
409
430
  With this file in place and `--auto-id` passed, generated `document_id` values look like `CSA-2026-GD-a3f1c91d`. The resolution order, highest priority first: explicit `--meta KEY=VAL` arguments, then `--meta-file PATH` (when given), then auto-discovered `.any2md.toml`, then the tool's defaults. Full flag and config reference in [docs/cli-reference.md](docs/cli-reference.md).
410
431
 
432
+ ### Network behavior
433
+
434
+ For PDFs whose filename matches the arxiv ID pattern (`\d{4}\.\d{4,5}`, e.g. `2501.17755v1.pdf`), any2md by default queries `https://export.arxiv.org/api/query` to enrich `authors`, `abstract_for_rag`, and `date` from the official arxiv metadata. The lookup is SSRF-guarded, has a 5-second timeout, and emits a non-blocking warning on any failure — conversion proceeds either way. Pass `--no-arxiv-lookup` to disable this for airgapped environments or when you want any2md to make no outbound calls. See [docs/cli-reference.md](docs/cli-reference.md#--no-arxiv-lookup) for details.
435
+
411
436
  ### Output verbosity and exit codes
412
437
 
413
438
  The CLI has three verbosity flags that pair with a four-value exit-code contract:
@@ -442,7 +467,7 @@ The full symptom-cause-fix triage guide, plus deeper diagnosis steps and follow-
442
467
  any2md uses a two-lane post-processing pipeline: one lane for layout-trustworthy backends (Docling), one for backends that need heavier text repair (pymupdf4llm, trafilatura, mammoth, the TXT heuristic). Both lanes converge on a shared cleanup pass, and the same `frontmatter.py` module emits YAML for every input format.
443
468
 
444
469
  ```
445
- any2md v1.0
470
+ any2md v1.0.2
446
471
  │
447
472
  ┌────┴────┐
448
473
  CLI │ cli.py │ parse args, classify URL/file/dir,
@@ -459,43 +484,66 @@ any2md uses a two-lane post-processing pipeline: one lane for layout-trustworthy
459
484
  │ pymupdf4llm│ mammoth+md │ │
460
485
  └────┬───────┴────┬───────┴───┬───┘
461
486
  │ │ │
487
+ │ SourceMeta carries:
488
+ │ organization, produced_by ◀ NEW in v1.0.2
489
+ │ (split via heuristics.filter_organization)
490
+ │
462
491
  structured-md structured-md text-md
463
492
  │ │ │
464
493
  └─────┬──────┴───────────┘
465
494
  ▼
466
495
  ┌─────────────────────────┐
467
496
  │ pipeline/structured.py │ structured lane
468
- │ - figure caption pass │ (trusts layout)
469
- │ - table compactor │
470
- │ - cite normalizer │
471
- │ - heading hierarchy │
497
+ │ S1 figure caption pass │ (trusts layout)
498
+ │ S2 table compactor │
499
+ │ S3 cite normalizer │
500
+ │ S4 heading hierarchy │
472
501
  └────────────┬────────────┘
473
502
  │
474
503
  ┌────────────┴────────────┐
475
504
  │ pipeline/text.py │ text lane
476
- │ - line-wrap repair │
477
- │ - dehyphenate │
478
- │ - duplicate paragraph │
479
- │ - TOC dedupe │
480
- │ - hdr/ftr strip │
481
- │ - list/code restore │
505
+ │ T1 line-wrap repair │
506
+ │ T2 dehyphenate │
507
+ │ T9 strip repeated │ ◀ NEW (E1)
508
+ │ byline │
509
+ │ T3 dedupe paragraphs │
510
+ │ T4 dedupe TOC block │
511
+ │ T7 dedupe TOC table │ ◀ NEW (D1)
512
+ │ T5 hdr/ftr strip │
513
+ │ T10 strip web │ ◀ NEW (D2)
514
+ │ fragments │
515
+ │ T6 list/code restore │
516
+ │ T8 strip cover │ ◀ NEW (D3)
517
+ │ artifacts │
482
518
  └────────────┬────────────┘
483
519
  │
484
520
  ┌────────────┴────────────┐
485
521
  │ pipeline/cleanup.py │ shared lane
486
- │ - NFC normalize │ (always last)
487
- │ - soft hyphen strip │
488
- │ - ligature normalize │
489
- │ - quote/dash normalize │
490
- │ - whitespace collapse │
491
- │ - footnote marker strip│
492
- │ - validate │
522
+ │ C1 NFC normalize │ (always last)
523
+ │ C2 soft hyphen strip │
524
+ │ C3 ligature normalize │
525
+ │ C4 quote/dash normalize│
526
+ │ C5 whitespace collapse │
527
+ │ C8 decode HTML entities│ ◀ NEW (B1, all profiles)
528
+ │ C6 footnote marker strip│
529
+ │ C7 validate │
493
530
  └────────────┬────────────┘
494
531
  │
495
- ┌────────────┴────────────┐
496
- │ frontmatter.py │ SSRM-compatible
497
- │ - merge user overrides │ YAML emitter
532
+ │ ┌──────────────────────────┐
533
+ │ │ any2md/heuristics.py │ ◀ NEW v1.0.2
534
+ │ │ - refine_title │
535
+ │ │ - refine_abstract │
536
+ │ │ - extract_authors │
537
+ │ │ (+ optional arxiv API)│
538
+ │ │ - filter_organization │
539
+ │ └────────────┬─────────────┘
540
+ │ │
541
+ ▼ │
542
+ ┌─────────────────────────┐ │
543
+ │ frontmatter.py │ ◀─────────┘ (consults heuristics
544
+ │ - merge user overrides │ before YAML emission)
498
545
  │ - derive title/date/… │
546
+ │ - emit produced_by │ ◀ NEW v1.0.2
499
547
  │ - compute content_hash │
500
548
  │ - emit YAML │
501
549
  └────────────┬────────────┘
@@ -504,7 +552,7 @@ any2md uses a two-lane post-processing pipeline: one lane for layout-trustworthy
504
552
  <output>.md ← frontmatter + cleaned body
505
553
  ```
506
554
 
507
- The two-lane design exists because Docling output is layout-correct (running aggressive line-wrap repair on it would corrupt tables), while pymupdf4llm/trafilatura/TXT output benefits from heavier regex repair. The shared cleanup pass runs identically for both lanes — that's the determinism boundary that makes `content_hash` reproducible. Full stage-by-stage contract reference, plus contributor guidance for adding new converters or pipeline stages, lives in [docs/architecture.md](docs/architecture.md).
555
+ The two-lane design exists because Docling output is layout-correct (running aggressive line-wrap repair on it would corrupt tables), while pymupdf4llm/trafilatura/TXT output benefits from heavier regex repair. The shared cleanup pass runs identically for both lanes — that's the determinism boundary that makes `content_hash` reproducible. v1.0.2 added a leaf `heuristics.py` module that `frontmatter.compose()` consults to refine the title, abstract, authors, and organization fields before YAML emission; new pipeline stages C8 and T7–T10 address concrete artifacts found in academic and standards-document PDFs. Full stage-by-stage contract reference, plus contributor guidance for adding new converters or pipeline stages, lives in [docs/architecture.md](docs/architecture.md).
508
556
 
509
557
  ## Migrating from v0.7
510
558
 
@@ -1,7 +1,27 @@
1
+ <p align="center">
2
+ <img src="assets/logo.png" alt="any2md" width="500"/>
3
+ </p>
4
+
1
5
  # any2md
2
6
 
3
7
  Convert PDFs, DOCX, HTML, URLs, and plain text into structured, machine-consumable Markdown for downstream RAG pipelines.
4
8
 
9
+ ## What's new in 1.0.2
10
+
11
+ A patch release focused on frontmatter and body-content quality, validated against four representative inputs (an arxiv academic PDF, an ISO/IEC standard, an academic DOCX, a Wikipedia URL). Eleven distinct artifacts are addressed:
12
+
13
+ - New `any2md/heuristics.py` module — pure-functions library that `frontmatter.compose()` consults to refine `title`, `abstract_for_rag`, `authors`, and `organization` before emission.
14
+ - New `produced_by` extension field, sitting between `extracted_via` and `pages`. Records the software that produced the source file (PDF `Creator`, DOCX `Application`). Distinct from `extracted_via`, which records the any2md backend.
15
+ - New shared cleanup stage **C8 `decode_html_entities`** — universal removal of `&amp;`, `&lt;`, `&gt;`, and numeric entities (`&#x2014;`, `&#8212;`) from body text. Code-block aware.
16
+ - Four new text-lane stages (aggressive/maximum profile only):
17
+ - **T7 `dedupe_toc_table`** — strips table-formatted TOCs that the existing T4 text-formatted heuristic doesn't catch. Common on academic PDFs.
18
+ - **T8 `strip_cover_artifacts`** — drops cover-page noise (QR-code blurbs, "Third edition 2022-02"-style version stamps) that appears before the first H2.
19
+ - **T9 `strip_repeated_byline`** — removes "Author's Contact Information:" and similar lines that duplicate a byline.
20
+ - **T10 `strip_web_fragments`** — drops trafilatura extraction fragments (orphan `|` and `>` lines, short incomplete sentences surrounded by blank lines).
21
+ - New CLI flag `--no-arxiv-lookup`. Arxiv enrichment for filenames matching `\d{4}\.\d{4,5}` is **on by default**; this flag disables it for airgapped environments.
22
+
23
+ The full design rationale lives in [docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md](docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md). Per-fix detail is in the [CHANGELOG](CHANGELOG.md).
24
+
5
25
  ## What this is
6
26
 
7
27
  any2md ingests heterogeneous source documents and emits Markdown with a fixed YAML frontmatter contract. The frontmatter shape is SSRM-compatible (Structured Security Reasoning Markdown — a documented schema for LLM-consumable documents), so retrieval pipelines, embeddings jobs, and chunking utilities downstream can rely on field names, types, and a deterministic `content_hash` for cache invalidation. The body is NFC-normalized with LF line endings, and the heading hierarchy is guaranteed to start at H1 and not skip levels. The goal is one stable shape, regardless of whether a document started life as a scanned PDF, a Word doc, a Wikipedia page, or a plain text dump.
@@ -114,6 +134,7 @@ source_file: "safety-alignment.docx" # original filename (any2md e
114
134
  type: "docx" # source format (any2md extension field)
115
135
  word_count: 14289 # any2md extension field
116
136
  extracted_via: "docling" # which backend produced the markdown
137
+ produced_by: "Microsoft® Word for Microsoft 365" # software that produced the source (new in v1.0.2)
117
138
  ---
118
139
  ```
119
140
 
@@ -196,7 +217,7 @@ You'd run this when your corpus has multi-column PDFs, complex tables, or scanne
196
217
  pip install "any2md[high-fidelity]"
197
218
  ```
198
219
 
199
- After install, the CLI auto-detects Docling and uses it for PDF and DOCX. To force it explicitly (and fail fast when it isn't installed), pass `-H` / `--high-fidelity`. To require it (treat fallback as an error), combine with `--strict`.
220
+ After install, the CLI auto-detects Docling and uses it for PDF and DOCX. To force it explicitly (and fail fast when it isn't installed), pass `-H` / `--high-fidelity`. To require it (treat fallback as an error), combine with `--strict`. Use `--backend pymupdf4llm` to force the lightweight backend even when Docling is installed (useful when Docling produces extraction artifacts on a specific input).
200
221
 
201
222
  ### When do I need high-fidelity?
202
223
 
@@ -378,6 +399,10 @@ type_code = "GD"
378
399
 
379
400
  With this file in place and `--auto-id` passed, generated `document_id` values look like `CSA-2026-GD-a3f1c91d`. The resolution order, highest priority first: explicit `--meta KEY=VAL` arguments, then `--meta-file PATH` (when given), then auto-discovered `.any2md.toml`, then the tool's defaults. Full flag and config reference in [docs/cli-reference.md](docs/cli-reference.md).
380
401
 
402
+ ### Network behavior
403
+
404
+ For PDFs whose filename matches the arxiv ID pattern (`\d{4}\.\d{4,5}`, e.g. `2501.17755v1.pdf`), any2md by default queries `https://export.arxiv.org/api/query` to enrich `authors`, `abstract_for_rag`, and `date` from the official arxiv metadata. The lookup is SSRF-guarded, has a 5-second timeout, and emits a non-blocking warning on any failure — conversion proceeds either way. Pass `--no-arxiv-lookup` to disable this for airgapped environments or when you want any2md to make no outbound calls. See [docs/cli-reference.md](docs/cli-reference.md#--no-arxiv-lookup) for details.
405
+
381
406
  ### Output verbosity and exit codes
382
407
 
383
408
  The CLI has three verbosity flags that pair with a four-value exit-code contract:
@@ -412,7 +437,7 @@ The full symptom-cause-fix triage guide, plus deeper diagnosis steps and follow-
412
437
  any2md uses a two-lane post-processing pipeline: one lane for layout-trustworthy backends (Docling), one for backends that need heavier text repair (pymupdf4llm, trafilatura, mammoth, the TXT heuristic). Both lanes converge on a shared cleanup pass, and the same `frontmatter.py` module emits YAML for every input format.
413
438
 
414
439
  ```
415
- any2md v1.0
440
+ any2md v1.0.2
416
441
  │
417
442
  ┌────┴────┐
418
443
  CLI │ cli.py │ parse args, classify URL/file/dir,
@@ -429,43 +454,66 @@ any2md uses a two-lane post-processing pipeline: one lane for layout-trustworthy
429
454
  │ pymupdf4llm│ mammoth+md │ │
430
455
  └────┬───────┴────┬───────┴───┬───┘
431
456
  │ │ │
457
+ │ SourceMeta carries:
458
+ │ organization, produced_by ◀ NEW in v1.0.2
459
+ │ (split via heuristics.filter_organization)
460
+ │
432
461
  structured-md structured-md text-md
433
462
  │ │ │
434
463
  └─────┬──────┴───────────┘
435
464
  ▼
436
465
  ┌─────────────────────────┐
437
466
  │ pipeline/structured.py │ structured lane
438
- │ - figure caption pass │ (trusts layout)
439
- │ - table compactor │
440
- │ - cite normalizer │
441
- │ - heading hierarchy │
467
+ │ S1 figure caption pass │ (trusts layout)
468
+ │ S2 table compactor │
469
+ │ S3 cite normalizer │
470
+ │ S4 heading hierarchy │
442
471
  └────────────┬────────────┘
443
472
  │
444
473
  ┌────────────┴────────────┐
445
474
  │ pipeline/text.py │ text lane
446
- │ - line-wrap repair │
447
- │ - dehyphenate │
448
- │ - duplicate paragraph │
449
- │ - TOC dedupe │
450
- │ - hdr/ftr strip │
451
- │ - list/code restore │
475
+ │ T1 line-wrap repair │
476
+ │ T2 dehyphenate │
477
+ │ T9 strip repeated │ ◀ NEW (E1)
478
+ │ byline │
479
+ │ T3 dedupe paragraphs │
480
+ │ T4 dedupe TOC block │
481
+ │ T7 dedupe TOC table │ ◀ NEW (D1)
482
+ │ T5 hdr/ftr strip │
483
+ │ T10 strip web │ ◀ NEW (D2)
484
+ │ fragments │
485
+ │ T6 list/code restore │
486
+ │ T8 strip cover │ ◀ NEW (D3)
487
+ │ artifacts │
452
488
  └────────────┬────────────┘
453
489
  │
454
490
  ┌────────────┴────────────┐
455
491
  │ pipeline/cleanup.py │ shared lane
456
- │ - NFC normalize │ (always last)
457
- │ - soft hyphen strip │
458
- │ - ligature normalize │
459
- │ - quote/dash normalize │
460
- │ - whitespace collapse │
461
- │ - footnote marker strip│
462
- │ - validate │
492
+ │ C1 NFC normalize │ (always last)
493
+ │ C2 soft hyphen strip │
494
+ │ C3 ligature normalize │
495
+ │ C4 quote/dash normalize│
496
+ │ C5 whitespace collapse │
497
+ │ C8 decode HTML entities│ ◀ NEW (B1, all profiles)
498
+ │ C6 footnote marker strip│
499
+ │ C7 validate │
463
500
  └────────────┬────────────┘
464
501
  │
465
- ┌────────────┴────────────┐
466
- │ frontmatter.py │ SSRM-compatible
467
- │ - merge user overrides │ YAML emitter
502
+ │ ┌──────────────────────────┐
503
+ │ │ any2md/heuristics.py │ ◀ NEW v1.0.2
504
+ │ │ - refine_title │
505
+ │ │ - refine_abstract │
506
+ │ │ - extract_authors │
507
+ │ │ (+ optional arxiv API)│
508
+ │ │ - filter_organization │
509
+ │ └────────────┬─────────────┘
510
+ │ │
511
+ ▼ │
512
+ ┌─────────────────────────┐ │
513
+ │ frontmatter.py │ ◀─────────┘ (consults heuristics
514
+ │ - merge user overrides │ before YAML emission)
468
515
  │ - derive title/date/… │
516
+ │ - emit produced_by │ ◀ NEW v1.0.2
469
517
  │ - compute content_hash │
470
518
  │ - emit YAML │
471
519
  └────────────┬────────────┘
@@ -474,7 +522,7 @@ any2md uses a two-lane post-processing pipeline: one lane for layout-trustworthy
474
522
  <output>.md ← frontmatter + cleaned body
475
523
  ```
476
524
 
477
- The two-lane design exists because Docling output is layout-correct (running aggressive line-wrap repair on it would corrupt tables), while pymupdf4llm/trafilatura/TXT output benefits from heavier regex repair. The shared cleanup pass runs identically for both lanes — that's the determinism boundary that makes `content_hash` reproducible. Full stage-by-stage contract reference, plus contributor guidance for adding new converters or pipeline stages, lives in [docs/architecture.md](docs/architecture.md).
525
+ The two-lane design exists because Docling output is layout-correct (running aggressive line-wrap repair on it would corrupt tables), while pymupdf4llm/trafilatura/TXT output benefits from heavier regex repair. The shared cleanup pass runs identically for both lanes — that's the determinism boundary that makes `content_hash` reproducible. v1.0.2 added a leaf `heuristics.py` module that `frontmatter.compose()` consults to refine the title, abstract, authors, and organization fields before YAML emission; new pipeline stages C8 and T7–T10 address concrete artifacts found in academic and standards-document PDFs. Full stage-by-stage contract reference, plus contributor guidance for adding new converters or pipeline stages, lives in [docs/architecture.md](docs/architecture.md).
478
526
 
479
527
  ## Migrating from v0.7
480
528
 
@@ -1,3 +1,3 @@
1
1
  """Convert PDF, DOCX, HTML, and TXT files to LLM-optimized Markdown."""
2
2
 
3
- __version__ = "1.0.0"
3
+ __version__ = "1.0.2"
@@ -132,6 +132,24 @@ def main():
132
132
  action="store_true",
133
133
  help="Force the Docling backend (PDF/DOCX). Exit 1 if not installed.",
134
134
  )
135
+ parser.add_argument(
136
+ "--no-arxiv-lookup",
137
+ action="store_true",
138
+ help="Disable the arxiv API lookup that enriches frontmatter for "
139
+ "PDFs whose filename matches the arxiv ID pattern (\\d{4}.\\d{4,5}). "
140
+ "Useful in airgapped envs or when you don't want any2md to make "
141
+ "network calls. Default: arxiv lookup is enabled.",
142
+ )
143
+ parser.add_argument(
144
+ "--backend",
145
+ choices=("docling", "pymupdf4llm", "mammoth"),
146
+ default=None,
147
+ help="Force a specific extraction backend. By default any2md auto-selects "
148
+ "(prefers Docling when installed). Use 'docling' (equivalent to --high-fidelity), "
149
+ "'pymupdf4llm' to force the lightweight PDF fallback even when Docling is installed, "
150
+ "or 'mammoth' to force the lightweight DOCX fallback. Mismatched format/backend "
151
+ "combinations (e.g., --backend pymupdf4llm on a DOCX) error out per file.",
152
+ )
135
153
  parser.add_argument(
136
154
  "--ocr-figures",
137
155
  action="store_true",
@@ -228,11 +246,16 @@ def main():
228
246
  sys.exit(1)
229
247
  overrides = _deep_merge_overrides(overrides, cli_meta)
230
248
 
231
- if args.high_fidelity or args.ocr_figures or args.save_images:
249
+ if (
250
+ args.high_fidelity
251
+ or args.ocr_figures
252
+ or args.save_images
253
+ or args.backend == "docling"
254
+ ):
232
255
  from any2md._docling import has_docling, INSTALL_HINT_MSG
233
256
  if not has_docling():
234
257
  print(
235
- f" ERROR: Docling required for --high-fidelity / --ocr-figures / --save-images.\n"
258
+ f" ERROR: Docling required for --high-fidelity / --ocr-figures / --save-images / --backend docling.\n"
236
259
  f" {INSTALL_HINT_MSG}",
237
260
  file=sys.stderr,
238
261
  )
@@ -252,6 +275,8 @@ def main():
252
275
  auto_id_prefix=auto_id_prefix,
253
276
  auto_id_type_code=auto_id_type_code,
254
277
  frontmatter_overrides=overrides or None,
278
+ backend=args.backend,
279
+ arxiv_lookup=not args.no_arxiv_lookup,
255
280
  )
256
281
 
257
282
  # CLI-only output controls (not part of PipelineOptions).
@@ -22,6 +22,7 @@ from any2md import pipeline
22
22
  from any2md._docling import has_docling
23
23
  from any2md.converters import add_warnings, is_quiet
24
24
  from any2md.frontmatter import SourceMeta, compose
25
+ from any2md.heuristics import filter_organization
25
26
  from any2md.pipeline import PipelineOptions
26
27
  from any2md.utils import sanitize_filename
27
28
 
@@ -40,6 +41,7 @@ def _read_docx_metadata(docx_path: Path) -> dict[str, object]:
40
41
  "title_hint": None,
41
42
  "authors": [],
42
43
  "organization": None,
44
+ "produced_by": None,
43
45
  "date": None,
44
46
  "keywords": [],
45
47
  }
@@ -65,8 +67,22 @@ def _read_docx_metadata(docx_path: Path) -> dict[str, object]:
65
67
  with z.open("docProps/app.xml") as f:
66
68
  root = ET.parse(f).getroot()
67
69
  company = root.findtext("ext:Company", namespaces=_NS_APP)
68
- if company:
70
+ application = root.findtext("ext:Application", namespaces=_NS_APP)
71
+ # v1.0.2: Company takes priority for `organization` (real
72
+ # legal entity); Application is software → goes to
73
+ # `produced_by` via filter_organization. When Company is
74
+ # absent we route Application through filter_organization
75
+ # for both fields so a real-org Application name still
76
+ # populates `organization`.
77
+ if company and company.strip():
69
78
  out["organization"] = company.strip()
79
+ if application and application.strip():
80
+ app_result = filter_organization(application.strip())
81
+ out["produced_by"] = app_result.produced_by
82
+ elif application and application.strip():
83
+ app_result = filter_organization(application.strip())
84
+ out["organization"] = app_result.organization
85
+ out["produced_by"] = app_result.produced_by
70
86
  except KeyError:
71
87
  pass
72
88
  except (zipfile.BadZipFile, ET.ParseError):
@@ -114,7 +130,24 @@ def convert_docx(
114
130
  return True
115
131
 
116
132
  try:
117
- if has_docling():
133
+ # Backend override (PipelineOptions.backend). When set, honor the
134
+ # caller's choice instead of the auto-select. ``pymupdf4llm`` is
135
+ # invalid for DOCX — fail the file with a clear error.
136
+ if options.backend == "pymupdf4llm":
137
+ print(
138
+ f" FAIL: {docx_path.name} -- backend 'pymupdf4llm' is not "
139
+ f"valid for DOCX input (pymupdf4llm processes PDF only).",
140
+ file=sys.stderr,
141
+ )
142
+ return False
143
+
144
+ if options.backend == "mammoth":
145
+ md_text, extracted_via = _extract_via_mammoth(docx_path, options)
146
+ lane = "text"
147
+ elif options.backend == "docling":
148
+ md_text, extracted_via = _extract_via_docling(docx_path)
149
+ lane = "structured"
150
+ elif has_docling():
118
151
  try:
119
152
  md_text, extracted_via = _extract_via_docling(docx_path)
120
153
  lane = "structured"
@@ -150,6 +183,7 @@ def convert_docx(
150
183
  doc_type="docx",
151
184
  extracted_via=extracted_via,
152
185
  lane=lane,
186
+ produced_by=props["produced_by"],
153
187
  )
154
188
  full = compose(
155
189
  md_text, meta, options, overrides=options.frontmatter_overrides
@@ -20,6 +20,7 @@ from any2md import pipeline
20
20
  from any2md._docling import has_docling, install_hint
21
21
  from any2md.converters import add_warnings, is_quiet
22
22
  from any2md.frontmatter import SourceMeta, compose
23
+ from any2md.heuristics import filter_organization
23
24
  from any2md.pipeline import PipelineOptions
24
25
  from any2md.utils import sanitize_filename
25
26
 
@@ -47,10 +48,13 @@ def _parse_pdf_date(raw: str | None) -> str | None:
47
48
 
48
49
  def _parse_pdf_metadata(doc: "pymupdf.Document") -> dict[str, object]:
49
50
  meta = doc.metadata or {}
51
+ creator_raw = (meta.get("creator") or "").strip() or None
52
+ org_result = filter_organization(creator_raw)
50
53
  return {
51
54
  "title_hint": (meta.get("title") or "").strip() or None,
52
55
  "authors": _parse_pdf_authors(meta.get("author")),
53
- "organization": (meta.get("creator") or "").strip() or None,
56
+ "organization": org_result.organization,
57
+ "produced_by": org_result.produced_by,
54
58
  "date": _parse_pdf_date(meta.get("creationDate")),
55
59
  "keywords": [
56
60
  k.strip() for k in (meta.get("keywords") or "").split(",") if k.strip()
@@ -182,9 +186,31 @@ def convert_pdf(
182
186
  return True
183
187
 
184
188
  try:
185
- use_docling = has_docling()
189
+ # Backend override (PipelineOptions.backend). When set, honor the
190
+ # caller's choice instead of the auto-select. ``mammoth`` is
191
+ # invalid for PDFs — fail the file with a clear error.
192
+ if options.backend == "mammoth":
193
+ print(
194
+ f" FAIL: {pdf_path.name} -- backend 'mammoth' is not "
195
+ f"valid for PDF input (mammoth processes DOCX only).",
196
+ file=sys.stderr,
197
+ )
198
+ return False
199
+
200
+ if options.backend == "pymupdf4llm":
201
+ use_docling = False
202
+ elif options.backend == "docling":
203
+ # Same install-required behavior as --high-fidelity. If
204
+ # Docling isn't importable, fall through to the existing
205
+ # auto-select path which will error via _extract_via_docling
206
+ # below if Docling truly isn't usable. The CLI layer
207
+ # pre-checks install for both --high-fidelity and
208
+ # --backend docling, so this branch normally has Docling.
209
+ use_docling = has_docling()
210
+ else:
211
+ use_docling = has_docling()
186
212
 
187
- if not use_docling and pdf_looks_complex(pdf_path):
213
+ if not use_docling and options.backend is None and pdf_looks_complex(pdf_path):
188
214
  install_hint()
189
215
 
190
216
  # Always extract metadata via PyMuPDF — independent of which
@@ -234,6 +260,7 @@ def convert_pdf(
234
260
  doc_type="pdf",
235
261
  extracted_via=extracted_via,
236
262
  lane=lane,
263
+ produced_by=props["produced_by"],
237
264
  )
238
265
  full = compose(
239
266
  md_text, meta, options, overrides=options.frontmatter_overrides