any2md 1.0.5__tar.gz → 1.0.7__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.5 → any2md-1.0.7}/CHANGELOG.md +119 -6
- {any2md-1.0.5 → any2md-1.0.7}/PKG-INFO +18 -15
- {any2md-1.0.5 → any2md-1.0.7}/README.md +7 -7
- {any2md-1.0.5 → any2md-1.0.7}/any2md/__init__.py +1 -1
- any2md-1.0.7/any2md/_http.py +106 -0
- {any2md-1.0.5 → any2md-1.0.7}/any2md/cli.py +11 -2
- {any2md-1.0.5 → any2md-1.0.7}/any2md/config.py +16 -2
- {any2md-1.0.5 → any2md-1.0.7}/any2md/converters/docx.py +50 -9
- {any2md-1.0.5 → any2md-1.0.7}/any2md/converters/html.py +26 -65
- {any2md-1.0.5 → any2md-1.0.7}/any2md/converters/pdf.py +9 -3
- {any2md-1.0.5 → any2md-1.0.7}/any2md/converters/txt.py +3 -3
- {any2md-1.0.5 → any2md-1.0.7}/any2md/frontmatter.py +47 -6
- {any2md-1.0.5 → any2md-1.0.7}/any2md/heuristics.py +14 -22
- {any2md-1.0.5 → any2md-1.0.7}/any2md/pipeline/cleanup.py +1 -1
- {any2md-1.0.5 → any2md-1.0.7}/any2md/utils.py +44 -0
- {any2md-1.0.5 → any2md-1.0.7}/docs/architecture.md +5 -5
- {any2md-1.0.5 → any2md-1.0.7}/docs/cli-reference.md +1 -1
- {any2md-1.0.5 → any2md-1.0.7}/docs/output-format.md +19 -19
- {any2md-1.0.5 → any2md-1.0.7}/docs/superpowers/plans/2026-04-26-any2md-v1-phase1-foundation.md +19 -19
- {any2md-1.0.5 → any2md-1.0.7}/docs/superpowers/plans/2026-04-26-any2md-v1-phase4-polish-config-docs.md +11 -11
- any2md-1.0.7/docs/superpowers/plans/2026-04-27-v1.0.6-security-hardening.md +1867 -0
- {any2md-1.0.5 → any2md-1.0.7}/docs/superpowers/specs/2026-04-26-any2md-v1-design.md +25 -25
- any2md-1.0.7/docs/superpowers/specs/2026-04-27-v1.0.6-security-hardening-design.md +477 -0
- {any2md-1.0.5 → any2md-1.0.7}/docs/upgrading-from-0.7.md +9 -9
- {any2md-1.0.5 → any2md-1.0.7}/pyproject.toml +10 -7
- any2md-1.0.7/requirements.txt +10 -0
- {any2md-1.0.5 → any2md-1.0.7}/scripts/validate-phase1.sh +1 -1
- any2md-1.0.7/tests/integration/test_html_trafilatura.py +105 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/integration/test_txt.py +1 -1
- any2md-1.0.7/tests/unit/test_atomic_write.py +52 -0
- any2md-1.0.7/tests/unit/test_config_walkup.py +53 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/test_content_hash.py +1 -1
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/test_frontmatter_compose.py +2 -2
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/test_heuristics.py +21 -27
- any2md-1.0.7/tests/unit/test_http_safe.py +202 -0
- any2md-1.0.7/tests/unit/test_log_sanitizer.py +27 -0
- any2md-1.0.7/tests/unit/test_meta_reserved_keys.py +37 -0
- any2md-1.0.7/tests/unit/test_safe_dir_name.py +25 -0
- any2md-1.0.7/tests/unit/test_zipbomb_guard.py +47 -0
- any2md-1.0.5/requirements.txt +0 -7
- any2md-1.0.5/tests/integration/test_html_trafilatura.py +0 -29
- {any2md-1.0.5 → any2md-1.0.7}/.gitignore +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/CODE_OF_CONDUCT.md +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/CONTRIBUTING.md +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/LICENSE +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/SECURITY.md +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/any2md/__main__.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/any2md/_docling.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/any2md/converters/__init__.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/any2md/pipeline/__init__.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/any2md/pipeline/structured.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/any2md/pipeline/text.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/any2md/validators.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/assets/logo-original.jpeg +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/assets/logo.png +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/assets/social-preview.jpg +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/docs/superpowers/plans/2026-04-26-any2md-v1-phase2-docling-backend.md +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/docs/superpowers/plans/2026-04-26-any2md-v1-phase3-figures-ocr-text-lane.md +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/docs/superpowers/plans/2026-04-26-any2md-v1.0.2-implementation.md +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/docs/superpowers/plans/2026-04-26-issue-17-toc-followups.md +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/docs/superpowers/specs/2026-04-26-issue-17-toc-followups-design.md +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/docs/superpowers/specs/2026-04-26-v1.0.3-empty-title-orphan-punct-design.md +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/docs/troubleshooting.md +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/scripts/audit-outputs.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/__init__.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/cli/__init__.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/cli/test_cli_backend.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/cli/test_cli_existing_flags.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/cli/test_cli_exit_codes.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/cli/test_cli_high_fidelity.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/cli/test_cli_meta.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/cli/test_cli_no_arxiv_lookup.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/cli/test_cli_ocr_save_images.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/cli/test_cli_quiet_verbose.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/cli/test_cli_strict.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/conftest.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/__init__.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/docs/ligatures_and_softhyphens.txt +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/docs/multi_column.pdf +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/docs/table_heavy.docx +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/docs/web_page.html +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/make_fixtures.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/snapshots/ligatures_and_softhyphens.md +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/snapshots/multi_column.docling.md +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/snapshots/multi_column.fallback.md +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/snapshots/table_heavy.docling.md +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/snapshots/table_heavy.fallback.md +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/snapshots/web_page.md +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/integration/__init__.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/integration/test_docx_backend_override.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/integration/test_docx_docling.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/integration/test_docx_fallback_on_warn.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/integration/test_docx_mammoth_fallback.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/integration/test_pdf_backend_override.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/integration/test_pdf_docling.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/integration/test_pdf_pymupdf_fallback.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/integration/test_save_images.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/integration/test_snapshots.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/__init__.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/__init__.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_cleanup_footnote_markers.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_cleanup_html_entities.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_cleanup_html_entities_double_encoded.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_cleanup_ligatures.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_cleanup_nfc.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_cleanup_quotes_dashes.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_cleanup_soft_hyphens.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_cleanup_validate.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_cleanup_whitespace.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_runner.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_strip_orphan_punctuation.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_structured_body_cleanup_stages.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_structured_compact_tables.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_structured_heading_hierarchy.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_structured_lift_figures.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_structured_normalize_cites.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_text_dedupe_paragraphs.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_text_dedupe_toc.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_text_dedupe_toc_table.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_text_dehyphenate.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_text_repair_line_wraps.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_text_restore_lists_code.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_text_strip_cover_artifacts.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_text_strip_headers_footers.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_text_strip_repeated_byline.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_text_strip_web_fragments.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/scripts/__init__.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/scripts/test_audit_outputs.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/test_auto_id.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/test_config.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/test_docling_helper.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/test_frontmatter_helpers.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/test_meta_overrides.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/test_pdf_complexity.py +0 -0
- {any2md-1.0.5 → any2md-1.0.7}/tests/unit/test_validators.py +0 -0
|
@@ -4,6 +4,119 @@ 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.7] — 2026-04-29
|
|
8
|
+
|
|
9
|
+
Branding/rebrand release. The frontmatter contract that any2md emits is
|
|
10
|
+
now described as "SAGE-compatible" (Security Analysis and Guidance
|
|
11
|
+
Exchange) — the upstream specification was renamed from SSRM (Structured
|
|
12
|
+
Security Reasoning Markdown) to SAGE. No functional or schema changes:
|
|
13
|
+
field names, ordering, controlled vocabularies, and `content_hash`
|
|
14
|
+
derivation are byte-identical to v1.0.6. Existing v1.0.6 outputs remain
|
|
15
|
+
valid SAGE-compatible documents without modification.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- README, CHANGELOG, and all of `docs/` updated to reference SAGE /
|
|
20
|
+
Security Analysis and Guidance Exchange.
|
|
21
|
+
- `--auto-id` CLI help text now reads "SAGE-conformant document_id"
|
|
22
|
+
(previously "SSRM-conformant"). Behavior unchanged.
|
|
23
|
+
- Module/function docstrings in `any2md/frontmatter.py`,
|
|
24
|
+
`any2md/converters/txt.py`, and `any2md/pipeline/cleanup.py` updated
|
|
25
|
+
to reference SAGE §3 / §5.1 (same section numbers as the upstream
|
|
26
|
+
SSRM v1.0-RC1 spec).
|
|
27
|
+
- Two test functions renamed for naming consistency
|
|
28
|
+
(`test_txt_end_to_end_writes_sage_compat_output`,
|
|
29
|
+
`test_compose_emits_required_sage_fields`). Pytest auto-discovery is
|
|
30
|
+
unaffected; assertions unchanged.
|
|
31
|
+
|
|
32
|
+
### Migration
|
|
33
|
+
|
|
34
|
+
None required. Public API, CLI flags, frontmatter shape, and
|
|
35
|
+
`content_hash` outputs are all preserved bit-for-bit.
|
|
36
|
+
|
|
37
|
+
## [1.0.6] — 2026-04-27
|
|
38
|
+
|
|
39
|
+
Security hardening release. Closes 8 actionable findings from the
|
|
40
|
+
2026-04-27 multi-tool audit (semgrep + trivy + gitleaks + bandit +
|
|
41
|
+
pip-audit + manual review by the security-engineer agent + Opus 4.7).
|
|
42
|
+
No public API changes; behavior unchanged for legitimate inputs.
|
|
43
|
+
|
|
44
|
+
### Security
|
|
45
|
+
|
|
46
|
+
- **F1 (HIGH)** SSRF: replaced `trafilatura.fetch_url` and the bare
|
|
47
|
+
`urllib.request.urlopen` paths with a new `any2md/_http.py` module
|
|
48
|
+
that does manual redirect walking with per-hop revalidation. Defends
|
|
49
|
+
against DNS-rebinding and redirect-based bypass of the IP-class
|
|
50
|
+
check. New `validate_url` rejects non-http(s) schemes and
|
|
51
|
+
private/loopback/link-local/reserved targets. 20 MB body cap, 3-hop
|
|
52
|
+
redirect cap, redirect-loop detection. Applied to `convert_url`,
|
|
53
|
+
`_http_last_modified`, and `arxiv_lookup`.
|
|
54
|
+
- **F5 (MED)** `--meta` integrity: `content_hash`, `extracted_via`,
|
|
55
|
+
`source_file`, `lane`, `token_estimate`, `recommended_chunk_level`
|
|
56
|
+
are now **reserved** and silently filtered from `--meta` /
|
|
57
|
+
`--meta-file` / `.any2md.toml` overrides with a stderr `WARN` per
|
|
58
|
+
drop. Defense-in-depth filter also runs inside `frontmatter.compose`.
|
|
59
|
+
- **F3 (MED)** TOML auto-discovery now stops at the first ancestor
|
|
60
|
+
containing `.git` / `pyproject.toml` / `setup.py` / `setup.cfg` /
|
|
61
|
+
`package.json` / `.any2md.toml.boundary`. New `--no-config` flag
|
|
62
|
+
fully disables discovery.
|
|
63
|
+
- **F4 (MED)** DOCX zip-bomb: `_safe_zip_open` enforces a 1 MB cap on
|
|
64
|
+
the declared uncompressed size of `core.xml` and `app.xml` before
|
|
65
|
+
parsing. Oversized members raise `ValueError` → clean per-file FAIL.
|
|
66
|
+
- **F-CVE (MED)** Dependency floors bumped: `lxml>=6.1.0,<7`
|
|
67
|
+
(CVE-2026-41066), `pillow>=12.2.0,<13` (CVE-2026-25990,
|
|
68
|
+
CVE-2026-40192), `urllib3>=2.6.3,<3` (CVE-2026-21441). All ranges
|
|
69
|
+
acquire upper bounds (compat envelope). `requirements.txt` becomes
|
|
70
|
+
a development/CI lockfile with exact pins.
|
|
71
|
+
- **F6 (LOW)** Atomic writes: new `any2md.utils.atomic_write_text`
|
|
72
|
+
writes via `tempfile.mkstemp` + `os.replace`. Refuses to overwrite
|
|
73
|
+
a pre-existing symlink at the output path. Wired through
|
|
74
|
+
`pdf/docx/html/txt` converters; PDF image extraction adds a symlink
|
|
75
|
+
check before `pil_image.save`.
|
|
76
|
+
- **F2 (LOW)** Control-char sanitizer strips C0/C1 controls (preserves
|
|
77
|
+
`\t`, `\n`) from captured Docling `msword_backend` warnings before
|
|
78
|
+
forwarding them to `collected_warnings()` and stderr. Defends
|
|
79
|
+
terminals from ANSI-escape spoofing.
|
|
80
|
+
- **F11 (LOW)** PDF image-dir name now goes through `safe_dir_name`
|
|
81
|
+
(alphanumeric/`_`/`-` only). Closes a `Path.stem == ".."` corner
|
|
82
|
+
case that co-mingled images with markdown outputs.
|
|
83
|
+
- **XXE defense-in-depth**: migrated `xml.etree.ElementTree` →
|
|
84
|
+
`defusedxml.ElementTree` in `docx.py` and `heuristics.py`. Stdlib
|
|
85
|
+
was already safe in Python 3.7+; this silences bandit B405/B314
|
|
86
|
+
permanently and protects against future stdlib regressions.
|
|
87
|
+
|
|
88
|
+
### Added
|
|
89
|
+
|
|
90
|
+
- New module `any2md/_http.py` (`validate_url`, `safe_fetch`).
|
|
91
|
+
- New CLI flag `--no-config`.
|
|
92
|
+
- New helpers `any2md.utils.atomic_write_text` and `safe_dir_name`.
|
|
93
|
+
- New runtime dependency: `defusedxml>=0.7.1,<1`.
|
|
94
|
+
|
|
95
|
+
### Tests
|
|
96
|
+
|
|
97
|
+
- 7 new unit-test files (40 new tests): `test_http_safe.py`,
|
|
98
|
+
`test_meta_reserved_keys.py`, `test_config_walkup.py`,
|
|
99
|
+
`test_zipbomb_guard.py`, `test_atomic_write.py`,
|
|
100
|
+
`test_safe_dir_name.py`, `test_log_sanitizer.py`.
|
|
101
|
+
- Existing 290 tests continue to pass.
|
|
102
|
+
- Bandit audit went from **9 findings** (3 medium, 6 low) to **1 low**
|
|
103
|
+
(legitimate `try/except/pass` in arxiv `_warn`).
|
|
104
|
+
- 5-DOCX regression run reproduces v1.0.5 word counts exactly
|
|
105
|
+
(978/6726/15566/7811/1477).
|
|
106
|
+
|
|
107
|
+
### Audit summary
|
|
108
|
+
|
|
109
|
+
| ID | Title | Severity |
|
|
110
|
+
|----|-------|----------|
|
|
111
|
+
| F1 | SSRF: DNS rebind + redirect bypass | High |
|
|
112
|
+
| F5 | `--meta` clobbers reserved frontmatter | Medium |
|
|
113
|
+
| F3 | TOML discovery walks above project root | Medium |
|
|
114
|
+
| F4 | DOCX zip-bomb amplification | Medium |
|
|
115
|
+
| F-CVE | Transitive deps with known CVEs | Medium |
|
|
116
|
+
| F6 | Symlink-following on output writes | Low |
|
|
117
|
+
| F2 | Control-char passthrough in Docling logs | Low |
|
|
118
|
+
| F11 | PDF stem `..` corner case | Low |
|
|
119
|
+
|
|
7
120
|
## [1.0.5] — 2026-04-27
|
|
8
121
|
|
|
9
122
|
Patch release. Recovers DOCX content that Docling's msword backend
|
|
@@ -235,7 +348,7 @@ Patch release. Adds an explicit backend-selection CLI flag.
|
|
|
235
348
|
First stable release of any2md v1.0. Validated end-to-end against
|
|
236
349
|
real-world documents (a 164-page PDF technical standard via Docling,
|
|
237
350
|
a multi-page academic DOCX, and a Wikipedia article via trafilatura).
|
|
238
|
-
The output contract —
|
|
351
|
+
The output contract — SAGE-compatible frontmatter, deterministic
|
|
239
352
|
`content_hash`, NFC + LF body normalization — is now stable. Downstream
|
|
240
353
|
consumers can rely on this shape.
|
|
241
354
|
|
|
@@ -257,7 +370,7 @@ Phase 4: configuration, polish, and the v1.0 documentation set.
|
|
|
257
370
|
- `--meta-file PATH` flag plus auto-discovery of `.any2md.toml` (walks up
|
|
258
371
|
from cwd). The `[meta]` table supplies frontmatter overrides; the
|
|
259
372
|
`[document_id]` table supplies `--auto-id` prefix and type code.
|
|
260
|
-
- `--auto-id` flag — generates an
|
|
373
|
+
- `--auto-id` flag — generates an SAGE-conformant `document_id` as
|
|
261
374
|
`{PREFIX}-{YYYY}-{TYPE}-{SHA8}`. Defaults to `LOCAL`/`DOC`; override via
|
|
262
375
|
the `[document_id]` table in `.any2md.toml`.
|
|
263
376
|
- `--strict` flag — promotes pipeline validation warnings (heading
|
|
@@ -276,7 +389,7 @@ Phase 4: configuration, polish, and the v1.0 documentation set.
|
|
|
276
389
|
- Comprehensive documentation set:
|
|
277
390
|
- `README.md` rewritten with educational tone — every command preceded
|
|
278
391
|
by why you'd run it and followed by what you'll see.
|
|
279
|
-
- `docs/output-format.md` —
|
|
392
|
+
- `docs/output-format.md` — SAGE-compatible field reference and the
|
|
280
393
|
`content_hash` recomputation recipe.
|
|
281
394
|
- `docs/cli-reference.md` — flag-by-flag reference with use cases and a
|
|
282
395
|
worked-example matrix.
|
|
@@ -379,14 +492,14 @@ any2md transparently falls back to pymupdf4llm (PDF) and mammoth (DOCX).
|
|
|
379
492
|
|
|
380
493
|
First prerelease of any2md v1.0. Phase 1 of 5: foundation only — no Docling
|
|
381
494
|
backend yet, no new CLI flags. Output frontmatter has been rewritten to be
|
|
382
|
-
|
|
495
|
+
SAGE-compatible (Security Analysis and Guidance Exchange) — this is a
|
|
383
496
|
breaking change for downstream consumers parsing v0.7 output. See
|
|
384
497
|
`docs/superpowers/specs/2026-04-26-any2md-v1-design.md` for the full
|
|
385
498
|
v1.0 design and `docs/superpowers/plans/2026-04-26-any2md-v1-phase1-foundation.md`
|
|
386
499
|
for this phase's task plan.
|
|
387
500
|
|
|
388
501
|
### Added
|
|
389
|
-
- New `any2md/frontmatter.py` module —
|
|
502
|
+
- New `any2md/frontmatter.py` module — SAGE-compatible YAML emitter with the
|
|
390
503
|
`SourceMeta` dataclass, deterministic `compute_content_hash`,
|
|
391
504
|
`estimate_tokens`, `recommend_chunk_level`, `extract_abstract`, and
|
|
392
505
|
`derive_title` helpers, plus the central `compose()` function (the only
|
|
@@ -412,7 +525,7 @@ for this phase's task plan.
|
|
|
412
525
|
`reportlab`, `ruff`.
|
|
413
526
|
|
|
414
527
|
### Changed
|
|
415
|
-
- **BREAKING:** Output frontmatter shape is now
|
|
528
|
+
- **BREAKING:** Output frontmatter shape is now SAGE-compatible. New required
|
|
416
529
|
fields: `document_id` (empty string by default — opt-in via future
|
|
417
530
|
`--auto-id`), `version`, `date`, `status: "draft"`, `document_type` (empty
|
|
418
531
|
for non-security documents), `content_domain` (empty array),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: any2md
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.7
|
|
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
|
|
@@ -12,13 +12,16 @@ Classifier: Operating System :: OS Independent
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3
|
|
13
13
|
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
14
14
|
Requires-Python: >=3.10
|
|
15
|
-
Requires-Dist: beautifulsoup4
|
|
16
|
-
Requires-Dist:
|
|
17
|
-
Requires-Dist:
|
|
18
|
-
Requires-Dist:
|
|
19
|
-
Requires-Dist:
|
|
20
|
-
Requires-Dist:
|
|
21
|
-
Requires-Dist:
|
|
15
|
+
Requires-Dist: beautifulsoup4<5,>=4.12.0
|
|
16
|
+
Requires-Dist: defusedxml<1,>=0.7.1
|
|
17
|
+
Requires-Dist: lxml<7,>=6.1.0
|
|
18
|
+
Requires-Dist: mammoth<2,>=1.6.0
|
|
19
|
+
Requires-Dist: markdownify<2,>=0.13.0
|
|
20
|
+
Requires-Dist: pillow<13,>=12.2.0
|
|
21
|
+
Requires-Dist: pymupdf4llm<1,>=0.0.17
|
|
22
|
+
Requires-Dist: pymupdf<2,>=1.24.0
|
|
23
|
+
Requires-Dist: trafilatura<3,>=1.12.0
|
|
24
|
+
Requires-Dist: urllib3<3,>=2.6.3
|
|
22
25
|
Provides-Extra: dev
|
|
23
26
|
Requires-Dist: pytest-snapshot>=0.9.0; extra == 'dev'
|
|
24
27
|
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
@@ -49,7 +52,7 @@ The full design rationale lives in [docs/superpowers/specs/2026-04-26-v1.0.3-emp
|
|
|
49
52
|
|
|
50
53
|
## What this is
|
|
51
54
|
|
|
52
|
-
any2md ingests heterogeneous source documents and emits Markdown with a fixed YAML frontmatter contract. The frontmatter shape is
|
|
55
|
+
any2md ingests heterogeneous source documents and emits Markdown with a fixed YAML frontmatter contract. The frontmatter shape is SAGE-compatible (Security Analysis and Guidance Exchange — 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.
|
|
53
56
|
|
|
54
57
|
## Quick start
|
|
55
58
|
|
|
@@ -141,8 +144,8 @@ document_id: "" # empty unless you pass --aut
|
|
|
141
144
|
version: "1" # "1" for source files without an embedded version
|
|
142
145
|
date: "2026-03-15" # docx core props "modified" → ISO-8601
|
|
143
146
|
status: "draft" # always "draft" for converted documents
|
|
144
|
-
document_type: "" #
|
|
145
|
-
content_domain: [] #
|
|
147
|
+
document_type: "" # SAGE controlled vocab — empty for non-security docs
|
|
148
|
+
content_domain: [] # SAGE controlled vocab — empty array when unknown
|
|
146
149
|
authors: # extracted from docProps/core.xml dc:creator
|
|
147
150
|
- "Rock Lambros"
|
|
148
151
|
organization: "" # docProps/app.xml Company; empty when absent
|
|
@@ -163,7 +166,7 @@ produced_by: "Microsoft® Word for Microsoft 365" # software that produced the
|
|
|
163
166
|
---
|
|
164
167
|
```
|
|
165
168
|
|
|
166
|
-
Fields under `# any2md extension field` are retained from v0.7 for traceability and observability — they're not part of the
|
|
169
|
+
Fields under `# any2md extension field` are retained from v0.7 for traceability and observability — they're not part of the SAGE contract proper, but they live in the same frontmatter block. See [docs/output-format.md](docs/output-format.md) for the full field reference.
|
|
167
170
|
|
|
168
171
|
### Before / after
|
|
169
172
|
|
|
@@ -359,7 +362,7 @@ You'll see the OK lines as if the conversion were fresh; previously-written outp
|
|
|
359
362
|
|
|
360
363
|
## The output format
|
|
361
364
|
|
|
362
|
-
Every file converted by any2md has the same frontmatter shape, regardless of source format. The shape is **
|
|
365
|
+
Every file converted by any2md has the same frontmatter shape, regardless of source format. The shape is **SAGE-compatible** — it matches the field names, types, and ordering of the Security Analysis and Guidance Exchange specification, but values aren't required to match SAGE's controlled vocabularies (most converted documents aren't security research). Fields that require a controlled vocabulary (`document_type`, `content_domain`, `tlp`) are emitted empty unless you supply them via `--meta` or `.any2md.toml`.
|
|
363
366
|
|
|
364
367
|
### Field auto-fill summary
|
|
365
368
|
|
|
@@ -404,7 +407,7 @@ You'll see the OK line as usual. The frontmatter of `Text/paper.md` will have `o
|
|
|
404
407
|
|
|
405
408
|
### `.any2md.toml`
|
|
406
409
|
|
|
407
|
-
You'd use this when you have a stable set of frontmatter defaults that should apply to every conversion in a project. The file is auto-discovered by walking up from the current working directory, so you can drop one at the root of your corpus repo and forget about it. Worked example for a security-research org producing
|
|
410
|
+
You'd use this when you have a stable set of frontmatter defaults that should apply to every conversion in a project. The file is auto-discovered by walking up from the current working directory, so you can drop one at the root of your corpus repo and forget about it. Worked example for a security-research org producing SAGE-conforming outputs:
|
|
408
411
|
|
|
409
412
|
```toml
|
|
410
413
|
[meta]
|
|
@@ -558,7 +561,7 @@ The two-lane design exists because Docling output is layout-correct (running agg
|
|
|
558
561
|
|
|
559
562
|
## Migrating from v0.7
|
|
560
563
|
|
|
561
|
-
v1.0 emits
|
|
564
|
+
v1.0 emits SAGE-compatible frontmatter — that's the one intended breaking change. v0.7's small frontmatter (`title`, `source_file`, `pages`, `type`, `word_count`) becomes a fuller block with `document_id`, `version`, `date`, `status`, `content_hash`, `token_estimate`, `authors`, and several other fields. The v0.7 fields are retained as any2md extension fields in the same block, so traceability is preserved, but downstream parsers that expected the old fixed shape will need to update. The body is now NFC-normalized with LF line endings, which means `content_hash` is reproducible. If you need bit-for-bit v0.7 output, pin `any2md==0.7.0`. The full field-by-field migration guide and behavior-change list lives in [docs/upgrading-from-0.7.md](docs/upgrading-from-0.7.md).
|
|
562
565
|
|
|
563
566
|
## Security
|
|
564
567
|
|
|
@@ -19,7 +19,7 @@ The full design rationale lives in [docs/superpowers/specs/2026-04-26-v1.0.3-emp
|
|
|
19
19
|
|
|
20
20
|
## What this is
|
|
21
21
|
|
|
22
|
-
any2md ingests heterogeneous source documents and emits Markdown with a fixed YAML frontmatter contract. The frontmatter shape is
|
|
22
|
+
any2md ingests heterogeneous source documents and emits Markdown with a fixed YAML frontmatter contract. The frontmatter shape is SAGE-compatible (Security Analysis and Guidance Exchange — 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.
|
|
23
23
|
|
|
24
24
|
## Quick start
|
|
25
25
|
|
|
@@ -111,8 +111,8 @@ document_id: "" # empty unless you pass --aut
|
|
|
111
111
|
version: "1" # "1" for source files without an embedded version
|
|
112
112
|
date: "2026-03-15" # docx core props "modified" → ISO-8601
|
|
113
113
|
status: "draft" # always "draft" for converted documents
|
|
114
|
-
document_type: "" #
|
|
115
|
-
content_domain: [] #
|
|
114
|
+
document_type: "" # SAGE controlled vocab — empty for non-security docs
|
|
115
|
+
content_domain: [] # SAGE controlled vocab — empty array when unknown
|
|
116
116
|
authors: # extracted from docProps/core.xml dc:creator
|
|
117
117
|
- "Rock Lambros"
|
|
118
118
|
organization: "" # docProps/app.xml Company; empty when absent
|
|
@@ -133,7 +133,7 @@ produced_by: "Microsoft® Word for Microsoft 365" # software that produced the
|
|
|
133
133
|
---
|
|
134
134
|
```
|
|
135
135
|
|
|
136
|
-
Fields under `# any2md extension field` are retained from v0.7 for traceability and observability — they're not part of the
|
|
136
|
+
Fields under `# any2md extension field` are retained from v0.7 for traceability and observability — they're not part of the SAGE contract proper, but they live in the same frontmatter block. See [docs/output-format.md](docs/output-format.md) for the full field reference.
|
|
137
137
|
|
|
138
138
|
### Before / after
|
|
139
139
|
|
|
@@ -329,7 +329,7 @@ You'll see the OK lines as if the conversion were fresh; previously-written outp
|
|
|
329
329
|
|
|
330
330
|
## The output format
|
|
331
331
|
|
|
332
|
-
Every file converted by any2md has the same frontmatter shape, regardless of source format. The shape is **
|
|
332
|
+
Every file converted by any2md has the same frontmatter shape, regardless of source format. The shape is **SAGE-compatible** — it matches the field names, types, and ordering of the Security Analysis and Guidance Exchange specification, but values aren't required to match SAGE's controlled vocabularies (most converted documents aren't security research). Fields that require a controlled vocabulary (`document_type`, `content_domain`, `tlp`) are emitted empty unless you supply them via `--meta` or `.any2md.toml`.
|
|
333
333
|
|
|
334
334
|
### Field auto-fill summary
|
|
335
335
|
|
|
@@ -374,7 +374,7 @@ You'll see the OK line as usual. The frontmatter of `Text/paper.md` will have `o
|
|
|
374
374
|
|
|
375
375
|
### `.any2md.toml`
|
|
376
376
|
|
|
377
|
-
You'd use this when you have a stable set of frontmatter defaults that should apply to every conversion in a project. The file is auto-discovered by walking up from the current working directory, so you can drop one at the root of your corpus repo and forget about it. Worked example for a security-research org producing
|
|
377
|
+
You'd use this when you have a stable set of frontmatter defaults that should apply to every conversion in a project. The file is auto-discovered by walking up from the current working directory, so you can drop one at the root of your corpus repo and forget about it. Worked example for a security-research org producing SAGE-conforming outputs:
|
|
378
378
|
|
|
379
379
|
```toml
|
|
380
380
|
[meta]
|
|
@@ -528,7 +528,7 @@ The two-lane design exists because Docling output is layout-correct (running agg
|
|
|
528
528
|
|
|
529
529
|
## Migrating from v0.7
|
|
530
530
|
|
|
531
|
-
v1.0 emits
|
|
531
|
+
v1.0 emits SAGE-compatible frontmatter — that's the one intended breaking change. v0.7's small frontmatter (`title`, `source_file`, `pages`, `type`, `word_count`) becomes a fuller block with `document_id`, `version`, `date`, `status`, `content_hash`, `token_estimate`, `authors`, and several other fields. The v0.7 fields are retained as any2md extension fields in the same block, so traceability is preserved, but downstream parsers that expected the old fixed shape will need to update. The body is now NFC-normalized with LF line endings, which means `content_hash` is reproducible. If you need bit-for-bit v0.7 output, pin `any2md==0.7.0`. The full field-by-field migration guide and behavior-change list lives in [docs/upgrading-from-0.7.md](docs/upgrading-from-0.7.md).
|
|
532
532
|
|
|
533
533
|
## Security
|
|
534
534
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""SSRF-safe HTTP fetching utilities for any2md.
|
|
2
|
+
|
|
3
|
+
Centralizes URL scheme + IP validation and provides a fetcher that
|
|
4
|
+
walks redirects manually and revalidates the host on each hop. This
|
|
5
|
+
defends against:
|
|
6
|
+
- DNS rebinding (host -> public IP for validator, private IP for fetch)
|
|
7
|
+
- Redirect-based SSRF (public landing page -> 302 to 169.254.169.254)
|
|
8
|
+
- Non-http(s) schemes (file://, gopher://, etc.)
|
|
9
|
+
- Decompression / huge-response DoS (response-size cap)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ipaddress
|
|
15
|
+
import socket
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import urllib.request
|
|
19
|
+
|
|
20
|
+
_MAX_REDIRECT_HOPS = 3
|
|
21
|
+
_FETCH_TIMEOUT = 15 # seconds
|
|
22
|
+
_MAX_RESPONSE_BYTES = 20 * 1024 * 1024 # 20 MB cap on body read
|
|
23
|
+
_USER_AGENT = "any2md/1.0.6"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def validate_url(url: str) -> str | None:
|
|
27
|
+
"""Validate URL scheme + host. Returns an error message or None.
|
|
28
|
+
|
|
29
|
+
One-shot DNS lookup; rebind protection requires re-checking on
|
|
30
|
+
every redirect hop (handled by ``safe_fetch``).
|
|
31
|
+
"""
|
|
32
|
+
parsed = urllib.parse.urlparse(url)
|
|
33
|
+
if parsed.scheme not in ("http", "https"):
|
|
34
|
+
return f"unsupported scheme: {parsed.scheme!r}"
|
|
35
|
+
if not parsed.hostname:
|
|
36
|
+
return f"no hostname in URL: {url}"
|
|
37
|
+
try:
|
|
38
|
+
infos = socket.getaddrinfo(parsed.hostname, None, type=socket.SOCK_STREAM)
|
|
39
|
+
except socket.gaierror:
|
|
40
|
+
return f"cannot resolve host: {parsed.hostname}"
|
|
41
|
+
for *_, sockaddr in infos:
|
|
42
|
+
try:
|
|
43
|
+
addr = ipaddress.ip_address(sockaddr[0])
|
|
44
|
+
except ValueError:
|
|
45
|
+
continue
|
|
46
|
+
if (
|
|
47
|
+
addr.is_private
|
|
48
|
+
or addr.is_reserved
|
|
49
|
+
or addr.is_loopback
|
|
50
|
+
or addr.is_link_local
|
|
51
|
+
):
|
|
52
|
+
return f"URL resolves to disallowed address: {addr}"
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _NoFollowRedirect(urllib.request.HTTPRedirectHandler):
|
|
57
|
+
"""Disable urllib's automatic redirect following."""
|
|
58
|
+
|
|
59
|
+
def redirect_request(self, *args, **kwargs): # noqa: ARG002
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def safe_fetch(
|
|
64
|
+
url: str,
|
|
65
|
+
*,
|
|
66
|
+
method: str = "GET",
|
|
67
|
+
max_hops: int = _MAX_REDIRECT_HOPS,
|
|
68
|
+
) -> tuple[bytes | None, dict | None, str | None]:
|
|
69
|
+
"""Fetch ``url`` with manual redirect walking + per-hop revalidation.
|
|
70
|
+
|
|
71
|
+
Returns ``(body, headers, None)`` on 2xx, or ``(None, None, error)``
|
|
72
|
+
on rejection / failure. ``headers`` is the response headers as a
|
|
73
|
+
plain ``dict``.
|
|
74
|
+
"""
|
|
75
|
+
visited: list[str] = []
|
|
76
|
+
current = url
|
|
77
|
+
opener = urllib.request.build_opener(_NoFollowRedirect())
|
|
78
|
+
for hop in range(max_hops + 1):
|
|
79
|
+
if current in visited:
|
|
80
|
+
return None, None, "redirect loop"
|
|
81
|
+
visited.append(current)
|
|
82
|
+
err = validate_url(current)
|
|
83
|
+
if err:
|
|
84
|
+
return None, None, err
|
|
85
|
+
req = urllib.request.Request(
|
|
86
|
+
current, method=method, headers={"User-Agent": _USER_AGENT}
|
|
87
|
+
)
|
|
88
|
+
try:
|
|
89
|
+
resp = opener.open(req, timeout=_FETCH_TIMEOUT)
|
|
90
|
+
except urllib.error.HTTPError as e:
|
|
91
|
+
if e.code in (301, 302, 303, 307, 308):
|
|
92
|
+
if hop >= max_hops:
|
|
93
|
+
return None, None, f"too many redirects (>{max_hops})"
|
|
94
|
+
location = e.headers.get("Location") if e.headers else None
|
|
95
|
+
if not location:
|
|
96
|
+
return None, None, f"HTTP {e.code} without Location"
|
|
97
|
+
current = urllib.parse.urljoin(current, location)
|
|
98
|
+
continue
|
|
99
|
+
return None, None, f"HTTP {e.code}"
|
|
100
|
+
except (urllib.error.URLError, OSError, TimeoutError) as e:
|
|
101
|
+
return None, None, f"fetch error: {e}"
|
|
102
|
+
body = resp.read(_MAX_RESPONSE_BYTES + 1)
|
|
103
|
+
if len(body) > _MAX_RESPONSE_BYTES:
|
|
104
|
+
return None, None, f"response exceeds {_MAX_RESPONSE_BYTES} bytes"
|
|
105
|
+
return body, dict(resp.headers), None
|
|
106
|
+
return None, None, f"too many redirects (>{max_hops})"
|
|
@@ -20,6 +20,7 @@ from any2md.converters import (
|
|
|
20
20
|
set_output_mode,
|
|
21
21
|
)
|
|
22
22
|
from any2md.converters.html import convert_url
|
|
23
|
+
from any2md.frontmatter import filter_reserved_overrides
|
|
23
24
|
from any2md.pipeline import PipelineOptions
|
|
24
25
|
from any2md.utils import sanitize_filename, url_to_filename
|
|
25
26
|
|
|
@@ -169,6 +170,11 @@ def main():
|
|
|
169
170
|
action="store_true",
|
|
170
171
|
help="Promote pipeline validation warnings to errors (exit 3).",
|
|
171
172
|
)
|
|
173
|
+
parser.add_argument(
|
|
174
|
+
"--no-config",
|
|
175
|
+
action="store_true",
|
|
176
|
+
help="Skip .any2md.toml auto-discovery (overrides default walk-up).",
|
|
177
|
+
)
|
|
172
178
|
parser.add_argument(
|
|
173
179
|
"--docx-fallback-on-warn",
|
|
174
180
|
action=argparse.BooleanOptionalAction,
|
|
@@ -194,7 +200,7 @@ def main():
|
|
|
194
200
|
parser.add_argument(
|
|
195
201
|
"--auto-id",
|
|
196
202
|
action="store_true",
|
|
197
|
-
help="Generate an
|
|
203
|
+
help="Generate an SAGE-conformant document_id of the form "
|
|
198
204
|
"{PREFIX}-{YYYY}-{TYPE}-{SHA8}. Defaults: PREFIX=LOCAL, TYPE=DOC. "
|
|
199
205
|
"Override via .any2md.toml [document_id] (publisher_prefix, type_code).",
|
|
200
206
|
)
|
|
@@ -222,7 +228,7 @@ def main():
|
|
|
222
228
|
overrides: dict[str, Any] = {}
|
|
223
229
|
auto_id_prefix, auto_id_type_code = "LOCAL", "DOC"
|
|
224
230
|
|
|
225
|
-
discovered = discover_config()
|
|
231
|
+
discovered = None if args.no_config else discover_config()
|
|
226
232
|
if discovered is not None:
|
|
227
233
|
cfg = load_toml(discovered)
|
|
228
234
|
overrides = _deep_merge_overrides(overrides, extract_meta_overrides(cfg))
|
|
@@ -249,6 +255,9 @@ def main():
|
|
|
249
255
|
print(f"Error: {e}", file=sys.stderr)
|
|
250
256
|
sys.exit(1)
|
|
251
257
|
overrides = _deep_merge_overrides(overrides, cli_meta)
|
|
258
|
+
overrides = filter_reserved_overrides(
|
|
259
|
+
overrides, source_label="--meta/--meta-file/.any2md.toml"
|
|
260
|
+
)
|
|
252
261
|
|
|
253
262
|
if (
|
|
254
263
|
args.high_fidelity
|
|
@@ -42,17 +42,31 @@ def load_toml(path: Path) -> dict[str, Any]:
|
|
|
42
42
|
return {}
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
_PROJECT_BOUNDARY_MARKERS = (
|
|
46
|
+
".git",
|
|
47
|
+
"pyproject.toml",
|
|
48
|
+
"setup.py",
|
|
49
|
+
"setup.cfg",
|
|
50
|
+
"package.json",
|
|
51
|
+
".any2md.toml.boundary",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
45
55
|
def discover_config(start: Path | None = None) -> Path | None:
|
|
46
56
|
"""Walk up from ``start`` (default: cwd) looking for ``.any2md.toml``.
|
|
47
57
|
|
|
48
|
-
|
|
49
|
-
|
|
58
|
+
Stops at the first ancestor containing a project boundary marker
|
|
59
|
+
(``.git``, ``pyproject.toml``, ``setup.py``, ``setup.cfg``,
|
|
60
|
+
``package.json``, or ``.any2md.toml.boundary``) so config in
|
|
61
|
+
unrelated parent directories cannot leak into a project's run.
|
|
50
62
|
"""
|
|
51
63
|
cur = (start or Path.cwd()).resolve()
|
|
52
64
|
while True:
|
|
53
65
|
candidate = cur / ".any2md.toml"
|
|
54
66
|
if candidate.is_file():
|
|
55
67
|
return candidate
|
|
68
|
+
if any((cur / m).exists() for m in _PROJECT_BOUNDARY_MARKERS):
|
|
69
|
+
return None
|
|
56
70
|
if cur.parent == cur:
|
|
57
71
|
return None
|
|
58
72
|
cur = cur.parent
|
|
@@ -10,9 +10,12 @@ produces the markdown body.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import logging
|
|
13
|
+
import re
|
|
13
14
|
import sys
|
|
14
|
-
import xml.etree.ElementTree as ET
|
|
15
15
|
import zipfile
|
|
16
|
+
|
|
17
|
+
from defusedxml.ElementTree import ParseError as _XmlParseError
|
|
18
|
+
from defusedxml.ElementTree import parse as _xml_parse
|
|
16
19
|
from datetime import date
|
|
17
20
|
from pathlib import Path
|
|
18
21
|
|
|
@@ -25,11 +28,44 @@ from any2md.converters import add_warnings, is_quiet
|
|
|
25
28
|
from any2md.frontmatter import SourceMeta, compose
|
|
26
29
|
from any2md.heuristics import filter_organization
|
|
27
30
|
from any2md.pipeline import PipelineOptions
|
|
28
|
-
from any2md.utils import sanitize_filename
|
|
31
|
+
from any2md.utils import atomic_write_text, sanitize_filename
|
|
29
32
|
|
|
30
33
|
|
|
31
34
|
_DOCLING_MSWORD_LOGGER = "docling.backend.msword_backend"
|
|
32
35
|
|
|
36
|
+
_LOG_CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _sanitize_log_text(s: str) -> str:
|
|
40
|
+
"""Strip ASCII C0/C1 control chars from a log message before re-emission.
|
|
41
|
+
|
|
42
|
+
Defends terminals from ANSI-escape spoofing planted in malicious DOCX
|
|
43
|
+
content that surfaces as a Docling msword_backend warning. ``\\t``
|
|
44
|
+
and ``\\n`` are preserved (not in the strip class).
|
|
45
|
+
"""
|
|
46
|
+
return _LOG_CONTROL_CHARS_RE.sub("", s)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_MAX_DOCX_METADATA_SIZE = 1 * 1024 * 1024 # 1 MB; core.xml/app.xml are typically <10 KB
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _safe_zip_open(
|
|
53
|
+
z: zipfile.ZipFile, name: str, max_size: int = _MAX_DOCX_METADATA_SIZE
|
|
54
|
+
):
|
|
55
|
+
"""Open a fixed-name DOCX member after a declared-size sanity check.
|
|
56
|
+
|
|
57
|
+
Defends against zip-bomb amplification: a malicious DOCX can declare
|
|
58
|
+
an uncompressed_size of 10 GB for ``core.xml`` and bomb the XML
|
|
59
|
+
parser. Raises ``ValueError`` (caught by ``convert_docx``'s existing
|
|
60
|
+
handler -> file fails cleanly).
|
|
61
|
+
"""
|
|
62
|
+
info = z.getinfo(name)
|
|
63
|
+
if info.file_size > max_size:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"{name} declared size {info.file_size} exceeds {max_size}-byte limit"
|
|
66
|
+
)
|
|
67
|
+
return z.open(name)
|
|
68
|
+
|
|
33
69
|
|
|
34
70
|
class _DoclingMswordWarningCapture:
|
|
35
71
|
"""Buffer WARNING+ records from Docling's DOCX backend logger.
|
|
@@ -89,8 +125,8 @@ def _read_docx_metadata(docx_path: Path) -> dict[str, object]:
|
|
|
89
125
|
try:
|
|
90
126
|
with zipfile.ZipFile(docx_path) as z:
|
|
91
127
|
try:
|
|
92
|
-
with z
|
|
93
|
-
root =
|
|
128
|
+
with _safe_zip_open(z, "docProps/core.xml") as f:
|
|
129
|
+
root = _xml_parse(f).getroot()
|
|
94
130
|
title = root.findtext("dc:title", namespaces=_NS_CORE)
|
|
95
131
|
if title:
|
|
96
132
|
out["title_hint"] = title.strip()
|
|
@@ -105,8 +141,8 @@ def _read_docx_metadata(docx_path: Path) -> dict[str, object]:
|
|
|
105
141
|
except KeyError:
|
|
106
142
|
pass
|
|
107
143
|
try:
|
|
108
|
-
with z
|
|
109
|
-
root =
|
|
144
|
+
with _safe_zip_open(z, "docProps/app.xml") as f:
|
|
145
|
+
root = _xml_parse(f).getroot()
|
|
110
146
|
company = root.findtext("ext:Company", namespaces=_NS_APP)
|
|
111
147
|
application = root.findtext("ext:Application", namespaces=_NS_APP)
|
|
112
148
|
# v1.0.2: Company takes priority for `organization` (real
|
|
@@ -126,7 +162,7 @@ def _read_docx_metadata(docx_path: Path) -> dict[str, object]:
|
|
|
126
162
|
out["produced_by"] = app_result.produced_by
|
|
127
163
|
except KeyError:
|
|
128
164
|
pass
|
|
129
|
-
except (zipfile.BadZipFile,
|
|
165
|
+
except (zipfile.BadZipFile, _XmlParseError):
|
|
130
166
|
pass
|
|
131
167
|
return out
|
|
132
168
|
|
|
@@ -233,7 +269,12 @@ def convert_docx(
|
|
|
233
269
|
md_text, _ = _extract_via_mammoth(docx_path, options)
|
|
234
270
|
extracted_via = "docling→mammoth (warning fallback)"
|
|
235
271
|
lane = "text"
|
|
236
|
-
add_warnings(
|
|
272
|
+
add_warnings(
|
|
273
|
+
[
|
|
274
|
+
f"docling.msword_backend: {_sanitize_log_text(m)}"
|
|
275
|
+
for m in docling_warnings
|
|
276
|
+
]
|
|
277
|
+
)
|
|
237
278
|
|
|
238
279
|
md_text, warnings = pipeline.run(md_text, lane, options)
|
|
239
280
|
add_warnings(warnings)
|
|
@@ -260,7 +301,7 @@ def convert_docx(
|
|
|
260
301
|
full = compose(md_text, meta, options, overrides=options.frontmatter_overrides)
|
|
261
302
|
|
|
262
303
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
263
|
-
out_path
|
|
304
|
+
atomic_write_text(out_path, full)
|
|
264
305
|
wc = meta.word_count or 0
|
|
265
306
|
suffix = f", {len(warnings)} warning(s)" if warnings else ""
|
|
266
307
|
if not is_quiet():
|