any2md 1.0.1__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.1 → any2md-1.0.2}/.gitignore +1 -0
  2. {any2md-1.0.1 → any2md-1.0.2}/CHANGELOG.md +61 -0
  3. {any2md-1.0.1 → any2md-1.0.2}/PKG-INFO +71 -23
  4. {any2md-1.0.1 → any2md-1.0.2}/README.md +70 -22
  5. {any2md-1.0.1 → any2md-1.0.2}/any2md/__init__.py +1 -1
  6. {any2md-1.0.1 → any2md-1.0.2}/any2md/cli.py +9 -0
  7. {any2md-1.0.1 → any2md-1.0.2}/any2md/converters/docx.py +18 -1
  8. {any2md-1.0.1 → any2md-1.0.2}/any2md/converters/pdf.py +6 -1
  9. {any2md-1.0.1 → any2md-1.0.2}/any2md/frontmatter.py +33 -3
  10. any2md-1.0.2/any2md/heuristics.py +580 -0
  11. {any2md-1.0.1 → any2md-1.0.2}/any2md/pipeline/__init__.py +6 -0
  12. {any2md-1.0.1 → any2md-1.0.2}/any2md/pipeline/cleanup.py +22 -0
  13. {any2md-1.0.1 → 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.1 → any2md-1.0.2}/docs/architecture.md +174 -59
  18. {any2md-1.0.1 → any2md-1.0.2}/docs/cli-reference.md +40 -1
  19. {any2md-1.0.1 → any2md-1.0.2}/docs/output-format.md +45 -2
  20. any2md-1.0.2/docs/superpowers/plans/2026-04-26-any2md-v1.0.2-implementation.md +307 -0
  21. any2md-1.0.2/docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md +694 -0
  22. {any2md-1.0.1 → any2md-1.0.2}/docs/troubleshooting.md +11 -0
  23. {any2md-1.0.1 → any2md-1.0.2}/docs/upgrading-from-0.7.md +1 -0
  24. any2md-1.0.2/tests/cli/test_cli_no_arxiv_lookup.py +41 -0
  25. any2md-1.0.2/tests/integration/test_docx_mammoth_fallback.py +174 -0
  26. {any2md-1.0.1 → any2md-1.0.2}/tests/integration/test_pdf_docling.py +3 -0
  27. any2md-1.0.2/tests/integration/test_pdf_pymupdf_fallback.py +79 -0
  28. any2md-1.0.2/tests/unit/pipeline/test_cleanup_html_entities.py +51 -0
  29. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_runner.py +8 -0
  30. any2md-1.0.2/tests/unit/pipeline/test_text_dedupe_toc_table.py +106 -0
  31. any2md-1.0.2/tests/unit/pipeline/test_text_strip_cover_artifacts.py +79 -0
  32. any2md-1.0.2/tests/unit/pipeline/test_text_strip_repeated_byline.py +57 -0
  33. any2md-1.0.2/tests/unit/pipeline/test_text_strip_web_fragments.py +63 -0
  34. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/test_frontmatter_compose.py +69 -0
  35. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/test_frontmatter_helpers.py +1 -1
  36. any2md-1.0.2/tests/unit/test_heuristics.py +507 -0
  37. any2md-1.0.1/tests/integration/test_docx_mammoth_fallback.py +0 -42
  38. any2md-1.0.1/tests/integration/test_pdf_pymupdf_fallback.py +0 -37
  39. {any2md-1.0.1 → any2md-1.0.2}/CONTRIBUTING.md +0 -0
  40. {any2md-1.0.1 → any2md-1.0.2}/LICENSE +0 -0
  41. {any2md-1.0.1 → any2md-1.0.2}/any2md/__main__.py +0 -0
  42. {any2md-1.0.1 → any2md-1.0.2}/any2md/_docling.py +0 -0
  43. {any2md-1.0.1 → any2md-1.0.2}/any2md/config.py +0 -0
  44. {any2md-1.0.1 → any2md-1.0.2}/any2md/converters/__init__.py +0 -0
  45. {any2md-1.0.1 → any2md-1.0.2}/any2md/converters/html.py +0 -0
  46. {any2md-1.0.1 → any2md-1.0.2}/any2md/converters/txt.py +0 -0
  47. {any2md-1.0.1 → any2md-1.0.2}/any2md/pipeline/structured.py +0 -0
  48. {any2md-1.0.1 → any2md-1.0.2}/any2md/utils.py +0 -0
  49. {any2md-1.0.1 → any2md-1.0.2}/any2md/validators.py +0 -0
  50. {any2md-1.0.1 → any2md-1.0.2}/docs/superpowers/plans/2026-04-26-any2md-v1-phase1-foundation.md +0 -0
  51. {any2md-1.0.1 → any2md-1.0.2}/docs/superpowers/plans/2026-04-26-any2md-v1-phase2-docling-backend.md +0 -0
  52. {any2md-1.0.1 → any2md-1.0.2}/docs/superpowers/plans/2026-04-26-any2md-v1-phase3-figures-ocr-text-lane.md +0 -0
  53. {any2md-1.0.1 → any2md-1.0.2}/docs/superpowers/plans/2026-04-26-any2md-v1-phase4-polish-config-docs.md +0 -0
  54. {any2md-1.0.1 → any2md-1.0.2}/docs/superpowers/specs/2026-04-26-any2md-v1-design.md +0 -0
  55. {any2md-1.0.1 → any2md-1.0.2}/pyproject.toml +0 -0
  56. {any2md-1.0.1 → any2md-1.0.2}/requirements.txt +0 -0
  57. {any2md-1.0.1 → any2md-1.0.2}/scripts/validate-phase1.sh +0 -0
  58. {any2md-1.0.1 → any2md-1.0.2}/tests/__init__.py +0 -0
  59. {any2md-1.0.1 → any2md-1.0.2}/tests/cli/__init__.py +0 -0
  60. {any2md-1.0.1 → any2md-1.0.2}/tests/cli/test_cli_backend.py +0 -0
  61. {any2md-1.0.1 → any2md-1.0.2}/tests/cli/test_cli_existing_flags.py +0 -0
  62. {any2md-1.0.1 → any2md-1.0.2}/tests/cli/test_cli_exit_codes.py +0 -0
  63. {any2md-1.0.1 → any2md-1.0.2}/tests/cli/test_cli_high_fidelity.py +0 -0
  64. {any2md-1.0.1 → any2md-1.0.2}/tests/cli/test_cli_meta.py +0 -0
  65. {any2md-1.0.1 → any2md-1.0.2}/tests/cli/test_cli_ocr_save_images.py +0 -0
  66. {any2md-1.0.1 → any2md-1.0.2}/tests/cli/test_cli_quiet_verbose.py +0 -0
  67. {any2md-1.0.1 → any2md-1.0.2}/tests/cli/test_cli_strict.py +0 -0
  68. {any2md-1.0.1 → any2md-1.0.2}/tests/conftest.py +0 -0
  69. {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/__init__.py +0 -0
  70. {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/docs/ligatures_and_softhyphens.txt +0 -0
  71. {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/docs/multi_column.pdf +0 -0
  72. {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/docs/table_heavy.docx +0 -0
  73. {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/docs/web_page.html +0 -0
  74. {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/make_fixtures.py +0 -0
  75. {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/snapshots/ligatures_and_softhyphens.md +0 -0
  76. {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/snapshots/multi_column.docling.md +0 -0
  77. {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/snapshots/multi_column.fallback.md +0 -0
  78. {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/snapshots/table_heavy.docling.md +0 -0
  79. {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/snapshots/table_heavy.fallback.md +0 -0
  80. {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/snapshots/web_page.md +0 -0
  81. {any2md-1.0.1 → any2md-1.0.2}/tests/integration/__init__.py +0 -0
  82. {any2md-1.0.1 → any2md-1.0.2}/tests/integration/test_docx_backend_override.py +0 -0
  83. {any2md-1.0.1 → any2md-1.0.2}/tests/integration/test_docx_docling.py +0 -0
  84. {any2md-1.0.1 → any2md-1.0.2}/tests/integration/test_html_trafilatura.py +0 -0
  85. {any2md-1.0.1 → any2md-1.0.2}/tests/integration/test_pdf_backend_override.py +0 -0
  86. {any2md-1.0.1 → any2md-1.0.2}/tests/integration/test_save_images.py +0 -0
  87. {any2md-1.0.1 → any2md-1.0.2}/tests/integration/test_snapshots.py +0 -0
  88. {any2md-1.0.1 → any2md-1.0.2}/tests/integration/test_txt.py +0 -0
  89. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/__init__.py +0 -0
  90. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/__init__.py +0 -0
  91. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_cleanup_footnote_markers.py +0 -0
  92. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_cleanup_ligatures.py +0 -0
  93. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_cleanup_nfc.py +0 -0
  94. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_cleanup_quotes_dashes.py +0 -0
  95. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_cleanup_soft_hyphens.py +0 -0
  96. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_cleanup_validate.py +0 -0
  97. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_cleanup_whitespace.py +0 -0
  98. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_structured_compact_tables.py +0 -0
  99. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_structured_heading_hierarchy.py +0 -0
  100. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_structured_lift_figures.py +0 -0
  101. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_structured_normalize_cites.py +0 -0
  102. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_text_dedupe_paragraphs.py +0 -0
  103. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_text_dedupe_toc.py +0 -0
  104. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_text_dehyphenate.py +0 -0
  105. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_text_repair_line_wraps.py +0 -0
  106. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_text_restore_lists_code.py +0 -0
  107. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_text_strip_headers_footers.py +0 -0
  108. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/test_auto_id.py +0 -0
  109. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/test_config.py +0 -0
  110. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/test_content_hash.py +0 -0
  111. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/test_docling_helper.py +0 -0
  112. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/test_meta_overrides.py +0 -0
  113. {any2md-1.0.1 → any2md-1.0.2}/tests/unit/test_pdf_complexity.py +0 -0
  114. {any2md-1.0.1 → 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,67 @@ 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
+
7
68
  ## [1.0.1] — 2026-04-26
8
69
 
9
70
  Patch release. Adds an explicit backend-selection CLI flag.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: any2md
3
- Version: 1.0.1
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
 
@@ -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
 
@@ -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.1"
3
+ __version__ = "1.0.2"
@@ -132,6 +132,14 @@ 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
+ )
135
143
  parser.add_argument(
136
144
  "--backend",
137
145
  choices=("docling", "pymupdf4llm", "mammoth"),
@@ -268,6 +276,7 @@ def main():
268
276
  auto_id_type_code=auto_id_type_code,
269
277
  frontmatter_overrides=overrides or None,
270
278
  backend=args.backend,
279
+ arxiv_lookup=not args.no_arxiv_lookup,
271
280
  )
272
281
 
273
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):
@@ -167,6 +183,7 @@ def convert_docx(
167
183
  doc_type="docx",
168
184
  extracted_via=extracted_via,
169
185
  lane=lane,
186
+ produced_by=props["produced_by"],
170
187
  )
171
188
  full = compose(
172
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()
@@ -256,6 +260,7 @@ def convert_pdf(
256
260
  doc_type="pdf",
257
261
  extracted_via=extracted_via,
258
262
  lane=lane,
263
+ produced_by=props["produced_by"],
259
264
  )
260
265
  full = compose(
261
266
  md_text, meta, options, overrides=options.frontmatter_overrides
@@ -15,6 +15,7 @@ from dataclasses import dataclass
15
15
  from datetime import date as _date_cls
16
16
  from typing import Any, Literal
17
17
 
18
+ from any2md import heuristics
18
19
  from any2md.pipeline import Lane, PipelineOptions
19
20
 
20
21
 
@@ -62,6 +63,11 @@ class SourceMeta:
62
63
  "trafilatura", "trafilatura+bs4_fallback", "heuristic",
63
64
  ]
64
65
  lane: Lane
66
+ # v1.0.2: software/tool that produced the source (PDF Creator field for
67
+ # PDFs, Application field for DOCX). Distinct from `organization`, which
68
+ # is the human/legal entity. Default None to keep existing call sites
69
+ # working without churn.
70
+ produced_by: str | None = None
65
71
 
66
72
 
67
73
  def estimate_tokens(body: str) -> int:
@@ -185,11 +191,33 @@ def _build_fields(
185
191
  can tell "absent" apart from "blank".
186
192
  """
187
193
  fallback = meta.source_file or meta.source_url or "untitled"
188
- title = derive_title(body, meta.title_hint, fallback)
194
+ title_raw = derive_title(body, meta.title_hint, fallback)
195
+ arxiv_id = heuristics.is_arxiv_filename(meta.source_file or "")
196
+ title = heuristics.refine_title(
197
+ title_raw, body,
198
+ source_url=meta.source_url,
199
+ profile=options.profile,
200
+ )
189
201
  content_hash = compute_content_hash(body)
190
202
  token_est = estimate_tokens(body)
191
203
  chunk_level = recommend_chunk_level(body)
192
- abstract = extract_abstract(body) if token_est >= 500 else None
204
+ abstract_raw = extract_abstract(body) if token_est >= 500 else None
205
+ abstract = (
206
+ heuristics.refine_abstract(abstract_raw, body, profile=options.profile)
207
+ if token_est >= 500
208
+ else None
209
+ )
210
+ # Author chain — only invoke heuristics when meta.authors is empty.
211
+ # Caller-supplied authors (e.g. from extractor metadata) take priority.
212
+ authors = list(meta.authors)
213
+ if not authors:
214
+ authors = heuristics.extract_authors(
215
+ body,
216
+ title_hint=meta.title_hint,
217
+ arxiv_id=arxiv_id,
218
+ arxiv_lookup_enabled=options.arxiv_lookup,
219
+ profile=options.profile,
220
+ )
193
221
  today = _date_cls.today().isoformat()
194
222
  fm_date = meta.date or today
195
223
 
@@ -210,7 +238,7 @@ def _build_fields(
210
238
  fields["status"] = "draft"
211
239
  fields["document_type"] = ""
212
240
  fields["content_domain"] = []
213
- fields["authors"] = list(meta.authors)
241
+ fields["authors"] = authors
214
242
  fields["organization"] = meta.organization or ""
215
243
  fields["generation_metadata"] = {"authored_by": "unknown"}
216
244
  fields["content_hash"] = content_hash
@@ -227,6 +255,8 @@ def _build_fields(
227
255
  fields["source_url"] = meta.source_url
228
256
  fields["type"] = meta.doc_type # v0.7-compat field (spec §3.2)
229
257
  fields["extracted_via"] = meta.extracted_via # v1.0 provenance extension
258
+ if meta.produced_by is not None:
259
+ fields["produced_by"] = meta.produced_by # v1.0.2 extension (spec §6.1)
230
260
  if meta.pages is not None:
231
261
  fields["pages"] = meta.pages
232
262
  if meta.word_count is not None: