any2md 1.0.2__tar.gz → 1.0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. {any2md-1.0.2 → any2md-1.0.4}/CHANGELOG.md +106 -0
  2. any2md-1.0.4/CODE_OF_CONDUCT.md +37 -0
  3. {any2md-1.0.2 → any2md-1.0.4}/PKG-INFO +16 -14
  4. {any2md-1.0.2 → any2md-1.0.4}/README.md +15 -13
  5. any2md-1.0.4/SECURITY.md +83 -0
  6. {any2md-1.0.2 → any2md-1.0.4}/any2md/__init__.py +1 -1
  7. {any2md-1.0.2 → any2md-1.0.4}/any2md/cli.py +6 -11
  8. {any2md-1.0.2 → any2md-1.0.4}/any2md/converters/__init__.py +5 -3
  9. {any2md-1.0.2 → any2md-1.0.4}/any2md/converters/docx.py +2 -6
  10. {any2md-1.0.2 → any2md-1.0.4}/any2md/converters/html.py +6 -5
  11. {any2md-1.0.2 → any2md-1.0.4}/any2md/converters/pdf.py +2 -6
  12. {any2md-1.0.2 → any2md-1.0.4}/any2md/converters/txt.py +17 -5
  13. {any2md-1.0.2 → any2md-1.0.4}/any2md/frontmatter.py +18 -17
  14. {any2md-1.0.2 → any2md-1.0.4}/any2md/heuristics.py +59 -35
  15. {any2md-1.0.2 → any2md-1.0.4}/any2md/pipeline/cleanup.py +38 -21
  16. {any2md-1.0.2 → any2md-1.0.4}/any2md/pipeline/structured.py +32 -2
  17. {any2md-1.0.2 → any2md-1.0.4}/any2md/pipeline/text.py +26 -13
  18. any2md-1.0.4/assets/social-preview.jpg +0 -0
  19. {any2md-1.0.2 → any2md-1.0.4}/docs/architecture.md +35 -12
  20. any2md-1.0.4/docs/superpowers/plans/2026-04-26-issue-17-toc-followups.md +981 -0
  21. any2md-1.0.4/docs/superpowers/specs/2026-04-26-issue-17-toc-followups-design.md +239 -0
  22. any2md-1.0.4/docs/superpowers/specs/2026-04-26-v1.0.3-empty-title-orphan-punct-design.md +133 -0
  23. {any2md-1.0.2 → any2md-1.0.4}/docs/troubleshooting.md +4 -0
  24. any2md-1.0.4/scripts/audit-outputs.py +202 -0
  25. {any2md-1.0.2 → any2md-1.0.4}/tests/cli/test_cli_high_fidelity.py +7 -2
  26. {any2md-1.0.2 → any2md-1.0.4}/tests/cli/test_cli_ocr_save_images.py +6 -3
  27. {any2md-1.0.2 → any2md-1.0.4}/tests/integration/test_docx_docling.py +1 -1
  28. {any2md-1.0.2 → any2md-1.0.4}/tests/integration/test_docx_mammoth_fallback.py +5 -13
  29. {any2md-1.0.2 → any2md-1.0.4}/tests/integration/test_html_trafilatura.py +1 -1
  30. {any2md-1.0.2 → any2md-1.0.4}/tests/integration/test_pdf_pymupdf_fallback.py +4 -7
  31. {any2md-1.0.2 → any2md-1.0.4}/tests/integration/test_snapshots.py +1 -2
  32. {any2md-1.0.2 → any2md-1.0.4}/tests/integration/test_txt.py +1 -1
  33. any2md-1.0.4/tests/unit/pipeline/test_cleanup_html_entities_double_encoded.py +45 -0
  34. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_cleanup_ligatures.py +3 -1
  35. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_runner.py +3 -0
  36. any2md-1.0.4/tests/unit/pipeline/test_strip_orphan_punctuation.py +40 -0
  37. any2md-1.0.4/tests/unit/pipeline/test_structured_body_cleanup_stages.py +84 -0
  38. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_structured_compact_tables.py +2 -6
  39. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_text_dedupe_toc_table.py +41 -0
  40. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_text_dehyphenate.py +1 -4
  41. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_text_strip_cover_artifacts.py +1 -9
  42. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_text_strip_repeated_byline.py +2 -14
  43. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_text_strip_web_fragments.py +4 -32
  44. any2md-1.0.4/tests/unit/scripts/__init__.py +0 -0
  45. any2md-1.0.4/tests/unit/scripts/test_audit_outputs.py +81 -0
  46. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/test_auto_id.py +1 -3
  47. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/test_docling_helper.py +1 -0
  48. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/test_frontmatter_compose.py +31 -10
  49. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/test_frontmatter_helpers.py +24 -11
  50. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/test_heuristics.py +113 -62
  51. any2md-1.0.2/assets/social-preview.png +0 -0
  52. {any2md-1.0.2 → any2md-1.0.4}/.gitignore +0 -0
  53. {any2md-1.0.2 → any2md-1.0.4}/CONTRIBUTING.md +0 -0
  54. {any2md-1.0.2 → any2md-1.0.4}/LICENSE +0 -0
  55. {any2md-1.0.2 → any2md-1.0.4}/any2md/__main__.py +0 -0
  56. {any2md-1.0.2 → any2md-1.0.4}/any2md/_docling.py +0 -0
  57. {any2md-1.0.2 → any2md-1.0.4}/any2md/config.py +0 -0
  58. {any2md-1.0.2 → any2md-1.0.4}/any2md/pipeline/__init__.py +0 -0
  59. {any2md-1.0.2 → any2md-1.0.4}/any2md/utils.py +0 -0
  60. {any2md-1.0.2 → any2md-1.0.4}/any2md/validators.py +0 -0
  61. {any2md-1.0.2 → any2md-1.0.4}/assets/logo-original.jpeg +0 -0
  62. {any2md-1.0.2 → any2md-1.0.4}/assets/logo.png +0 -0
  63. {any2md-1.0.2 → any2md-1.0.4}/docs/cli-reference.md +0 -0
  64. {any2md-1.0.2 → any2md-1.0.4}/docs/output-format.md +0 -0
  65. {any2md-1.0.2 → any2md-1.0.4}/docs/superpowers/plans/2026-04-26-any2md-v1-phase1-foundation.md +0 -0
  66. {any2md-1.0.2 → any2md-1.0.4}/docs/superpowers/plans/2026-04-26-any2md-v1-phase2-docling-backend.md +0 -0
  67. {any2md-1.0.2 → any2md-1.0.4}/docs/superpowers/plans/2026-04-26-any2md-v1-phase3-figures-ocr-text-lane.md +0 -0
  68. {any2md-1.0.2 → any2md-1.0.4}/docs/superpowers/plans/2026-04-26-any2md-v1-phase4-polish-config-docs.md +0 -0
  69. {any2md-1.0.2 → any2md-1.0.4}/docs/superpowers/plans/2026-04-26-any2md-v1.0.2-implementation.md +0 -0
  70. {any2md-1.0.2 → any2md-1.0.4}/docs/superpowers/specs/2026-04-26-any2md-v1-design.md +0 -0
  71. {any2md-1.0.2 → any2md-1.0.4}/docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md +0 -0
  72. {any2md-1.0.2 → any2md-1.0.4}/docs/upgrading-from-0.7.md +0 -0
  73. {any2md-1.0.2 → any2md-1.0.4}/pyproject.toml +0 -0
  74. {any2md-1.0.2 → any2md-1.0.4}/requirements.txt +0 -0
  75. {any2md-1.0.2 → any2md-1.0.4}/scripts/validate-phase1.sh +0 -0
  76. {any2md-1.0.2 → any2md-1.0.4}/tests/__init__.py +0 -0
  77. {any2md-1.0.2 → any2md-1.0.4}/tests/cli/__init__.py +0 -0
  78. {any2md-1.0.2 → any2md-1.0.4}/tests/cli/test_cli_backend.py +0 -0
  79. {any2md-1.0.2 → any2md-1.0.4}/tests/cli/test_cli_existing_flags.py +0 -0
  80. {any2md-1.0.2 → any2md-1.0.4}/tests/cli/test_cli_exit_codes.py +0 -0
  81. {any2md-1.0.2 → any2md-1.0.4}/tests/cli/test_cli_meta.py +0 -0
  82. {any2md-1.0.2 → any2md-1.0.4}/tests/cli/test_cli_no_arxiv_lookup.py +0 -0
  83. {any2md-1.0.2 → any2md-1.0.4}/tests/cli/test_cli_quiet_verbose.py +0 -0
  84. {any2md-1.0.2 → any2md-1.0.4}/tests/cli/test_cli_strict.py +0 -0
  85. {any2md-1.0.2 → any2md-1.0.4}/tests/conftest.py +0 -0
  86. {any2md-1.0.2 → any2md-1.0.4}/tests/fixtures/__init__.py +0 -0
  87. {any2md-1.0.2 → any2md-1.0.4}/tests/fixtures/docs/ligatures_and_softhyphens.txt +0 -0
  88. {any2md-1.0.2 → any2md-1.0.4}/tests/fixtures/docs/multi_column.pdf +0 -0
  89. {any2md-1.0.2 → any2md-1.0.4}/tests/fixtures/docs/table_heavy.docx +0 -0
  90. {any2md-1.0.2 → any2md-1.0.4}/tests/fixtures/docs/web_page.html +0 -0
  91. {any2md-1.0.2 → any2md-1.0.4}/tests/fixtures/make_fixtures.py +0 -0
  92. {any2md-1.0.2 → any2md-1.0.4}/tests/fixtures/snapshots/ligatures_and_softhyphens.md +0 -0
  93. {any2md-1.0.2 → any2md-1.0.4}/tests/fixtures/snapshots/multi_column.docling.md +0 -0
  94. {any2md-1.0.2 → any2md-1.0.4}/tests/fixtures/snapshots/multi_column.fallback.md +0 -0
  95. {any2md-1.0.2 → any2md-1.0.4}/tests/fixtures/snapshots/table_heavy.docling.md +0 -0
  96. {any2md-1.0.2 → any2md-1.0.4}/tests/fixtures/snapshots/table_heavy.fallback.md +0 -0
  97. {any2md-1.0.2 → any2md-1.0.4}/tests/fixtures/snapshots/web_page.md +0 -0
  98. {any2md-1.0.2 → any2md-1.0.4}/tests/integration/__init__.py +0 -0
  99. {any2md-1.0.2 → any2md-1.0.4}/tests/integration/test_docx_backend_override.py +0 -0
  100. {any2md-1.0.2 → any2md-1.0.4}/tests/integration/test_pdf_backend_override.py +0 -0
  101. {any2md-1.0.2 → any2md-1.0.4}/tests/integration/test_pdf_docling.py +0 -0
  102. {any2md-1.0.2 → any2md-1.0.4}/tests/integration/test_save_images.py +0 -0
  103. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/__init__.py +0 -0
  104. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/__init__.py +0 -0
  105. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_cleanup_footnote_markers.py +0 -0
  106. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_cleanup_html_entities.py +0 -0
  107. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_cleanup_nfc.py +0 -0
  108. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_cleanup_quotes_dashes.py +0 -0
  109. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_cleanup_soft_hyphens.py +0 -0
  110. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_cleanup_validate.py +0 -0
  111. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_cleanup_whitespace.py +0 -0
  112. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_structured_heading_hierarchy.py +0 -0
  113. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_structured_lift_figures.py +0 -0
  114. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_structured_normalize_cites.py +0 -0
  115. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_text_dedupe_paragraphs.py +0 -0
  116. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_text_dedupe_toc.py +0 -0
  117. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_text_repair_line_wraps.py +0 -0
  118. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_text_restore_lists_code.py +0 -0
  119. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/pipeline/test_text_strip_headers_footers.py +0 -0
  120. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/test_config.py +0 -0
  121. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/test_content_hash.py +0 -0
  122. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/test_meta_overrides.py +0 -0
  123. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/test_pdf_complexity.py +0 -0
  124. {any2md-1.0.2 → any2md-1.0.4}/tests/unit/test_validators.py +1 -1
