any2md 1.1.0__tar.gz → 1.2.0__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.1.0 → any2md-1.2.0}/.gitignore +0 -1
- {any2md-1.1.0 → any2md-1.2.0}/CHANGELOG.md +140 -0
- {any2md-1.1.0 → any2md-1.2.0}/CONTRIBUTING.md +19 -1
- {any2md-1.1.0 → any2md-1.2.0}/PKG-INFO +5 -5
- {any2md-1.1.0 → any2md-1.2.0}/SECURITY.md +22 -0
- {any2md-1.1.0 → any2md-1.2.0}/any2md/__init__.py +1 -1
- {any2md-1.1.0 → any2md-1.2.0}/any2md/_docling.py +1 -1
- {any2md-1.1.0 → any2md-1.2.0}/any2md/_docling_cache.py +3 -1
- any2md-1.2.0/any2md/_http.py +199 -0
- any2md-1.2.0/any2md/_logging.py +61 -0
- {any2md-1.1.0 → any2md-1.2.0}/any2md/cli.py +11 -6
- {any2md-1.1.0 → any2md-1.2.0}/any2md/config.py +7 -15
- {any2md-1.1.0 → any2md-1.2.0}/any2md/converters/__init__.py +2 -2
- {any2md-1.1.0 → any2md-1.2.0}/any2md/converters/docx.py +3 -16
- {any2md-1.1.0 → any2md-1.2.0}/any2md/converters/html.py +143 -5
- {any2md-1.1.0 → any2md-1.2.0}/any2md/converters/pdf.py +27 -13
- {any2md-1.1.0 → any2md-1.2.0}/any2md/converters/txt.py +3 -4
- {any2md-1.1.0 → any2md-1.2.0}/any2md/frontmatter.py +30 -0
- {any2md-1.1.0 → any2md-1.2.0}/any2md/heuristics.py +9 -6
- {any2md-1.1.0 → any2md-1.2.0}/any2md/utils.py +57 -10
- {any2md-1.1.0 → any2md-1.2.0}/docs/architecture.md +3 -0
- {any2md-1.1.0 → any2md-1.2.0}/docs/output-format.md +46 -8
- any2md-1.2.0/docs/superpowers/plans/2026-05-06-v1.1.1-security-hardening.md +2543 -0
- any2md-1.2.0/docs/superpowers/specs/2026-05-06-v1.1.1-security-design.md +553 -0
- {any2md-1.1.0 → any2md-1.2.0}/pyproject.toml +3 -3
- any2md-1.2.0/requirements.txt +81 -0
- any2md-1.2.0/tests/fixtures/docs/web_bold_title_first_line.html +15 -0
- any2md-1.2.0/tests/fixtures/docs/web_h1_in_hero.html +38 -0
- any2md-1.2.0/tests/fixtures/docs/web_h3_sections.html +21 -0
- any2md-1.2.0/tests/fixtures/docs/web_no_title.html +12 -0
- any2md-1.2.0/tests/integration/test_html_h1.py +148 -0
- any2md-1.2.0/tests/unit/test_atomic_write_symlink_race.py +39 -0
- any2md-1.2.0/tests/unit/test_config_toml_warn.py +36 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_docling_cache.py +2 -2
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_frontmatter_compose.py +28 -0
- any2md-1.2.0/tests/unit/test_frontmatter_key_injection.py +59 -0
- any2md-1.2.0/tests/unit/test_heuristics_wikipedia_host.py +22 -0
- any2md-1.2.0/tests/unit/test_html_heading_levels.py +72 -0
- any2md-1.2.0/tests/unit/test_html_title_h1.py +117 -0
- any2md-1.2.0/tests/unit/test_http_ip_allowlist.py +68 -0
- any2md-1.2.0/tests/unit/test_http_ssrf_pin.py +63 -0
- any2md-1.2.0/tests/unit/test_http_user_agent.py +10 -0
- any2md-1.2.0/tests/unit/test_lockfile_freshness.py +41 -0
- any2md-1.2.0/tests/unit/test_logging_module.py +85 -0
- any2md-1.2.0/tests/unit/test_pdf_image_symlink.py +54 -0
- any2md-1.2.0/tests/unit/test_security_error_strings.py +86 -0
- any2md-1.2.0/tests/unit/test_url_creds_scrubbed.py +76 -0
- any2md-1.2.0/tests/unit/test_workflow_permissions.py +44 -0
- any2md-1.1.0/any2md/_http.py +0 -106
- any2md-1.1.0/requirements.txt +0 -10
- {any2md-1.1.0 → any2md-1.2.0}/CODE_OF_CONDUCT.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/LICENSE +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/README.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/any2md/__main__.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/any2md/pipeline/__init__.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/any2md/pipeline/cleanup.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/any2md/pipeline/structured.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/any2md/pipeline/text.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/any2md/validators.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/assets/logo-original.jpeg +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/assets/logo.png +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/assets/social-preview.jpg +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/docs/cli-reference.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase1-foundation.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase2-docling-backend.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase3-figures-ocr-text-lane.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase4-polish-config-docs.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-any2md-v1.0.2-implementation.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-issue-17-toc-followups.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/plans/2026-04-27-v1.0.6-security-hardening.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/plans/2026-05-04-v1.1.0-persistent-converter.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/specs/2026-04-26-any2md-v1-design.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/specs/2026-04-26-issue-17-toc-followups-design.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/specs/2026-04-26-v1.0.3-empty-title-orphan-punct-design.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/specs/2026-04-27-v1.0.6-security-hardening-design.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/specs/2026-05-04-v1.1.0-persistent-converter-design.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/docs/troubleshooting.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/docs/upgrading-from-0.7.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/scripts/audit-outputs.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/scripts/bench_docling_cache.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/scripts/validate-phase1.sh +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/__init__.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/cli/__init__.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/cli/test_cli_backend.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/cli/test_cli_existing_flags.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/cli/test_cli_exit_codes.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/cli/test_cli_high_fidelity.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/cli/test_cli_meta.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/cli/test_cli_no_arxiv_lookup.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/cli/test_cli_ocr_save_images.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/cli/test_cli_quiet_verbose.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/cli/test_cli_strict.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/conftest.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/__init__.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/docs/ligatures_and_softhyphens.txt +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/docs/multi_column.pdf +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/docs/table_heavy.docx +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/docs/web_page.html +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/make_fixtures.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/snapshots/ligatures_and_softhyphens.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/snapshots/multi_column.docling.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/snapshots/multi_column.fallback.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/snapshots/table_heavy.docling.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/snapshots/table_heavy.fallback.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/snapshots/web_page.md +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/integration/__init__.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_docling_persistence.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_docx_backend_override.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_docx_docling.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_docx_fallback_on_warn.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_docx_mammoth_fallback.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_html_trafilatura.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_pdf_backend_override.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_pdf_docling.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_pdf_pymupdf_fallback.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_save_images.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_snapshots.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_txt.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/__init__.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/__init__.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_footnote_markers.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_html_entities.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_html_entities_double_encoded.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_ligatures.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_nfc.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_quotes_dashes.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_soft_hyphens.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_validate.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_whitespace.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_runner.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_strip_orphan_punctuation.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_structured_body_cleanup_stages.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_structured_compact_tables.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_structured_heading_hierarchy.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_structured_lift_figures.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_structured_normalize_cites.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_text_dedupe_paragraphs.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_text_dedupe_toc.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_text_dedupe_toc_table.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_text_dehyphenate.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_text_repair_line_wraps.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_text_restore_lists_code.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_text_strip_cover_artifacts.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_text_strip_headers_footers.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_text_strip_repeated_byline.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_text_strip_web_fragments.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/scripts/__init__.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/scripts/test_audit_outputs.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_atomic_write.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_auto_id.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_config.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_config_walkup.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_content_hash.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_docling_helper.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_frontmatter_helpers.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_heuristics.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_http_safe.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_log_sanitizer.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_meta_overrides.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_meta_reserved_keys.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_pdf_complexity.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_safe_dir_name.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_validators.py +0 -0
- {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_zipbomb_guard.py +0 -0
|
@@ -4,6 +4,146 @@ 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.2.0] — 2026-11-09
|
|
8
|
+
|
|
9
|
+
Drops Python 3.10 to take the Pillow 13 security fixes, and fixes
|
|
10
|
+
missing H1 headings in HTML/URL output.
|
|
11
|
+
|
|
12
|
+
### Removed
|
|
13
|
+
|
|
14
|
+
- **Python 3.10 support.** `requires-python` is now `>=3.11`. Pillow
|
|
15
|
+
13.0.0 is the only release that fixes the advisories below, and it
|
|
16
|
+
requires Python 3.11. Python 3.10 reached end-of-life in October 2026.
|
|
17
|
+
pip on 3.10 keeps resolving any2md 1.1.1, which pins the vulnerable
|
|
18
|
+
pillow 12.x; upgrade the interpreter to get these fixes. The `tomli`
|
|
19
|
+
fallback dependency is gone with it (stdlib `tomllib` covers 3.11+),
|
|
20
|
+
and the CI matrix now tests 3.11, 3.12, and 3.13.
|
|
21
|
+
|
|
22
|
+
### Security
|
|
23
|
+
|
|
24
|
+
- **Pillow 13.0.0.** The `pillow>=12.2.0,<13` range held installs on a
|
|
25
|
+
release with 39 published advisories (e.g. CVE-2026-112395), all fixed
|
|
26
|
+
in 13.0.0. The range is now `pillow>=13.0.0,<14`. Docling's packages
|
|
27
|
+
accept `pillow<14`, so the `high-fidelity` extra still resolves.
|
|
28
|
+
- **Locked dev-container dependencies.** `.devcontainer/requirements.lock`
|
|
29
|
+
and `requirements.txt` move certifi, lxml-html-clean, soupsieve, and
|
|
30
|
+
urllib3 to their fixed releases (lxml follows to 6.1.3). Both
|
|
31
|
+
lockfiles now audit clean with `pip-audit`.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **HTML/URL bodies had no H1.** trafilatura keeps a heading only
|
|
36
|
+
when it sits inside the content node it selects. A page `<h1>` in a
|
|
37
|
+
hero banner or `<header>` block outside that node was dropped, so
|
|
38
|
+
nearly every blog page warned `validator: H1 count is 0 (expected 1)`
|
|
39
|
+
(106 of 106 labs.zenity.io posts, 182 of 214 zenity.io/blog posts).
|
|
40
|
+
The HTML converter now restores the H1 before the pipeline runs:
|
|
41
|
+
- The body already has an H1: unchanged.
|
|
42
|
+
- The first body line repeats the title as plain or bold text: that
|
|
43
|
+
line is promoted to the H1, so the title appears once.
|
|
44
|
+
- Otherwise `# <title>` is inserted as the first line.
|
|
45
|
+
- No title is available: unchanged (the validator still warns).
|
|
46
|
+
|
|
47
|
+
The H1 text is the page's own `<h1>` when the page has exactly one
|
|
48
|
+
non-empty `<h1>`, else trafilatura's metadata title. `content_hash`,
|
|
49
|
+
`token_estimate`, `word_count`, `recommended_chunk_level`, and
|
|
50
|
+
`abstract_for_rag` are computed from the body with the H1 in place.
|
|
51
|
+
PDF, DOCX, and TXT output is unchanged.
|
|
52
|
+
- **HTML/URL heading levels skipped below the H1.** Many CMS templates
|
|
53
|
+
mark body sections as H3 or H4 directly under the page H1, so the
|
|
54
|
+
restored H1 surfaced `validator: heading level skip h1 -> h3` (34 of
|
|
55
|
+
the 320 zenity posts above). Headings are now re-ranked by section
|
|
56
|
+
depth below the H1: siblings stay siblings, nesting is kept, and only
|
|
57
|
+
the `#` prefix changes (`H3, H3, H4, H3` becomes `H2, H2, H3, H2`).
|
|
58
|
+
Bodies without skips are byte-identical. All 320 posts now convert
|
|
59
|
+
with 0 validator warnings.
|
|
60
|
+
|
|
61
|
+
### Added
|
|
62
|
+
|
|
63
|
+
- **`seo_title`** frontmatter extension field (HTML/URL only). Holds
|
|
64
|
+
trafilatura's metadata title (`og:title` / `<title>`) when it differs
|
|
65
|
+
from `title`. Omitted when the two match.
|
|
66
|
+
|
|
67
|
+
### Changed
|
|
68
|
+
|
|
69
|
+
- **CI pins ruff to 0.15.22.** Ruff 0.16 and later enable a wider default
|
|
70
|
+
rule set this repo has not adopted, which failed the required lint check
|
|
71
|
+
on an unchanged `main`. The `dev` extra pins the same version.
|
|
72
|
+
- **Release workflow pins `pypa/gh-action-pypi-publish` v1.14.3.** The
|
|
73
|
+
previous pin bundled twine 6.1, which rejects the Metadata-Version 2.5
|
|
74
|
+
that current hatchling writes, so uploads failed before reaching the
|
|
75
|
+
index. The validate step now pins `build==1.6.1` and `twine==7.0.0`.
|
|
76
|
+
- **HTML/URL `title` follows the visible page heading.** `title` is
|
|
77
|
+
still derived from the body H1, and the body H1 is now the page's
|
|
78
|
+
`<h1>`. On pages whose `og:title` differs from their `<h1>`, `title`
|
|
79
|
+
changes from the SEO title to the visible heading. The previous value
|
|
80
|
+
moves to `seo_title`. Pin a title with `--meta title=...` if you
|
|
81
|
+
depend on the old value.
|
|
82
|
+
|
|
83
|
+
## [1.1.1] — 2026-05-07
|
|
84
|
+
|
|
85
|
+
Security patch release. Closes 10 findings from a comprehensive scan
|
|
86
|
+
(bandit, snyk-code, gitleaks, pip-audit, trivy, manual review) plus
|
|
87
|
+
a 5-round adversarial review of the implementation plan.
|
|
88
|
+
|
|
89
|
+
### Security
|
|
90
|
+
|
|
91
|
+
- **SSRF intra-hop DNS-rebind**: `safe_fetch` now resolves the
|
|
92
|
+
hostname once, validates the IP against an allowlist, and passes
|
|
93
|
+
the validated IP through to `socket.create_connection` via a
|
|
94
|
+
custom `HTTPSConnection`. Defeats attacks that return a public IP
|
|
95
|
+
to a validator and a private IP (e.g., `169.254.169.254`) to the
|
|
96
|
+
connector. `ProxyHandler({})` explicitly suppresses
|
|
97
|
+
`HTTP_PROXY`/`HTTPS_PROXY` env vars that would otherwise route
|
|
98
|
+
traffic past the validator.
|
|
99
|
+
- **IP allowlist**: switched from a four-flag denylist
|
|
100
|
+
(`is_private | is_reserved | is_loopback | is_link_local`) to an
|
|
101
|
+
allowlist (`is_global and not is_multicast and not is_unspecified`).
|
|
102
|
+
**Breaking** for users on multicast / CGNAT / IETF-reserved
|
|
103
|
+
networks fetching same-network resources; pre-fetch with `curl`
|
|
104
|
+
and pass `--from-file` as a workaround.
|
|
105
|
+
- **Frontmatter override-key validation**: keys must now match
|
|
106
|
+
`^[A-Za-z_][A-Za-z0-9_-]*$`. Defends against newline-injection in
|
|
107
|
+
TOML basic-string keys that produced split YAML lines.
|
|
108
|
+
**Breaking** for TOML configs with quoted keys containing
|
|
109
|
+
whitespace or punctuation.
|
|
110
|
+
- **URL credentials and sensitive query parameters** in `userinfo`
|
|
111
|
+
and matching `(?i)(token|api[_-]?key|access[_-]?token|bearer|signature|sig|password|secret|aws[_-]?session[_-]?token)$`
|
|
112
|
+
are scrubbed before any logging, persistence, or filename
|
|
113
|
+
derivation. IPv6 host brackets preserved across the scrub.
|
|
114
|
+
- **Docling image saves** use `O_EXCL|O_NOFOLLOW` to refuse symlink
|
|
115
|
+
TOCTOU.
|
|
116
|
+
- **`atomic_write_text`** tightens the symlink-check window via
|
|
117
|
+
`os.lstat` immediately before `os.replace`. Parent-dir hardening
|
|
118
|
+
deferred to v1.2 per the operator-trusted threat model.
|
|
119
|
+
- **Stderr/stdout sanitizer** extended with Unicode bidi
|
|
120
|
+
(U+202A–E, U+2066–9), zero-width chars (U+200B–F, U+FEFF), and
|
|
121
|
+
the BOM. New `any2md._logging` module; `_sanitize_log_text`
|
|
122
|
+
re-exported from `any2md.converters.docx` for one release.
|
|
123
|
+
- **`publish.yml` workflow**: top-level `permissions: contents: read`;
|
|
124
|
+
both publish jobs flip to `attestations: true` for PEP 740 sigstore
|
|
125
|
+
provenance.
|
|
126
|
+
|
|
127
|
+
### Fixed
|
|
128
|
+
|
|
129
|
+
- `wikipedia.org` heuristic host check no longer matches
|
|
130
|
+
`evilwikipedia.org`. Pure correctness fix; impact was incorrect
|
|
131
|
+
text formatting only, not a security boundary.
|
|
132
|
+
- `_USER_AGENT` no longer hardcoded to `any2md/1.0.6`; derives from
|
|
133
|
+
`__version__`.
|
|
134
|
+
|
|
135
|
+
### Changed
|
|
136
|
+
|
|
137
|
+
- `requirements.txt` is now a generated artifact; canonical source
|
|
138
|
+
is `.devcontainer/requirements.lock` with hashes
|
|
139
|
+
(`--require-hashes` enforced in `.devcontainer/devcontainer.json`).
|
|
140
|
+
See `CONTRIBUTING.md` for the dep-update workflow.
|
|
141
|
+
- `load_toml()` now warns to stderr on parse error rather than
|
|
142
|
+
silently returning `{}`.
|
|
143
|
+
- `.devcontainer/` is now tracked in git (was previously ignored)
|
|
144
|
+
so the security-critical Codespace bootstrap config is
|
|
145
|
+
version-controlled.
|
|
146
|
+
|
|
7
147
|
## [1.1.0] — 2026-05-04
|
|
8
148
|
|
|
9
149
|
Performance and library-mode hygiene release.
|
|
@@ -29,7 +29,7 @@ the test suite gates Docling-dependent tests with
|
|
|
29
29
|
`@pytest.mark.skipif(not has_docling(), …)` so they're transparently skipped
|
|
30
30
|
without it. Coverage of fallback paths is unaffected.
|
|
31
31
|
|
|
32
|
-
Python 3.
|
|
32
|
+
Python 3.11 or later is required.
|
|
33
33
|
|
|
34
34
|
## Running tests
|
|
35
35
|
|
|
@@ -131,6 +131,24 @@ The flow:
|
|
|
131
131
|
Don't tag and release directly without the PR — CI gates exist for a
|
|
132
132
|
reason.
|
|
133
133
|
|
|
134
|
+
## Updating dependencies
|
|
135
|
+
|
|
136
|
+
Dependencies are declared in `pyproject.toml` and locked into both:
|
|
137
|
+
|
|
138
|
+
- `.devcontainer/requirements.lock` — hash-locked, used by Codespace `postCreateCommand` (`pip install --require-hashes`).
|
|
139
|
+
- `requirements.txt` — non-hash form, kept for backwards compatibility with `pip install -r requirements.txt` consumers.
|
|
140
|
+
|
|
141
|
+
To add or update a dep:
|
|
142
|
+
|
|
143
|
+
1. Edit `pyproject.toml` `[project] dependencies` (or the relevant `[project.optional-dependencies]` group).
|
|
144
|
+
2. Regenerate both lockfiles in a venv with `pip-tools` installed:
|
|
145
|
+
|
|
146
|
+
pip-compile pyproject.toml --generate-hashes --resolver=backtracking \
|
|
147
|
+
-o .devcontainer/requirements.lock
|
|
148
|
+
pip-compile pyproject.toml --resolver=backtracking -o requirements.txt
|
|
149
|
+
|
|
150
|
+
3. Commit all three files together.
|
|
151
|
+
|
|
134
152
|
## Pull request process
|
|
135
153
|
|
|
136
154
|
- Branch off `main` (or off a phase branch like `v1.0` if one is open for
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: any2md
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
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
|
|
@@ -11,13 +11,13 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
11
11
|
Classifier: Operating System :: OS Independent
|
|
12
12
|
Classifier: Programming Language :: Python :: 3
|
|
13
13
|
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
14
|
-
Requires-Python: >=3.
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
15
|
Requires-Dist: beautifulsoup4<5,>=4.12.0
|
|
16
16
|
Requires-Dist: defusedxml<1,>=0.7.1
|
|
17
17
|
Requires-Dist: lxml<7,>=6.1.0
|
|
18
18
|
Requires-Dist: mammoth<2,>=1.6.0
|
|
19
19
|
Requires-Dist: markdownify<2,>=0.13.0
|
|
20
|
-
Requires-Dist: pillow<13
|
|
20
|
+
Requires-Dist: pillow<14,>=13.0.0
|
|
21
21
|
Requires-Dist: pymupdf4llm<1,>=0.0.17
|
|
22
22
|
Requires-Dist: pymupdf<2,>=1.24.0
|
|
23
23
|
Requires-Dist: trafilatura<3,>=1.12.0
|
|
@@ -27,7 +27,7 @@ Requires-Dist: pytest-snapshot>=0.9.0; extra == 'dev'
|
|
|
27
27
|
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
28
28
|
Requires-Dist: pyyaml>=6.0; extra == 'dev'
|
|
29
29
|
Requires-Dist: reportlab>=4.0.0; extra == 'dev'
|
|
30
|
-
Requires-Dist: ruff
|
|
30
|
+
Requires-Dist: ruff==0.15.22; extra == 'dev'
|
|
31
31
|
Provides-Extra: high-fidelity
|
|
32
32
|
Requires-Dist: docling>=2.0.0; extra == 'high-fidelity'
|
|
33
33
|
Description-Content-Type: text/markdown
|
|
@@ -81,3 +81,25 @@ If you operate any2md in a hardened environment:
|
|
|
81
81
|
|
|
82
82
|
No publicly disclosed vulnerabilities to date. Future advisories will
|
|
83
83
|
appear at https://github.com/rocklambros/any2md/security/advisories.
|
|
84
|
+
|
|
85
|
+
## Trust boundaries
|
|
86
|
+
|
|
87
|
+
`any2md` is an operator-trusted CLI distributed via PyPI for desktop and CI use.
|
|
88
|
+
|
|
89
|
+
**Trusted:** the operator, their filesystem, their `--output-dir`, and the Python interpreter they invoke. CLI flags and `--meta key=val` arguments are operator-controlled.
|
|
90
|
+
|
|
91
|
+
**Untrusted:**
|
|
92
|
+
- URLs passed to `any2md` (may resolve to internal IPs, redirect, or carry credentials in userinfo/query).
|
|
93
|
+
- Documents (PDF/DOCX/HTML) passed for conversion (may embed crafted images, control characters, malicious metadata).
|
|
94
|
+
- `.any2md.toml` config files discovered via cwd-walking (an attacker who can drop a file in any ancestor directory can inject frontmatter overrides).
|
|
95
|
+
- HTTP responses from URLs (may carry hostile redirects, oversized bodies, control characters in headers).
|
|
96
|
+
|
|
97
|
+
**Out of scope:** multi-tenant service hosting; running `any2md` against attacker-supplied output directories; supply-chain compromise of pinned dependencies; protection against an operator who has chosen `--output-dir` pointing at a shared/symlinked directory.
|
|
98
|
+
|
|
99
|
+
## Dependency-drift policy
|
|
100
|
+
|
|
101
|
+
If `pip-audit` reports a NEW HIGH/CRITICAL vulnerability between baseline and release-cut, the policy is:
|
|
102
|
+
|
|
103
|
+
1. Bump the dep within its compatible range (regenerate `.devcontainer/requirements.lock` and `requirements.txt`) and re-cut the rc.
|
|
104
|
+
2. If no compatible fix exists, document in CHANGELOG `## Known issues` and ship only if the codepath is mitigated by other code (e.g., XXE blocked by `defusedxml`).
|
|
105
|
+
3. If neither is possible, hold the release until upstream lands a fix.
|
|
@@ -271,7 +271,9 @@ class ConverterCache:
|
|
|
271
271
|
|
|
272
272
|
if is_quiet() or not sys.stderr.isatty():
|
|
273
273
|
return True
|
|
274
|
-
print(
|
|
274
|
+
print(
|
|
275
|
+
" Loading Docling models (one-time)...", file=sys.stderr
|
|
276
|
+
) # log-safe: literal
|
|
275
277
|
return True
|
|
276
278
|
|
|
277
279
|
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""SSRF-safe HTTP fetching for any2md.
|
|
2
|
+
|
|
3
|
+
Defenses against:
|
|
4
|
+
- non-http(s) schemes
|
|
5
|
+
- URLs resolving to non-public addresses (private, multicast, CGNAT, etc.)
|
|
6
|
+
- DNS rebinding via redirect (per-hop revalidation)
|
|
7
|
+
- DNS rebinding INTRA-hop (IP pinned through to socket.create_connection,
|
|
8
|
+
so the IP we validated is the IP we connect to)
|
|
9
|
+
- Environment-proxy bypass (ProxyHandler({}) suppresses HTTP_PROXY/HTTPS_PROXY)
|
|
10
|
+
- Decompression / huge-response DoS (20 MB body cap)
|
|
11
|
+
- Redirect loops / excessive hops (max 3)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import http.client
|
|
17
|
+
import ipaddress
|
|
18
|
+
import socket
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.parse
|
|
21
|
+
import urllib.request
|
|
22
|
+
|
|
23
|
+
from any2md import __version__
|
|
24
|
+
|
|
25
|
+
_MAX_REDIRECT_HOPS = 3
|
|
26
|
+
_FETCH_TIMEOUT = 15 # seconds
|
|
27
|
+
_MAX_RESPONSE_BYTES = 20 * 1024 * 1024 # 20 MB cap on body read
|
|
28
|
+
_USER_AGENT = f"any2md/{__version__}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _addr_allowed(addr) -> bool:
|
|
32
|
+
"""Allowlist policy: globally routable, non-multicast, non-unspecified."""
|
|
33
|
+
return bool(addr.is_global) and not addr.is_multicast and not addr.is_unspecified
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_and_validate(hostname: str) -> tuple[list[str], str | None]:
|
|
37
|
+
"""One-shot DNS resolution + IP-class validation.
|
|
38
|
+
|
|
39
|
+
Returns ``(ips, None)`` on success or ``([], error)`` on rejection.
|
|
40
|
+
Deny-on-any: if ANY resolved address fails policy, the whole hostname
|
|
41
|
+
is rejected (defends against multi-record split-result attacks).
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
infos = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
|
|
45
|
+
except socket.gaierror:
|
|
46
|
+
return [], f"cannot resolve host: {hostname}"
|
|
47
|
+
ips: list[str] = []
|
|
48
|
+
for *_, sockaddr in infos:
|
|
49
|
+
ip_str = sockaddr[0]
|
|
50
|
+
try:
|
|
51
|
+
addr = ipaddress.ip_address(ip_str)
|
|
52
|
+
except ValueError:
|
|
53
|
+
continue
|
|
54
|
+
if not _addr_allowed(addr):
|
|
55
|
+
return [], f"URL resolves to disallowed address: {addr}"
|
|
56
|
+
ips.append(ip_str)
|
|
57
|
+
if not ips:
|
|
58
|
+
return [], f"no usable address for {hostname}"
|
|
59
|
+
return ips, None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def validate_url(url: str) -> str | None:
|
|
63
|
+
"""Validate scheme + hostname + IP class. Returns error or None.
|
|
64
|
+
|
|
65
|
+
One-shot DNS resolution; intra-hop rebind defense lives in
|
|
66
|
+
:func:`safe_fetch` (which pins the connection to the resolved IP).
|
|
67
|
+
"""
|
|
68
|
+
parsed = urllib.parse.urlsplit(url)
|
|
69
|
+
if parsed.scheme not in ("http", "https"):
|
|
70
|
+
return f"unsupported scheme: {parsed.scheme!r}"
|
|
71
|
+
if not parsed.hostname:
|
|
72
|
+
return f"no hostname in URL: {url}"
|
|
73
|
+
_ips, err = _resolve_and_validate(parsed.hostname)
|
|
74
|
+
return err
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _NoFollowRedirect(urllib.request.HTTPRedirectHandler):
|
|
78
|
+
"""Disable urllib's automatic redirect following."""
|
|
79
|
+
|
|
80
|
+
def redirect_request(self, *args, **kwargs): # noqa: ARG002
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _PinnedHTTPSConnection(http.client.HTTPSConnection):
|
|
85
|
+
def __init__(self, host: str, *args, pinned_ip: str, **kwargs):
|
|
86
|
+
super().__init__(host, *args, **kwargs)
|
|
87
|
+
self._pinned_ip = pinned_ip
|
|
88
|
+
|
|
89
|
+
def connect(self):
|
|
90
|
+
sock = socket.create_connection(
|
|
91
|
+
(self._pinned_ip, self.port), timeout=self.timeout
|
|
92
|
+
)
|
|
93
|
+
if self._tunnel_host:
|
|
94
|
+
self.sock = sock
|
|
95
|
+
self._tunnel()
|
|
96
|
+
# SNI + cert validation use the original hostname, NOT the IP
|
|
97
|
+
self.sock = self._context.wrap_socket(sock, server_hostname=self.host)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _PinnedHTTPConnection(http.client.HTTPConnection):
|
|
101
|
+
def __init__(self, host: str, *args, pinned_ip: str, **kwargs):
|
|
102
|
+
super().__init__(host, *args, **kwargs)
|
|
103
|
+
self._pinned_ip = pinned_ip
|
|
104
|
+
|
|
105
|
+
def connect(self):
|
|
106
|
+
self.sock = socket.create_connection(
|
|
107
|
+
(self._pinned_ip, self.port), timeout=self.timeout
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _build_pinned_opener(pinned_ip: str, scheme: str) -> urllib.request.OpenerDirector:
|
|
112
|
+
"""Build an opener that:
|
|
113
|
+
- suppresses HTTP_PROXY/HTTPS_PROXY env (ProxyHandler({}))
|
|
114
|
+
- disables urllib's auto-redirect (we walk hops manually)
|
|
115
|
+
- pins HTTP/S connections to the validated IP
|
|
116
|
+
"""
|
|
117
|
+
proxy_handler = urllib.request.ProxyHandler({})
|
|
118
|
+
no_redirect = _NoFollowRedirect()
|
|
119
|
+
|
|
120
|
+
if scheme == "https":
|
|
121
|
+
|
|
122
|
+
class _Handler(urllib.request.HTTPSHandler):
|
|
123
|
+
def https_open(self, req): # noqa: D401
|
|
124
|
+
return self.do_open(
|
|
125
|
+
lambda host, **kw: _PinnedHTTPSConnection(
|
|
126
|
+
host, pinned_ip=pinned_ip, **kw
|
|
127
|
+
),
|
|
128
|
+
req,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
pinned_handler = _Handler()
|
|
132
|
+
else:
|
|
133
|
+
|
|
134
|
+
class _Handler(urllib.request.HTTPHandler):
|
|
135
|
+
def http_open(self, req): # noqa: D401
|
|
136
|
+
return self.do_open(
|
|
137
|
+
lambda host, **kw: _PinnedHTTPConnection(
|
|
138
|
+
host, pinned_ip=pinned_ip, **kw
|
|
139
|
+
),
|
|
140
|
+
req,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
pinned_handler = _Handler()
|
|
144
|
+
|
|
145
|
+
return urllib.request.build_opener(proxy_handler, no_redirect, pinned_handler)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def safe_fetch(
|
|
149
|
+
url: str,
|
|
150
|
+
*,
|
|
151
|
+
method: str = "GET",
|
|
152
|
+
max_hops: int = _MAX_REDIRECT_HOPS,
|
|
153
|
+
) -> tuple[bytes | None, dict | None, str | None]:
|
|
154
|
+
"""Fetch ``url`` with manual redirect walking + per-hop revalidation
|
|
155
|
+
+ IP-pinned connection.
|
|
156
|
+
|
|
157
|
+
Returns ``(body, headers, None)`` on 2xx, or ``(None, None, error)``
|
|
158
|
+
on rejection / failure.
|
|
159
|
+
"""
|
|
160
|
+
visited: list[str] = []
|
|
161
|
+
current = url
|
|
162
|
+
for hop in range(max_hops + 1):
|
|
163
|
+
if current in visited:
|
|
164
|
+
return None, None, "redirect loop"
|
|
165
|
+
visited.append(current)
|
|
166
|
+
|
|
167
|
+
parsed = urllib.parse.urlsplit(current)
|
|
168
|
+
if parsed.scheme not in ("http", "https"):
|
|
169
|
+
return None, None, f"unsupported scheme: {parsed.scheme!r}"
|
|
170
|
+
if not parsed.hostname:
|
|
171
|
+
return None, None, f"no hostname in URL: {current}"
|
|
172
|
+
|
|
173
|
+
ips, err = _resolve_and_validate(parsed.hostname)
|
|
174
|
+
if err:
|
|
175
|
+
return None, None, err
|
|
176
|
+
|
|
177
|
+
opener = _build_pinned_opener(ips[0], parsed.scheme)
|
|
178
|
+
req = urllib.request.Request(
|
|
179
|
+
current, method=method, headers={"User-Agent": _USER_AGENT}
|
|
180
|
+
)
|
|
181
|
+
try:
|
|
182
|
+
resp = opener.open(req, timeout=_FETCH_TIMEOUT)
|
|
183
|
+
except urllib.error.HTTPError as e:
|
|
184
|
+
if e.code in (301, 302, 303, 307, 308):
|
|
185
|
+
if hop >= max_hops:
|
|
186
|
+
return None, None, f"too many redirects (>{max_hops})"
|
|
187
|
+
location = e.headers.get("Location") if e.headers else None
|
|
188
|
+
if not location:
|
|
189
|
+
return None, None, f"HTTP {e.code} without Location"
|
|
190
|
+
current = urllib.parse.urljoin(current, location)
|
|
191
|
+
continue
|
|
192
|
+
return None, None, f"HTTP {e.code}"
|
|
193
|
+
except (urllib.error.URLError, OSError, TimeoutError) as e:
|
|
194
|
+
return None, None, f"fetch error: {e}"
|
|
195
|
+
body = resp.read(_MAX_RESPONSE_BYTES + 1)
|
|
196
|
+
if len(body) > _MAX_RESPONSE_BYTES:
|
|
197
|
+
return None, None, f"response exceeds {_MAX_RESPONSE_BYTES} bytes"
|
|
198
|
+
return body, dict(resp.headers), None
|
|
199
|
+
return None, None, f"too many redirects (>{max_hops})"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Stderr/stdout sanitizer + structured-prefix log helpers.
|
|
2
|
+
|
|
3
|
+
Centralizes control-character + Unicode-spoof stripping for any string
|
|
4
|
+
interpolated from untrusted input (filenames, URLs, exception text,
|
|
5
|
+
HTTP response data). Provides ``warn`` / ``fail`` helpers that wrap
|
|
6
|
+
``sys.stderr.write`` with a fixed two-space indent + uppercase prefix.
|
|
7
|
+
|
|
8
|
+
Threat model: attacker-supplied tokens reach a terminal via stderr.
|
|
9
|
+
We strip C0/C1 control chars (incl. ANSI escapes), Unicode bidi
|
|
10
|
+
embeds/overrides/isolates, zero-width chars, and the BOM. Newlines
|
|
11
|
+
are preserved by ``_sanitize_log_text`` (multi-line tracebacks remain
|
|
12
|
+
readable) and stripped by ``safe_oneline`` (single-line interpolation).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
_LOG_CONTROL_CHARS_RE = re.compile(
|
|
21
|
+
r"["
|
|
22
|
+
r"\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f" # C0 / C1 control chars
|
|
23
|
+
r"-" # bidi embeds + override
|
|
24
|
+
r"-" # bidi isolates
|
|
25
|
+
r"-" # zero-width + LRM/RLM
|
|
26
|
+
r"" # BOM
|
|
27
|
+
r"]"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_NEWLINE_CR_RE = re.compile(r"[\r\n]")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _sanitize_log_text(s: str) -> str:
|
|
34
|
+
"""Strip control chars + Unicode bidi/zero-width. Preserves newlines."""
|
|
35
|
+
return _LOG_CONTROL_CHARS_RE.sub("", s)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def safe_oneline(s: str) -> str:
|
|
39
|
+
"""Sanitize and collapse all newlines/CRs to single spaces.
|
|
40
|
+
|
|
41
|
+
Use for single-line interpolation of untrusted tokens (filenames,
|
|
42
|
+
URLs, ``str(exception)``) into log messages.
|
|
43
|
+
"""
|
|
44
|
+
return _NEWLINE_CR_RE.sub(" ", _sanitize_log_text(s))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def warn(msg: str, *, prefix: str = "WARN") -> None:
|
|
48
|
+
"""Write ``f' {prefix}: {sanitized}\\n'`` to stderr.
|
|
49
|
+
|
|
50
|
+
Applies ``safe_oneline`` to ``msg`` so embedded newlines/CRs in
|
|
51
|
+
interpolated values (filenames, exception text, URL components)
|
|
52
|
+
cannot forge additional log lines. The output is a single line.
|
|
53
|
+
For multi-line output (e.g., a full traceback), call
|
|
54
|
+
``sys.stderr.write`` directly with ``_sanitize_log_text``.
|
|
55
|
+
"""
|
|
56
|
+
sys.stderr.write(f" {prefix}: {safe_oneline(msg)}\n")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def fail(msg: str, *, prefix: str = "FAIL") -> None:
|
|
60
|
+
"""Same as :func:`warn` with ``prefix='FAIL'``."""
|
|
61
|
+
warn(msg, prefix=prefix)
|
|
@@ -6,6 +6,7 @@ import time
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
|
+
from any2md import _logging
|
|
9
10
|
from any2md.config import (
|
|
10
11
|
discover_config,
|
|
11
12
|
extract_document_id_settings,
|
|
@@ -236,7 +237,9 @@ def main():
|
|
|
236
237
|
|
|
237
238
|
if args.meta_file is not None:
|
|
238
239
|
if not args.meta_file.is_file():
|
|
239
|
-
|
|
240
|
+
sys.stderr.write(
|
|
241
|
+
f"Error: --meta-file not found: {_logging.safe_oneline(str(args.meta_file))}\n"
|
|
242
|
+
)
|
|
240
243
|
sys.exit(1)
|
|
241
244
|
cfg = load_toml(args.meta_file)
|
|
242
245
|
overrides = _deep_merge_overrides(overrides, extract_meta_overrides(cfg))
|
|
@@ -252,7 +255,7 @@ def main():
|
|
|
252
255
|
try:
|
|
253
256
|
cli_meta = parse_meta_args(args.meta)
|
|
254
257
|
except ValueError as e:
|
|
255
|
-
|
|
258
|
+
sys.stderr.write(f"Error: {_logging.safe_oneline(str(e))}\n")
|
|
256
259
|
sys.exit(1)
|
|
257
260
|
overrides = _deep_merge_overrides(overrides, cli_meta)
|
|
258
261
|
overrides = filter_reserved_overrides(
|
|
@@ -319,7 +322,7 @@ def main():
|
|
|
319
322
|
if not p.is_absolute():
|
|
320
323
|
p = Path.cwd() / p
|
|
321
324
|
if not p.exists():
|
|
322
|
-
|
|
325
|
+
_logging.fail(f"{f}", prefix="NOT FOUND")
|
|
323
326
|
continue
|
|
324
327
|
if p.is_dir():
|
|
325
328
|
glob_method = p.rglob if args.recursive else p.glob
|
|
@@ -332,12 +335,14 @@ def main():
|
|
|
332
335
|
)
|
|
333
336
|
continue
|
|
334
337
|
if p.suffix.lower() not in SUPPORTED_EXTENSIONS:
|
|
335
|
-
|
|
338
|
+
_logging.fail(f"{f}", prefix="UNSUPPORTED FORMAT")
|
|
336
339
|
continue
|
|
337
340
|
file_paths.append(p)
|
|
338
341
|
elif args.input_dir:
|
|
339
342
|
if not args.input_dir.is_dir():
|
|
340
|
-
|
|
343
|
+
sys.stderr.write(
|
|
344
|
+
f"Error: not a directory: {_logging.safe_oneline(str(args.input_dir))}\n"
|
|
345
|
+
)
|
|
341
346
|
sys.exit(1)
|
|
342
347
|
glob_method = args.input_dir.rglob if args.recursive else args.input_dir.glob
|
|
343
348
|
file_paths = sorted(
|
|
@@ -393,7 +398,7 @@ def main():
|
|
|
393
398
|
try:
|
|
394
399
|
file_size = file_path.stat().st_size
|
|
395
400
|
except OSError as e:
|
|
396
|
-
|
|
401
|
+
_logging.fail(f"{file_path.name} -- {e}")
|
|
397
402
|
fail += 1
|
|
398
403
|
continue
|
|
399
404
|
|
|
@@ -5,25 +5,16 @@ is treated as frontmatter overrides (deep-merged below ``--meta-file``
|
|
|
5
5
|
and CLI ``--meta`` arguments). The ``[document_id]`` table provides
|
|
6
6
|
``publisher_prefix`` / ``type_code`` overrides for ``--auto-id``.
|
|
7
7
|
|
|
8
|
-
TOML parsing uses stdlib ``tomllib``
|
|
9
|
-
interpreters we fall back to the optional ``tomli`` package; if neither
|
|
10
|
-
is importable, ``load_toml`` returns ``{}`` and config is silently
|
|
11
|
-
disabled (no hard error — config is opt-in convenience).
|
|
8
|
+
TOML parsing uses stdlib ``tomllib`` (any2md requires Python 3.11+).
|
|
12
9
|
"""
|
|
13
10
|
|
|
14
11
|
from __future__ import annotations
|
|
15
12
|
|
|
16
|
-
import
|
|
13
|
+
import tomllib
|
|
17
14
|
from pathlib import Path
|
|
18
15
|
from typing import Any
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
import tomllib
|
|
22
|
-
else: # pragma: no cover - exercised only on Python 3.10
|
|
23
|
-
try:
|
|
24
|
-
import tomli as tomllib # type: ignore[import-not-found,no-redef]
|
|
25
|
-
except ImportError:
|
|
26
|
-
tomllib = None # type: ignore[assignment]
|
|
17
|
+
from any2md import _logging
|
|
27
18
|
|
|
28
19
|
|
|
29
20
|
def load_toml(path: Path) -> dict[str, Any]:
|
|
@@ -33,12 +24,13 @@ def load_toml(path: Path) -> dict[str, Any]:
|
|
|
33
24
|
not block conversion. Callers that need strict validation should
|
|
34
25
|
parse the file themselves.
|
|
35
26
|
"""
|
|
36
|
-
if tomllib is None: # pragma: no cover - 3.10 without tomli
|
|
37
|
-
return {}
|
|
38
27
|
try:
|
|
39
28
|
with open(path, "rb") as f:
|
|
40
29
|
return tomllib.load(f)
|
|
41
|
-
except
|
|
30
|
+
except FileNotFoundError:
|
|
31
|
+
return {} # silent — discovery walks ancestors and most won't have one
|
|
32
|
+
except (OSError, ValueError) as e:
|
|
33
|
+
_logging.warn(f"failed to parse {path}: {e}")
|
|
42
34
|
return {}
|
|
43
35
|
|
|
44
36
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Converter dispatcher for any2md."""
|
|
2
2
|
|
|
3
|
-
import sys
|
|
4
3
|
from pathlib import Path
|
|
5
4
|
|
|
5
|
+
from any2md import _logging
|
|
6
6
|
from any2md.pipeline import PipelineOptions
|
|
7
7
|
|
|
8
8
|
SUPPORTED_EXTENSIONS = {".pdf", ".docx", ".html", ".htm", ".txt"}
|
|
@@ -84,5 +84,5 @@ def convert_file(
|
|
|
84
84
|
from any2md.converters.txt import convert_txt
|
|
85
85
|
|
|
86
86
|
return convert_txt(file_path, output_dir, options=options, force=force)
|
|
87
|
-
|
|
87
|
+
_logging.fail(f"{file_path.name} (no converter for {ext})", prefix="UNSUPPORTED")
|
|
88
88
|
return False
|