any2md 1.0.2__tar.gz → 1.0.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {any2md-1.0.2 → any2md-1.0.3}/CHANGELOG.md +60 -0
- any2md-1.0.3/CODE_OF_CONDUCT.md +37 -0
- {any2md-1.0.2 → any2md-1.0.3}/PKG-INFO +16 -14
- {any2md-1.0.2 → any2md-1.0.3}/README.md +15 -13
- any2md-1.0.3/SECURITY.md +83 -0
- {any2md-1.0.2 → any2md-1.0.3}/any2md/__init__.py +1 -1
- {any2md-1.0.2 → any2md-1.0.3}/any2md/cli.py +6 -11
- {any2md-1.0.2 → any2md-1.0.3}/any2md/converters/__init__.py +5 -3
- {any2md-1.0.2 → any2md-1.0.3}/any2md/converters/docx.py +2 -6
- {any2md-1.0.2 → any2md-1.0.3}/any2md/converters/html.py +6 -5
- {any2md-1.0.2 → any2md-1.0.3}/any2md/converters/pdf.py +2 -6
- {any2md-1.0.2 → any2md-1.0.3}/any2md/converters/txt.py +17 -5
- {any2md-1.0.2 → any2md-1.0.3}/any2md/frontmatter.py +18 -17
- {any2md-1.0.2 → any2md-1.0.3}/any2md/heuristics.py +59 -35
- {any2md-1.0.2 → any2md-1.0.3}/any2md/pipeline/cleanup.py +38 -21
- {any2md-1.0.2 → any2md-1.0.3}/any2md/pipeline/structured.py +32 -2
- {any2md-1.0.2 → any2md-1.0.3}/any2md/pipeline/text.py +19 -11
- any2md-1.0.3/assets/social-preview.jpg +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/docs/architecture.md +35 -12
- any2md-1.0.3/docs/superpowers/specs/2026-04-26-v1.0.3-empty-title-orphan-punct-design.md +133 -0
- {any2md-1.0.2 → any2md-1.0.3}/docs/troubleshooting.md +4 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/cli/test_cli_high_fidelity.py +7 -2
- {any2md-1.0.2 → any2md-1.0.3}/tests/cli/test_cli_ocr_save_images.py +6 -3
- {any2md-1.0.2 → any2md-1.0.3}/tests/integration/test_docx_docling.py +1 -1
- {any2md-1.0.2 → any2md-1.0.3}/tests/integration/test_docx_mammoth_fallback.py +5 -13
- {any2md-1.0.2 → any2md-1.0.3}/tests/integration/test_html_trafilatura.py +1 -1
- {any2md-1.0.2 → any2md-1.0.3}/tests/integration/test_pdf_pymupdf_fallback.py +4 -7
- {any2md-1.0.2 → any2md-1.0.3}/tests/integration/test_snapshots.py +1 -2
- {any2md-1.0.2 → any2md-1.0.3}/tests/integration/test_txt.py +1 -1
- any2md-1.0.3/tests/unit/pipeline/test_cleanup_html_entities_double_encoded.py +45 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_cleanup_ligatures.py +3 -1
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_runner.py +3 -0
- any2md-1.0.3/tests/unit/pipeline/test_strip_orphan_punctuation.py +40 -0
- any2md-1.0.3/tests/unit/pipeline/test_structured_body_cleanup_stages.py +84 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_structured_compact_tables.py +2 -6
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_text_dehyphenate.py +1 -4
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_text_strip_cover_artifacts.py +1 -9
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_text_strip_repeated_byline.py +2 -14
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_text_strip_web_fragments.py +4 -32
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/test_auto_id.py +1 -3
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/test_docling_helper.py +1 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/test_frontmatter_compose.py +31 -10
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/test_frontmatter_helpers.py +24 -11
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/test_heuristics.py +113 -62
- any2md-1.0.2/assets/social-preview.png +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/.gitignore +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/CONTRIBUTING.md +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/LICENSE +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/any2md/__main__.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/any2md/_docling.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/any2md/config.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/any2md/pipeline/__init__.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/any2md/utils.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/any2md/validators.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/assets/logo-original.jpeg +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/assets/logo.png +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/docs/cli-reference.md +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/docs/output-format.md +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/docs/superpowers/plans/2026-04-26-any2md-v1-phase1-foundation.md +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/docs/superpowers/plans/2026-04-26-any2md-v1-phase2-docling-backend.md +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/docs/superpowers/plans/2026-04-26-any2md-v1-phase3-figures-ocr-text-lane.md +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/docs/superpowers/plans/2026-04-26-any2md-v1-phase4-polish-config-docs.md +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/docs/superpowers/plans/2026-04-26-any2md-v1.0.2-implementation.md +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/docs/superpowers/specs/2026-04-26-any2md-v1-design.md +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/docs/upgrading-from-0.7.md +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/pyproject.toml +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/requirements.txt +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/scripts/validate-phase1.sh +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/__init__.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/cli/__init__.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/cli/test_cli_backend.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/cli/test_cli_existing_flags.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/cli/test_cli_exit_codes.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/cli/test_cli_meta.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/cli/test_cli_no_arxiv_lookup.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/cli/test_cli_quiet_verbose.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/cli/test_cli_strict.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/conftest.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/fixtures/__init__.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/fixtures/docs/ligatures_and_softhyphens.txt +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/fixtures/docs/multi_column.pdf +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/fixtures/docs/table_heavy.docx +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/fixtures/docs/web_page.html +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/fixtures/make_fixtures.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/fixtures/snapshots/ligatures_and_softhyphens.md +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/fixtures/snapshots/multi_column.docling.md +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/fixtures/snapshots/multi_column.fallback.md +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/fixtures/snapshots/table_heavy.docling.md +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/fixtures/snapshots/table_heavy.fallback.md +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/fixtures/snapshots/web_page.md +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/integration/__init__.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/integration/test_docx_backend_override.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/integration/test_pdf_backend_override.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/integration/test_pdf_docling.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/integration/test_save_images.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/__init__.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/__init__.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_cleanup_footnote_markers.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_cleanup_html_entities.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_cleanup_nfc.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_cleanup_quotes_dashes.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_cleanup_soft_hyphens.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_cleanup_validate.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_cleanup_whitespace.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_structured_heading_hierarchy.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_structured_lift_figures.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_structured_normalize_cites.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_text_dedupe_paragraphs.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_text_dedupe_toc.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_text_dedupe_toc_table.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_text_repair_line_wraps.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_text_restore_lists_code.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/pipeline/test_text_strip_headers_footers.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/test_config.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/test_content_hash.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/test_meta_overrides.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/test_pdf_complexity.py +0 -0
- {any2md-1.0.2 → any2md-1.0.3}/tests/unit/test_validators.py +1 -1
|
@@ -4,6 +4,66 @@ 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.3] — 2026-04-26
|
|
8
|
+
|
|
9
|
+
Patch release. Audit on the v1.0.2-regenerated corpus surfaced two
|
|
10
|
+
patterns the previous release did not handle: cover-page-titled
|
|
11
|
+
documents whose first H2 stripped to empty produced an empty
|
|
12
|
+
`title` field, and Docling-lane output retained malformed `|`
|
|
13
|
+
table-row remnants because the lone-punctuation filter was
|
|
14
|
+
text-lane-only. The lane-agnostic body cleanup that v1.0.2's
|
|
15
|
+
text lane already had also now runs on Docling output.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- New lane-agnostic stage **`strip_orphan_punctuation`** — drops
|
|
19
|
+
lines containing only `|` or `>`. Extracted from T10
|
|
20
|
+
`strip_web_fragments` so it can run on the structured (Docling)
|
|
21
|
+
lane in addition to the text (trafilatura) lane.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- `heuristics.refine_title` now walks all H2 lines line-by-line
|
|
25
|
+
and skips any whose content strips to empty after removing
|
|
26
|
+
markdown emphasis, instead of always returning the first
|
|
27
|
+
regex match. Also guards the Wikipedia-prefix strip against
|
|
28
|
+
emitting empty when the candidate is the bare prefix.
|
|
29
|
+
- `pipeline/structured.py` now extends `STAGES` with
|
|
30
|
+
`strip_repeated_byline` (T9), `dedupe_toc_table` (T7),
|
|
31
|
+
`strip_cover_artifacts` (T8), and `strip_orphan_punctuation`
|
|
32
|
+
so Docling output gets the same body-cleanup pass as text-lane
|
|
33
|
+
output. T10's short-fragment heuristic stays text-lane-only —
|
|
34
|
+
it would over-strip Docling's deliberate short headings.
|
|
35
|
+
- `pipeline/cleanup.py` `decode_html_entities` (C8) now loops
|
|
36
|
+
`html.unescape` until output stabilizes (max 5 iterations) so
|
|
37
|
+
double-encoded entities like `&` → `&` → `&` are
|
|
38
|
+
fully decoded. Some extractors emit doubly-encoded entities
|
|
39
|
+
that survived a single pass.
|
|
40
|
+
|
|
41
|
+
### Fixes
|
|
42
|
+
- **Empty-title regression**: cover-page H1 documents whose first
|
|
43
|
+
H2 contained only markdown emphasis, NBSP-equivalent unicode,
|
|
44
|
+
or where the H2 regex's `\s+` class spanned a newline into the
|
|
45
|
+
next paragraph produced `title: ""`. Walk H2 lines line-by-line
|
|
46
|
+
with a non-empty guard.
|
|
47
|
+
- **Orphan-punctuation in Docling output**: malformed table-row
|
|
48
|
+
remnants (lone `|`) survived in structured-lane output because
|
|
49
|
+
T10 was text-lane-only. New `strip_orphan_punctuation` runs on
|
|
50
|
+
both lanes.
|
|
51
|
+
- **Double-encoded HTML entities**: `&` left a residual
|
|
52
|
+
`&` after a single unescape pass. Loop until stable.
|
|
53
|
+
|
|
54
|
+
### Tests
|
|
55
|
+
- 4 new tests in `tests/unit/test_heuristics.py::TestRefineTitle`
|
|
56
|
+
covering NBSP-only H2, emphasis-only H2 (skip-and-pick-next),
|
|
57
|
+
all-empty-H2 fallthrough, and Wikipedia-prefix-only candidate.
|
|
58
|
+
- New `tests/unit/pipeline/test_strip_orphan_punctuation.py`
|
|
59
|
+
covering aggressive removal, conservative no-op, and preservation
|
|
60
|
+
of lines containing other content alongside `|`/`>`.
|
|
61
|
+
- New `tests/unit/pipeline/test_structured_body_cleanup_stages.py`
|
|
62
|
+
asserting the lane-agnostic stages are registered and that the
|
|
63
|
+
targeted patterns are removed from structured-lane output.
|
|
64
|
+
- New `tests/unit/pipeline/test_cleanup_html_entities_double_encoded.py`
|
|
65
|
+
covering the C8 loop.
|
|
66
|
+
|
|
7
67
|
## [1.0.2] — 2026-04-26
|
|
8
68
|
|
|
9
69
|
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.
|
|
3
|
+
Version: 1.0.3
|
|
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.
|
|
39
|
+
## What's new in 1.0.3
|
|
40
40
|
|
|
41
|
-
A patch release
|
|
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
|
-
-
|
|
44
|
-
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
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-
|
|
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.
|
|
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.
|
|
9
|
+
## What's new in 1.0.3
|
|
10
10
|
|
|
11
|
-
A patch release
|
|
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
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
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-
|
|
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.
|
|
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 │
|
any2md-1.0.3/SECURITY.md
ADDED
|
@@ -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.
|
|
@@ -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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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 = {
|
|
143
|
-
|
|
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",
|
|
31
|
-
"
|
|
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
|
|
54
|
+
date: str | None # ISO-8601 YYYY-MM-DD
|
|
55
55
|
keywords: list[str]
|
|
56
|
-
pages: int | None
|
|
57
|
-
word_count: int | None
|
|
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"]
|
|
60
|
+
doc_type: Literal["pdf", "docx", "html", "txt"] # v0.7-compat extension
|
|
61
61
|
extracted_via: Literal[
|
|
62
|
-
"docling",
|
|
63
|
-
"
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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,
|
|
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
|
|
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
|