Skip to main content

html_to_markdown_rs/
lib.rs

1// ============================================================================
2// Crate-wide lint configuration
3//
4// Lints suppressed here fire in many places due to structural constraints of the
5// DOM traversal pipeline and cannot be easily fixed without API breakage or
6// significant complexity increase. Each allow is justified below.
7// ============================================================================
8
9// reason: converter handler functions take (node_handle, tag, parser, output, options,
10// ctx, depth, dom_ctx) — 8 arguments minimum imposed by the DOM traversal API.
11// Reducing to fewer arguments would require threading a single "Context" mega-struct
12// through everything, which would make call sites less explicit, not clearer.
13#![allow(clippy::too_many_arguments)]
14// reason: many converter functions are long because they handle all HTML variants of a tag
15// (e.g., code_block handles <pre>, <code>, <samp>, language attrs, visitor callbacks,
16// metadata collection, and feature-gated paths) — splitting would obscure the logic.
17#![allow(clippy::too_many_lines)]
18// reason: DOM API uses tl::NodeHandle (4-byte Copy) passed as &NodeHandle throughout
19// because that is the established calling convention from the tl parser crate.
20// Changing to pass-by-value would require updating hundreds of call sites and
21// would break the parallel with html5ever's API which also uses handles by reference.
22#![allow(clippy::trivially_copy_pass_by_ref)]
23// reason: ConversionOptions and related config structs use bool fields to represent
24// independent toggles. Using bitflags or enums would add complexity for no readability gain
25// since each flag has a distinct semantic meaning.
26#![allow(clippy::struct_excessive_bools, clippy::fn_params_excessive_bools)]
27// reason: usize→u32 casts occur when converting DOM-internal counts to protocol/metadata
28// fields. On 32-bit targets usize == u32 so no truncation; on 64-bit targets the
29// values are bounded by HTML document sizes which fit in u32. A TryFrom would add
30// error handling for a condition that cannot occur with valid HTML.
31#![allow(clippy::cast_possible_truncation)]
32// reason: float ratio computations in validation.rs use usize→f64 casts to compute
33// proportions. The precision loss is intentional and acceptable for threshold comparisons.
34// Cast sign loss is impossible because usize is unsigned.
35#![allow(clippy::cast_precision_loss, clippy::cast_sign_loss)]
36// reason: several methods need &self for trait conformance or future extensibility
37// even though they don't access self fields currently.
38#![allow(clippy::unused_self)]
39// reason: collapsible_if and collapsible_match fire extensively in the DOM scanner
40// where the nested structure mirrors the hierarchical HTML grammar and readability
41// is better preserved by keeping the nesting explicit.
42#![allow(clippy::collapsible_if, clippy::collapsible_else_if, clippy::collapsible_match)]
43// reason: these idiom lints (map_or, let..else, option_if_let_else, match_same_arms)
44// fire in 80+ places throughout the converter. Applying them would change many
45// correct patterns without improving correctness; they are style preferences only.
46#![allow(
47    clippy::map_unwrap_or,
48    clippy::manual_let_else,
49    clippy::option_if_let_else,
50    clippy::match_same_arms,
51    clippy::branches_sharing_code,
52    clippy::items_after_statements,
53    clippy::match_wildcard_for_single_variants,
54    clippy::needless_pass_by_value
55)]
56// reason: doc_markdown fires on HTML element names and CSS identifiers in doc comments
57// (e.g., "CommonMark", "data-*", "aria-label") that clippy considers should be in backticks.
58// The majority of these are in internal modules without public doc coverage requirements.
59#![allow(clippy::doc_markdown, clippy::missing_errors_doc)]
60// reason: default_trait_access (Default::default() vs Foo::default()) is a style preference;
61// the explicit Default::default() form is clearer at call sites with multiple defaults.
62#![allow(clippy::default_trait_access)]
63
64//! High-performance HTML to Markdown converter.
65//!
66//! Built with html5ever for fast, memory-efficient HTML parsing.
67//!
68//! ## Optional inline image extraction
69//!
70//! Enable the `inline-images` Cargo feature to collect embedded data URI images and inline SVG
71//! assets alongside the produced Markdown.
72
73// ============================================================================
74// Module Declarations
75// ============================================================================
76
77pub mod error;
78#[cfg(feature = "metadata")]
79pub mod metadata;
80pub mod options;
81pub mod types;
82#[cfg(feature = "visitor")]
83pub mod visitor;
84
85// Internal modules (not part of public API)
86mod convert_api;
87// reason: converter is a large pub(crate) module; some sub-items are only used when
88// specific feature flags are active. The allow silences cross-feature dead_code noise
89// that would otherwise require fine-grained #[cfg] gates on every internal helper.
90#[allow(dead_code)]
91pub(crate) mod converter;
92mod exports;
93
94// Re-export internal test/benchmark modules when the testkit feature is active.
95// This lets integration tests and the bench harness access prescan and tier1
96// without making them part of the stable public API.
97//
98// We use a pub mod alias so tests can use both the short path (`crate::prescan`)
99// and the original path (`crate::converter::prescan`) via the re-export below.
100#[cfg(any(test, feature = "testkit"))]
101// reason: re-exports are gated on test/testkit; `use` items inside may not all be
102// consumed by every test file, producing unused_imports warnings in some configurations.
103#[allow(unused_imports)]
104/// Re-exports of internal modules for integration tests and the bench harness.
105pub mod testkit {
106    pub use crate::converter::prescan;
107    pub use crate::converter::tier1;
108}
109#[cfg(any(test, feature = "testkit"))]
110pub use converter::prescan;
111#[cfg(any(test, feature = "testkit"))]
112pub use converter::tier1;
113#[cfg(feature = "inline-images")]
114mod inline_images;
115pub(crate) mod prelude;
116mod rcdom;
117pub(crate) mod text;
118mod validation;
119#[cfg(feature = "visitor")]
120// reason: visitor_helpers exposes functions that accept Option<&X> parameters
121// where &Option<X> (ref_option) would be the clippy preferred form; using Option<&X>
122// matches the calling convention for the visitor dispatch API at every call site.
123#[allow(clippy::ref_option)]
124pub(crate) mod visitor_helpers;
125pub(crate) mod wrapper;
126
127// ============================================================================
128// Public Re-exports (from exports module)
129// ============================================================================
130
131pub use exports::*;
132pub use types::{
133    AnnotationKind, ConversionResult, DocumentNode, DocumentStructure, GridCell, ImageDimensions, MetadataEntry,
134    NodeContent, ProcessingWarning, TableData, TableGrid, TextAnnotation, WarningKind,
135};
136#[cfg(feature = "visitor")]
137pub use visitor::{NodeContext, NodeType, VisitResult, VisitorHandle};
138
139// ============================================================================
140// Main Public API Functions
141// ============================================================================
142
143pub use convert_api::convert;
144
145#[cfg(feature = "mcp")]
146pub mod mcp;
147
148// Tests
149// ============================================================================
150
151#[cfg(test)]
152mod basic_tests {
153    use super::*;
154
155    #[test]
156    fn test_binary_input_rejected() {
157        let html = format!("abc{}def", "\0".repeat(20));
158        let result = convert(&html, None);
159        assert!(matches!(result, Err(ConversionError::InvalidInput(_))));
160    }
161
162    #[test]
163    fn test_binary_magic_rejected() {
164        let html = "%PDF-1.7";
165        let result = convert(html, None);
166        assert!(matches!(result, Err(ConversionError::InvalidInput(_))));
167    }
168
169    #[test]
170    fn test_utf16_hint_recovered() {
171        let html = String::from_utf8_lossy(b"\xFF\xFE<\0h\0t\0m\0l\0>\0").to_string();
172        let result = convert(&html, None);
173        assert!(result.is_ok(), "UTF-16 input should be recovered instead of rejected");
174    }
175
176    #[test]
177    fn test_plain_text_allowed() {
178        let result = convert("Just text", None).unwrap();
179        let content = result.content.unwrap_or_default();
180        assert!(content.contains("Just text"));
181    }
182
183    #[test]
184    fn test_plain_text_escaped_when_enabled() {
185        let options = ConversionOptions {
186            escape_asterisks: true,
187            escape_underscores: true,
188            ..ConversionOptions::default()
189        };
190        let result = convert("Text *asterisks* _underscores_", Some(options)).unwrap();
191        let content = result.content.unwrap_or_default();
192        assert!(content.contains(r"\*asterisks\*"));
193        assert!(content.contains(r"\_underscores\_"));
194    }
195}