@@ -4,6 +4,112 @@ 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.4] — 2026-04-26
8
+
9
+ Patch release. Two follow-ups deferred from v1.0.3 (#17): the T7
10
+ `dedupe_toc_table` now normalizes leader-dot padding from TOC
11
+ cells before matching against body headings, so Docling-rendered
12
+ TOC tables (e.g., `Purpose..........Page`) get properly stripped;
13
+ and the audit script's `leading-toc-table` check no longer
14
+ false-flags documents whose only GFM table sits at the end (skip
15
+ when the body contains no H2).
16
+
17
+ ### Changed
18
+ - `pipeline/text.py::dedupe_toc_table` strips leader-dot padding
19
+ (`re.sub(r'\.{3,}.*$', '', cell)`) from each TOC cell before
20
+ lower-casing and matching against body H2/H3 titles. (The
21
+ text-block TOC variant already handles this via `_TOC_LINE_RE`'s
22
+ capture group — only the table-variant site needed the fix.)
23
+ - `scripts/audit-outputs.py` `leading-toc-table` check is now
24
+ gated on `"\n## "` being present in the body — when no H2
25
+ exists, no "leading-TOC region" is defined, so the check is
26
+ skipped instead of matching the entire document.
27
+
28
+ ### Fixes
29
+ - TOC tables with dotted-page-number padding (typical Docling
30
+ output for SafeBreach-style PDFs — e.g., `Backup.pdf`,
31
+ `Password.pdf`) now get stripped by T7 in aggressive/maximum
32
+ profiles. Previously they survived because the cell content
33
+ `"1.1. Purpose............."` failed string-equality with the
34
+ body heading `"1.1. Purpose"`, dropping overlap below the 70%
35
+ threshold.
36
+ - Audit script no longer reports false-positive
37
+ `leading-toc-table` flags on documents whose only GFM table is
38
+ at the end and the body contains no `## ` headings.
39
+
40
+ ### Tests
41
+ - New `test_strips_toc_table_with_leader_dot_padding` in
42
+ `tests/unit/pipeline/test_text_dedupe_toc_table.py` — TOC
43
+ table whose cells carry leader-dot padding mirroring later H2
44
+ headings still triggers the strip.
45
+ - New `tests/unit/scripts/test_audit_outputs.py` covering the
46
+ audit-script `leading-toc-table` check: no-H2-with-trailing-
47
+ table is NOT flagged; leading-table-before-H2 IS still flagged.
48
+
49
+ ### Other
50
+ - `scripts/audit-outputs.py` is now tracked in version control
51
+ (was previously local-only).
52
+
53
+ ## [1.0.3] — 2026-04-26
54
+
55
+ Patch release. Audit on the v1.0.2-regenerated corpus surfaced two
56
+ patterns the previous release did not handle: cover-page-titled
57
+ documents whose first H2 stripped to empty produced an empty
58
+ `title` field, and Docling-lane output retained malformed `|`
59
+ table-row remnants because the lone-punctuation filter was
60
+ text-lane-only. The lane-agnostic body cleanup that v1.0.2's
61
+ text lane already had also now runs on Docling output.
62
+
63
+ ### Added
64
+ - New lane-agnostic stage **`strip_orphan_punctuation`** — drops
65
+ lines containing only `|` or `>`. Extracted from T10
66
+ `strip_web_fragments` so it can run on the structured (Docling)
67
+ lane in addition to the text (trafilatura) lane.
68
+
69
+ ### Changed
70
+ - `heuristics.refine_title` now walks all H2 lines line-by-line
71
+ and skips any whose content strips to empty after removing
72
+ markdown emphasis, instead of always returning the first
73
+ regex match. Also guards the Wikipedia-prefix strip against
74
+ emitting empty when the candidate is the bare prefix.
75
+ - `pipeline/structured.py` now extends `STAGES` with
76
+ `strip_repeated_byline` (T9), `dedupe_toc_table` (T7),
77
+ `strip_cover_artifacts` (T8), and `strip_orphan_punctuation`
78
+ so Docling output gets the same body-cleanup pass as text-lane
79
+ output. T10's short-fragment heuristic stays text-lane-only —
80
+ it would over-strip Docling's deliberate short headings.
81
+ - `pipeline/cleanup.py` `decode_html_entities` (C8) now loops
82
+ `html.unescape` until output stabilizes (max 5 iterations) so
83
+ double-encoded entities like `&` → `&` → `&` are
84
+ fully decoded. Some extractors emit doubly-encoded entities
85
+ that survived a single pass.
86
+
87
+ ### Fixes
88
+ - **Empty-title regression**: cover-page H1 documents whose first
89
+ H2 contained only markdown emphasis, NBSP-equivalent unicode,
90
+ or where the H2 regex's `\s+` class spanned a newline into the
91
+ next paragraph produced `title: ""`. Walk H2 lines line-by-line
92
+ with a non-empty guard.
93
+ - **Orphan-punctuation in Docling output**: malformed table-row
94
+ remnants (lone `|`) survived in structured-lane output because
95
+ T10 was text-lane-only. New `strip_orphan_punctuation` runs on
96
+ both lanes.
97
+ - **Double-encoded HTML entities**: `&` left a residual
98
+ `&` after a single unescape pass. Loop until stable.
99
+
100
+ ### Tests
101
+ - 4 new tests in `tests/unit/test_heuristics.py::TestRefineTitle`
102
+ covering NBSP-only H2, emphasis-only H2 (skip-and-pick-next),
103
+ all-empty-H2 fallthrough, and Wikipedia-prefix-only candidate.
104
+ - New `tests/unit/pipeline/test_strip_orphan_punctuation.py`
105
+ covering aggressive removal, conservative no-op, and preservation
106
+ of lines containing other content alongside `|`/`>`.
107
+ - New `tests/unit/pipeline/test_structured_body_cleanup_stages.py`
108
+ asserting the lane-agnostic stages are registered and that the
109
+ targeted patterns are removed from structured-lane output.
110
+ - New `tests/unit/pipeline/test_cleanup_html_entities_double_encoded.py`
111
+ covering the C8 loop.
112
+
7
113
  ## [1.0.2] — 2026-04-26
8
114
 
9
115
  Patch release. Closes issue #15 plus 8 additional quality issues
@@ -0,0 +1,37 @@
1
+ # Code of Conduct
2
+
3
+ This project adopts the **[Contributor Covenant version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/)**
4
+ as its code of conduct. The full text is available at the link above and
5
+ the canonical source is at https://www.contributor-covenant.org/.
6
+
7
+ ## Scope
8
+
9
+ The covenant applies to all project spaces, including but not limited to:
10
+
11
+ - The GitHub repository (issues, pull requests, discussions, wiki, code
12
+ review comments, commit messages).
13
+ - Any community channel that explicitly identifies itself as a project
14
+ space (mailing list, chat room, project event).
15
+ - Public spaces when an individual is officially representing the project.
16
+
17
+ ## Reporting
18
+
19
+ Reports of abuse, harassment, or otherwise unacceptable behavior may be
20
+ sent to **rock@rockcyber.com**. Reports are confidential and will be
21
+ reviewed within 5 business days.
22
+
23
+ If the report concerns the maintainer, please contact a trusted
24
+ intermediary or use a public reporting channel of your choice.
25
+
26
+ ## Enforcement
27
+
28
+ The maintainer is responsible for enforcement and may take any action
29
+ deemed appropriate, ranging from a private warning to a permanent ban
30
+ from project spaces. Enforcement actions follow the
31
+ [Contributor Covenant Enforcement Guidelines](https://www.contributor-covenant.org/version/2/1/code_of_conduct/#enforcement-guidelines).
32
+
33
+ ## Attribution
34
+
35
+ Code of conduct adapted from the
36
+ [Contributor Covenant](https://www.contributor-covenant.org), version 2.1,
37
+ available at https://www.contributor-covenant.org/version/2/1/code_of_conduct/.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: any2md
3
- Version: 1.0.2
3
+ Version: 1.0.4
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
@@ -36,21 +36,16 @@ Description-Content-Type: text/markdown
36
36
 
37
37
  Convert PDFs, DOCX, HTML, URLs, and plain text into structured, machine-consumable Markdown for downstream RAG pipelines.
38
38
 
39
- ## What's new in 1.0.2
39
+ ## What's new in 1.0.3
40
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:
41
+ A patch release closing two regressions surfaced by audit on the v1.0.2-regenerated corpus, plus a Docling-lane reach for the v1.0.2 body-cleanup stages:
42
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 `&`, `<`, `>`, and numeric entities (`—`, `—`) 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.
43
+ - **Empty-title fix**: cover-page-titled documents (`# INTERNATIONAL STANDARD` etc.) whose first H2 stripped to empty (markdown emphasis only, NBSP-equivalent unicode, regex span crossing into the next paragraph) emitted `title: ""`. `heuristics.refine_title` now walks H2 lines line-by-line and skips any that strip to empty after dropping markdown emphasis. The Wikipedia-prefix strip got the same non-empty guard.
44
+ - **Docling-lane orphan-punctuation**: lone `|` or `>` lines from Docling's malformed table parsing leaked through because T10 `strip_web_fragments` was text-lane-only by design. The orphan-punctuation portion is extracted into a new lane-agnostic stage `strip_orphan_punctuation` that runs on Docling output too. T10's trafilatura-specific short-fragment heuristic stays text-lane-only.
45
+ - **Lane-agnostic body cleanup on Docling lane**: T7 `dedupe_toc_table`, T8 `strip_cover_artifacts`, and T9 `strip_repeated_byline` are now appended to the structured-lane `STAGES` list, so Docling output gets the same body-cleanup pass that text-lane output already had.
46
+ - **Double-encoded HTML entities**: C8 `decode_html_entities` now loops `html.unescape` until output stabilizes (max 5 iterations) so `&` → `&` → `&` is fully decoded.
52
47
 
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).
48
+ The full design rationale lives in [docs/superpowers/specs/2026-04-26-v1.0.3-empty-title-orphan-punct-design.md](docs/superpowers/specs/2026-04-26-v1.0.3-empty-title-orphan-punct-design.md). Per-fix detail is in the [CHANGELOG](CHANGELOG.md). v1.0.2 highlights — `any2md/heuristics.py`, `produced_by`, C8, T7–T10, `--no-arxiv-lookup` — remain available; see [docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md](docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md).
54
49
 
55
50
  ## What this is
56
51
 
@@ -467,7 +462,7 @@ The full symptom-cause-fix triage guide, plus deeper diagnosis steps and follow-
467
462
  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.
468
463
 
469
464
  ```
470
- any2md v1.0.2
465
+ any2md v1.0.3
471
466
  │
472
467
  ┌────┴────┐
473
468
  CLI │ cli.py │ parse args, classify URL/file/dir,
@@ -498,6 +493,12 @@ any2md uses a two-lane post-processing pipeline: one lane for layout-trustworthy
498
493
  │ S2 table compactor │
499
494
  │ S3 cite normalizer │
500
495
  │ S4 heading hierarchy │
496
+ │ T9 strip repeated │ ◀ NEW v1.0.3
497
+ │ byline │
498
+ │ T7 dedupe TOC table │ ◀ NEW v1.0.3
499
+ │ T8 strip cover │ ◀ NEW v1.0.3
500
+ │ artifacts │
501
+ │ strip_orphan_punct │ ◀ NEW v1.0.3
501
502
  └────────────┬────────────┘
502
503
  │
503
504
  ┌────────────┴────────────┐
@@ -512,6 +513,7 @@ any2md uses a two-lane post-processing pipeline: one lane for layout-trustworthy
512
513
  │ T5 hdr/ftr strip │
513
514
  │ T10 strip web │ ◀ NEW (D2)
514
515
  │ fragments │
516
+ │ ↳ strip_orphan_punct ◀ split out v1.0.3
515
517
  │ T6 list/code restore │
516
518
  │ T8 strip cover │ ◀ NEW (D3)
517
519
  │ artifacts │
@@ -6,21 +6,16 @@
6
6
 
7
7
  Convert PDFs, DOCX, HTML, URLs, and plain text into structured, machine-consumable Markdown for downstream RAG pipelines.
8
8
 
9
- ## What's new in 1.0.2
9
+ ## What's new in 1.0.3
10
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:
11
+ A patch release closing two regressions surfaced by audit on the v1.0.2-regenerated corpus, plus a Docling-lane reach for the v1.0.2 body-cleanup stages:
12
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 `&`, `<`, `>`, and numeric entities (`—`, `—`) 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.
13
+ - **Empty-title fix**: cover-page-titled documents (`# INTERNATIONAL STANDARD` etc.) whose first H2 stripped to empty (markdown emphasis only, NBSP-equivalent unicode, regex span crossing into the next paragraph) emitted `title: ""`. `heuristics.refine_title` now walks H2 lines line-by-line and skips any that strip to empty after dropping markdown emphasis. The Wikipedia-prefix strip got the same non-empty guard.
14
+ - **Docling-lane orphan-punctuation**: lone `|` or `>` lines from Docling's malformed table parsing leaked through because T10 `strip_web_fragments` was text-lane-only by design. The orphan-punctuation portion is extracted into a new lane-agnostic stage `strip_orphan_punctuation` that runs on Docling output too. T10's trafilatura-specific short-fragment heuristic stays text-lane-only.
15
+ - **Lane-agnostic body cleanup on Docling lane**: T7 `dedupe_toc_table`, T8 `strip_cover_artifacts`, and T9 `strip_repeated_byline` are now appended to the structured-lane `STAGES` list, so Docling output gets the same body-cleanup pass that text-lane output already had.
16
+ - **Double-encoded HTML entities**: C8 `decode_html_entities` now loops `html.unescape` until output stabilizes (max 5 iterations) so `&` → `&` → `&` is fully decoded.
22
17
 
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).
18
+ The full design rationale lives in [docs/superpowers/specs/2026-04-26-v1.0.3-empty-title-orphan-punct-design.md](docs/superpowers/specs/2026-04-26-v1.0.3-empty-title-orphan-punct-design.md). Per-fix detail is in the [CHANGELOG](CHANGELOG.md). v1.0.2 highlights — `any2md/heuristics.py`, `produced_by`, C8, T7–T10, `--no-arxiv-lookup` — remain available; see [docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md](docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md).
24
19
 
25
20
  ## What this is
26
21
 
@@ -437,7 +432,7 @@ The full symptom-cause-fix triage guide, plus deeper diagnosis steps and follow-
437
432
  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.
438
433
 
439
434
  ```
440
- any2md v1.0.2
435
+ any2md v1.0.3
441
436
  │
442
437
  ┌────┴────┐
443
438
  CLI │ cli.py │ parse args, classify URL/file/dir,
@@ -468,6 +463,12 @@ any2md uses a two-lane post-processing pipeline: one lane for layout-trustworthy
468
463
  │ S2 table compactor │
469
464
  │ S3 cite normalizer │
470
465
  │ S4 heading hierarchy │
466
+ │ T9 strip repeated │ ◀ NEW v1.0.3
467
+ │ byline │
468
+ │ T7 dedupe TOC table │ ◀ NEW v1.0.3
469
+ │ T8 strip cover │ ◀ NEW v1.0.3
470
+ │ artifacts │
471
+ │ strip_orphan_punct │ ◀ NEW v1.0.3
471
472
  └────────────┬────────────┘
472
473
  │
473
474
  ┌────────────┴────────────┐
@@ -482,6 +483,7 @@ any2md uses a two-lane post-processing pipeline: one lane for layout-trustworthy
482
483
  │ T5 hdr/ftr strip │
483
484
  │ T10 strip web │ ◀ NEW (D2)
484
485
  │ fragments │
486
+ │ ↳ strip_orphan_punct ◀ split out v1.0.3
485
487
  │ T6 list/code restore │
486
488
  │ T8 strip cover │ ◀ NEW (D3)
487
489
  │ artifacts │
@@ -0,0 +1,83 @@
1
+ # Security Policy
2
+
3
+ ## Supported versions
4
+
5
+ | Version | Status |
6
+ |---|---|
7
+ | 1.0.x | ✅ Supported (current stable). Patch releases for security and quality fixes are published as needed. |
8
+ | 0.7.x | ⚠️ Receives critical security fixes only until 2026-10-26 (six months after the v1.0.0 GA on 2026-04-26). |
9
+ | < 0.7 | ❌ Not supported. Please upgrade. |
10
+
11
+ ## Reporting a vulnerability
12
+
13
+ **Please do not file public issues for security vulnerabilities.** Public
14
+ disclosure before a fix is published puts users at risk.
15
+
16
+ Email security disclosures to **rock@rockcyber.com** with the subject line
17
+ `any2md security disclosure`. Encrypted email is welcome — request a
18
+ public key in your initial message and one will be provided.
19
+
20
+ Include in your report:
21
+
22
+ - A description of the issue and the affected component.
23
+ - Affected version(s) — e.g., `pip show any2md` output, or commit SHA.
24
+ - Reproduction steps (smallest possible repro is ideal).
25
+ - Any proof-of-concept (please redact sensitive data).
26
+ - Whether you intend to publish your own write-up; if so, a proposed
27
+ embargo date so we can coordinate.
28
+
29
+ ### Response timeline
30
+
31
+ - **Acknowledgement** within 5 business days.
32
+ - **Triage and severity assessment** within 10 business days.
33
+ - **Critical issues** (remote code execution, data exfiltration via
34
+ crafted input, supply-chain compromise of a published wheel): patched
35
+ and released within 30 days of confirmation, with a coordinated public
36
+ advisory.
37
+ - **High** (denial-of-service, information disclosure with limited blast
38
+ radius): patched within 60 days.
39
+ - **Medium / low** issues are tracked in private until a fix lands in a
40
+ scheduled release, then disclosed publicly via the GitHub Security
41
+ Advisories page.
42
+
43
+ ## Scope
44
+
45
+ In scope:
46
+
47
+ - The `any2md` library and CLI.
48
+ - The published wheels and sdists on PyPI.
49
+ - The release pipeline (`.github/workflows/publish.yml`) and any
50
+ side-effects of running the workflow.
51
+ - The default conversion pipeline (PDF / DOCX / HTML / URL / TXT) and
52
+ the optional Docling backend integration.
53
+ - The arxiv API enrichment path (network call + XML parsing).
54
+ - The SSRF guards in the URL fetcher.
55
+
56
+ Out of scope:
57
+
58
+ - Vulnerabilities in third-party dependencies (please file directly with
59
+ the upstream — Docling, PyMuPDF, mammoth, trafilatura, BeautifulSoup,
60
+ lxml, etc.). We monitor upstream advisories via Dependabot.
61
+ - Issues that require local filesystem access already granted to the
62
+ running Python process.
63
+ - Local timing attacks against your own machine.
64
+ - Content correctness issues in converted Markdown (those are
65
+ conversion-quality reports — please use the
66
+ [Conversion quality issue template](.github/ISSUE_TEMPLATE/conversion_quality.md)
67
+ instead).
68
+
69
+ ## Hardening reference
70
+
71
+ If you operate any2md in a hardened environment:
72
+
73
+ - Pass `--no-arxiv-lookup` to disable the arxiv API call.
74
+ - Pass `--max-file-size` with a budget appropriate for your workload.
75
+ - Run conversions in a sandbox that cannot reach private networks
76
+ (the SSRF guard validates resolved IPs, but defense in depth is best).
77
+ - Pin `any2md` and its extras (`any2md[high-fidelity]==<exact-version>`)
78
+ to a verified version and audit the lockfile.
79
+
80
+ ## Disclosure history
81
+
82
+ No publicly disclosed vulnerabilities to date. Future advisories will
83
+ appear at https://github.com/rocklambros/any2md/security/advisories.
@@ -1,3 +1,3 @@
1
1
  """Convert PDF, DOCX, HTML, and TXT files to LLM-optimized Markdown."""
2
2
 
3
- __version__ = "1.0.2"
3
+ __version__ = "1.0.4"
@@ -65,11 +65,7 @@ def _deep_merge_overrides(
65
65
  """
66
66
  out = dict(base)
67
67
  for key, val in overrides.items():
68
- if (
69
- key in out
70
- and isinstance(out[key], dict)
71
- and isinstance(val, dict)
72
- ):
68
+ if key in out and isinstance(out[key], dict) and isinstance(val, dict):
73
69
  out[key] = _deep_merge_overrides(out[key], val)
74
70
  else:
75
71
  out[key] = val
@@ -136,9 +132,9 @@ def main():
136
132
  "--no-arxiv-lookup",
137
133
  action="store_true",
138
134
  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.",
135
+ "PDFs whose filename matches the arxiv ID pattern (\\d{4}.\\d{4,5}). "
136
+ "Useful in airgapped envs or when you don't want any2md to make "
137
+ "network calls. Default: arxiv lookup is enabled.",
142
138
  )
143
139
  parser.add_argument(
144
140
  "--backend",
@@ -224,9 +220,7 @@ def main():
224
220
 
225
221
  if args.meta_file is not None:
226
222
  if not args.meta_file.is_file():
227
- print(
228
- f"Error: --meta-file not found: {args.meta_file}", file=sys.stderr
229
- )
223
+ print(f"Error: --meta-file not found: {args.meta_file}", file=sys.stderr)
230
224
  sys.exit(1)
231
225
  cfg = load_toml(args.meta_file)
232
226
  overrides = _deep_merge_overrides(overrides, extract_meta_overrides(cfg))
@@ -253,6 +247,7 @@ def main():
253
247
  or args.backend == "docling"
254
248
  ):
255
249
  from any2md._docling import has_docling, INSTALL_HINT_MSG
250
+
256
251
  if not has_docling():
257
252
  print(
258
253
  f" ERROR: Docling required for --high-fidelity / --ocr-figures / --save-images / --backend docling.\n"
@@ -70,17 +70,19 @@ def convert_file(
70
70
  ext = file_path.suffix.lower()
71
71
  if ext == ".pdf":
72
72
  from any2md.converters.pdf import convert_pdf
73
+
73
74
  return convert_pdf(file_path, output_dir, options=options, force=force)
74
75
  if ext == ".docx":
75
76
  from any2md.converters.docx import convert_docx
77
+
76
78
  return convert_docx(file_path, output_dir, options=options, force=force)
77
79
  if ext in (".html", ".htm"):
78
80
  from any2md.converters.html import convert_html
81
+
79
82
  return convert_html(file_path, output_dir, options=options, force=force)
80
83
  if ext == ".txt":
81
84
  from any2md.converters.txt import convert_txt
85
+
82
86
  return convert_txt(file_path, output_dir, options=options, force=force)
83
- print(
84
- f" UNSUPPORTED: {file_path.name} (no converter for {ext})", file=sys.stderr
85
- )
87
+ print(f" UNSUPPORTED: {file_path.name} (no converter for {ext})", file=sys.stderr)
86
88
  return False
@@ -99,9 +99,7 @@ def _extract_via_docling(docx_path: Path) -> tuple[str, str]:
99
99
  return result.document.export_to_markdown(), "docling"
100
100
 
101
101
 
102
- def _extract_via_mammoth(
103
- docx_path: Path, options: PipelineOptions
104
- ) -> tuple[str, str]:
102
+ def _extract_via_mammoth(docx_path: Path, options: PipelineOptions) -> tuple[str, str]:
105
103
  with open(docx_path, "rb") as f:
106
104
  html_result = mammoth.convert_to_html(f)
107
105
  md = markdownify.markdownify(
@@ -185,9 +183,7 @@ def convert_docx(
185
183
  lane=lane,
186
184
  produced_by=props["produced_by"],
187
185
  )
188
- full = compose(
189
- md_text, meta, options, overrides=options.frontmatter_overrides
190
- )
186
+ full = compose(md_text, meta, options, overrides=options.frontmatter_overrides)
191
187
 
192
188
  output_dir.mkdir(parents=True, exist_ok=True)
193
189
  out_path.write_text(full, encoding="utf-8", newline="\n")
@@ -91,6 +91,7 @@ def _http_last_modified(url: str) -> str | None:
91
91
  lm = resp.headers.get("Last-Modified")
92
92
  if lm:
93
93
  from email.utils import parsedate_to_datetime
94
+
94
95
  return parsedate_to_datetime(lm).date().isoformat()
95
96
  except Exception: # noqa: BLE001
96
97
  pass
@@ -139,8 +140,10 @@ def _extract_metadata(
139
140
  elif isinstance(bare, dict):
140
141
  data = bare
141
142
  else:
142
- data = {k: getattr(bare, k, None) for k in
143
- ("title", "author", "sitename", "date", "categories")}
143
+ data = {
144
+ k: getattr(bare, k, None)
145
+ for k in ("title", "author", "sitename", "date", "categories")
146
+ }
144
147
  title = data.get("title")
145
148
  authors_raw = data.get("author") or ""
146
149
  authors = [a.strip() for a in authors_raw.split(",") if a.strip()]
@@ -221,9 +224,7 @@ def convert_html(
221
224
  extracted_via=extracted_via,
222
225
  lane="text",
223
226
  )
224
- full = compose(
225
- md_text, meta, options, overrides=options.frontmatter_overrides
226
- )
227
+ full = compose(md_text, meta, options, overrides=options.frontmatter_overrides)
227
228
 
228
229
  output_dir.mkdir(parents=True, exist_ok=True)
229
230
  out_path.write_text(full, encoding="utf-8", newline="\n")
@@ -262,17 +262,13 @@ def convert_pdf(
262
262
  lane=lane,
263
263
  produced_by=props["produced_by"],
264
264
  )
265
- full = compose(
266
- md_text, meta, options, overrides=options.frontmatter_overrides
267
- )
265
+ full = compose(md_text, meta, options, overrides=options.frontmatter_overrides)
268
266
 
269
267
  output_dir.mkdir(parents=True, exist_ok=True)
270
268
  out_path.write_text(full, encoding="utf-8", newline="\n")
271
269
  suffix = f", {len(warnings)} warning(s)" if warnings else ""
272
270
  if not is_quiet():
273
- print(
274
- f" OK: {out_name} ({page_count} pages, via {extracted_via}{suffix})"
275
- )
271
+ print(f" OK: {out_name} ({page_count} pages, via {extracted_via}{suffix})")
276
272
  return True
277
273
 
278
274
  except (OSError, ValueError, RuntimeError) as e:
@@ -27,8 +27,22 @@ def _is_title_case(line: str) -> bool:
27
27
  if len(words) < 2:
28
28
  return False
29
29
  skip = {
30
- "a", "an", "the", "and", "or", "but", "in", "on", "at", "to",
31
- "for", "of", "with", "by", "from", "is",
30
+ "a",
31
+ "an",
32
+ "the",
33
+ "and",
34
+ "or",
35
+ "but",
36
+ "in",
37
+ "on",
38
+ "at",
39
+ "to",
40
+ "for",
41
+ "of",
42
+ "with",
43
+ "by",
44
+ "from",
45
+ "is",
32
46
  }
33
47
  caps = sum(1 for w in words if w[0].isupper() or w.lower() in skip)
34
48
  return caps >= len(words) * 0.7
@@ -182,9 +196,7 @@ def convert_txt(
182
196
  md_text, warnings = pipeline.run(md_text, "text", options)
183
197
  add_warnings(warnings)
184
198
  meta = _build_meta(txt_path, md_text)
185
- full = compose(
186
- md_text, meta, options, overrides=options.frontmatter_overrides
187
- )
199
+ full = compose(md_text, meta, options, overrides=options.frontmatter_overrides)
188
200
 
189
201
  output_dir.mkdir(parents=True, exist_ok=True)
190
202
  out_path.write_text(full, encoding="utf-8", newline="\n")
@@ -51,16 +51,20 @@ class SourceMeta:
51
51
  title_hint: str | None
52
52
  authors: list[str]
53
53
  organization: str | None
54
- date: str | None # ISO-8601 YYYY-MM-DD
54
+ date: str | None # ISO-8601 YYYY-MM-DD
55
55
  keywords: list[str]
56
- pages: int | None # PDFs only
57
- word_count: int | None # DOCX/HTML/TXT (post-cleanup)
56
+ pages: int | None # PDFs only
57
+ word_count: int | None # DOCX/HTML/TXT (post-cleanup)
58
58
  source_file: str | None
59
59
  source_url: str | None
60
- doc_type: Literal["pdf", "docx", "html", "txt"] # v0.7-compat extension
60
+ doc_type: Literal["pdf", "docx", "html", "txt"] # v0.7-compat extension
61
61
  extracted_via: Literal[
62
- "docling", "pymupdf4llm", "mammoth+markdownify",
63
- "trafilatura", "trafilatura+bs4_fallback", "heuristic",
62
+ "docling",
63
+ "pymupdf4llm",
64
+ "mammoth+markdownify",
65
+ "trafilatura",
66
+ "trafilatura+bs4_fallback",
67
+ "heuristic",
64
68
  ]
65
69
  lane: Lane
66
70
  # v1.0.2: software/tool that produced the source (PDF Creator field for
@@ -105,7 +109,7 @@ def extract_abstract(body: str) -> str | None:
105
109
  return None
106
110
 
107
111
  # Walk paragraphs after the H1 (split on blank lines).
108
- after = body[h1.end():]
112
+ after = body[h1.end() :]
109
113
  paragraphs = [p.strip() for p in re.split(r"\n\s*\n", after)]
110
114
  for para in paragraphs:
111
115
  if not para:
@@ -145,9 +149,9 @@ def derive_title(body: str, title_hint: str | None, fallback: str) -> str:
145
149
  def _yaml_escape(value: str) -> str:
146
150
  return (
147
151
  value.replace("\\", "\\\\")
148
- .replace('"', '\\"')
149
- .replace("\n", "\\n")
150
- .replace("\r", "\\r")
152
+ .replace('"', '\\"')
153
+ .replace("\n", "\\n")
154
+ .replace("\r", "\\r")
151
155
  )
152
156
 
153
157
 
@@ -194,7 +198,8 @@ def _build_fields(
194
198
  title_raw = derive_title(body, meta.title_hint, fallback)
195
199
  arxiv_id = heuristics.is_arxiv_filename(meta.source_file or "")
196
200
  title = heuristics.refine_title(
197
- title_raw, body,
201
+ title_raw,
202
+ body,
198
203
  source_url=meta.source_url,
199
204
  profile=options.profile,
200
205
  )
@@ -253,7 +258,7 @@ def _build_fields(
253
258
  fields["source_file"] = meta.source_file
254
259
  if meta.source_url:
255
260
  fields["source_url"] = meta.source_url
256
- fields["type"] = meta.doc_type # v0.7-compat field (spec §3.2)
261
+ fields["type"] = meta.doc_type # v0.7-compat field (spec §3.2)
257
262
  fields["extracted_via"] = meta.extracted_via # v1.0 provenance extension
258
263
  if meta.produced_by is not None:
259
264
  fields["produced_by"] = meta.produced_by # v1.0.2 extension (spec §6.1)
@@ -273,11 +278,7 @@ def _deep_merge(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, An
273
278
  """
274
279
  out = dict(base)
275
280
  for key, val in overrides.items():
276
- if (
277
- key in out
278
- and isinstance(out[key], dict)
279
- and isinstance(val, dict)
280
- ):
281
+ if key in out and isinstance(out[key], dict) and isinstance(val, dict):
281
282
  out[key] = _deep_merge(out[key], val)
282
283
  else:
283
284
  out[key] = val