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.
- {any2md-1.0.1 → any2md-1.0.2}/.gitignore +1 -0
- {any2md-1.0.1 → any2md-1.0.2}/CHANGELOG.md +61 -0
- {any2md-1.0.1 → any2md-1.0.2}/PKG-INFO +71 -23
- {any2md-1.0.1 → any2md-1.0.2}/README.md +70 -22
- {any2md-1.0.1 → any2md-1.0.2}/any2md/__init__.py +1 -1
- {any2md-1.0.1 → any2md-1.0.2}/any2md/cli.py +9 -0
- {any2md-1.0.1 → any2md-1.0.2}/any2md/converters/docx.py +18 -1
- {any2md-1.0.1 → any2md-1.0.2}/any2md/converters/pdf.py +6 -1
- {any2md-1.0.1 → any2md-1.0.2}/any2md/frontmatter.py +33 -3
- any2md-1.0.2/any2md/heuristics.py +580 -0
- {any2md-1.0.1 → any2md-1.0.2}/any2md/pipeline/__init__.py +6 -0
- {any2md-1.0.1 → any2md-1.0.2}/any2md/pipeline/cleanup.py +22 -0
- {any2md-1.0.1 → any2md-1.0.2}/any2md/pipeline/text.py +187 -0
- any2md-1.0.2/assets/logo-original.jpeg +0 -0
- any2md-1.0.2/assets/logo.png +0 -0
- any2md-1.0.2/assets/social-preview.png +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/docs/architecture.md +174 -59
- {any2md-1.0.1 → any2md-1.0.2}/docs/cli-reference.md +40 -1
- {any2md-1.0.1 → any2md-1.0.2}/docs/output-format.md +45 -2
- any2md-1.0.2/docs/superpowers/plans/2026-04-26-any2md-v1.0.2-implementation.md +307 -0
- any2md-1.0.2/docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md +694 -0
- {any2md-1.0.1 → any2md-1.0.2}/docs/troubleshooting.md +11 -0
- {any2md-1.0.1 → any2md-1.0.2}/docs/upgrading-from-0.7.md +1 -0
- any2md-1.0.2/tests/cli/test_cli_no_arxiv_lookup.py +41 -0
- any2md-1.0.2/tests/integration/test_docx_mammoth_fallback.py +174 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/integration/test_pdf_docling.py +3 -0
- any2md-1.0.2/tests/integration/test_pdf_pymupdf_fallback.py +79 -0
- any2md-1.0.2/tests/unit/pipeline/test_cleanup_html_entities.py +51 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_runner.py +8 -0
- any2md-1.0.2/tests/unit/pipeline/test_text_dedupe_toc_table.py +106 -0
- any2md-1.0.2/tests/unit/pipeline/test_text_strip_cover_artifacts.py +79 -0
- any2md-1.0.2/tests/unit/pipeline/test_text_strip_repeated_byline.py +57 -0
- any2md-1.0.2/tests/unit/pipeline/test_text_strip_web_fragments.py +63 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/test_frontmatter_compose.py +69 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/test_frontmatter_helpers.py +1 -1
- any2md-1.0.2/tests/unit/test_heuristics.py +507 -0
- any2md-1.0.1/tests/integration/test_docx_mammoth_fallback.py +0 -42
- any2md-1.0.1/tests/integration/test_pdf_pymupdf_fallback.py +0 -37
- {any2md-1.0.1 → any2md-1.0.2}/CONTRIBUTING.md +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/LICENSE +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/any2md/__main__.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/any2md/_docling.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/any2md/config.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/any2md/converters/__init__.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/any2md/converters/html.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/any2md/converters/txt.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/any2md/pipeline/structured.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/any2md/utils.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/any2md/validators.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/docs/superpowers/plans/2026-04-26-any2md-v1-phase1-foundation.md +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/docs/superpowers/plans/2026-04-26-any2md-v1-phase2-docling-backend.md +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/docs/superpowers/plans/2026-04-26-any2md-v1-phase3-figures-ocr-text-lane.md +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/docs/superpowers/plans/2026-04-26-any2md-v1-phase4-polish-config-docs.md +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/docs/superpowers/specs/2026-04-26-any2md-v1-design.md +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/pyproject.toml +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/requirements.txt +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/scripts/validate-phase1.sh +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/__init__.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/cli/__init__.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/cli/test_cli_backend.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/cli/test_cli_existing_flags.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/cli/test_cli_exit_codes.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/cli/test_cli_high_fidelity.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/cli/test_cli_meta.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/cli/test_cli_ocr_save_images.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/cli/test_cli_quiet_verbose.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/cli/test_cli_strict.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/conftest.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/__init__.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/docs/ligatures_and_softhyphens.txt +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/docs/multi_column.pdf +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/docs/table_heavy.docx +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/docs/web_page.html +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/make_fixtures.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/snapshots/ligatures_and_softhyphens.md +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/snapshots/multi_column.docling.md +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/snapshots/multi_column.fallback.md +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/snapshots/table_heavy.docling.md +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/snapshots/table_heavy.fallback.md +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/fixtures/snapshots/web_page.md +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/integration/__init__.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/integration/test_docx_backend_override.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/integration/test_docx_docling.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/integration/test_html_trafilatura.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/integration/test_pdf_backend_override.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/integration/test_save_images.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/integration/test_snapshots.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/integration/test_txt.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/__init__.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/__init__.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_cleanup_footnote_markers.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_cleanup_ligatures.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_cleanup_nfc.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_cleanup_quotes_dashes.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_cleanup_soft_hyphens.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_cleanup_validate.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_cleanup_whitespace.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_structured_compact_tables.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_structured_heading_hierarchy.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_structured_lift_figures.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_structured_normalize_cites.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_text_dedupe_paragraphs.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_text_dedupe_toc.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_text_dehyphenate.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_text_repair_line_wraps.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_text_restore_lists_code.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/pipeline/test_text_strip_headers_footers.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/test_auto_id.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/test_config.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/test_content_hash.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/test_docling_helper.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/test_meta_overrides.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/test_pdf_complexity.py +0 -0
- {any2md-1.0.1 → any2md-1.0.2}/tests/unit/test_validators.py +0 -0
|
@@ -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.
|
|
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 `&`, `<`, `>`, 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.
|
|
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
|
-
│
|
|
469
|
-
│
|
|
470
|
-
│
|
|
471
|
-
│
|
|
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
|
-
│
|
|
477
|
-
│
|
|
478
|
-
│
|
|
479
|
-
│
|
|
480
|
-
│
|
|
481
|
-
│
|
|
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
|
-
│
|
|
487
|
-
│
|
|
488
|
-
│
|
|
489
|
-
│
|
|
490
|
-
│
|
|
491
|
-
│
|
|
492
|
-
│
|
|
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
|
-
|
|
497
|
-
|
|
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 `&`, `<`, `>`, 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.
|
|
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
|
-
│
|
|
439
|
-
│
|
|
440
|
-
│
|
|
441
|
-
│
|
|
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
|
-
│
|
|
447
|
-
│
|
|
448
|
-
│
|
|
449
|
-
│
|
|
450
|
-
│
|
|
451
|
-
│
|
|
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
|
-
│
|
|
457
|
-
│
|
|
458
|
-
│
|
|
459
|
-
│
|
|
460
|
-
│
|
|
461
|
-
│
|
|
462
|
-
│
|
|
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
|
-
|
|
467
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
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"] =
|
|
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:
|