Skip to main content

html_to_markdown_rs/options/
conversion.rs

1//! Main conversion options with builder pattern.
2
3use crate::options::preprocessing::PreprocessingOptions;
4use crate::options::validation::{
5    CodeBlockStyle, HeadingStyle, HighlightStyle, LinkStyle, ListIndentType, NewlineStyle, OutputFormat,
6    UrlEscapeStyle, WhitespaceMode,
7};
8
9/// Controls which conversion tier is used.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11#[cfg_attr(
12    any(feature = "serde", feature = "metadata"),
13    derive(serde::Serialize, serde::Deserialize)
14)]
15#[cfg_attr(any(feature = "serde", feature = "metadata"), serde(rename_all = "snake_case"))]
16pub enum TierStrategy {
17    /// Automatically pick the best tier for the input (default).
18    ///
19    /// Runs the classifier against the prescan report and uses Tier-1 when
20    /// eligible; falls back to Tier-2 on bail or when the classifier routes
21    /// to Tier-2.
22    #[default]
23    Auto,
24    /// Always use the Tier-2 (`tl::parse` + walk) path, skipping Tier-1.
25    Tier2,
26    /// Force the Tier-1 byte scanner; if it bails, fall back to Tier-2.
27    /// Testkit-only; not stable API.
28    #[cfg(any(test, feature = "testkit"))]
29    Tier1,
30}
31
32/// Main conversion options for HTML to Markdown conversion.
33///
34/// Use [`ConversionOptions::builder()`] to construct, or [`Default::default()`] for defaults.
35///
36/// # Example
37///
38/// ```rust
39/// use html_to_markdown_rs::{ConversionOptions, HeadingStyle};
40///
41/// let options = ConversionOptions::builder()
42///     .heading_style(HeadingStyle::Atx)
43///     .wrap(true)
44///     .wrap_width(100)
45///     .build();
46/// ```
47#[derive(Debug, Clone)]
48#[cfg_attr(
49    any(feature = "serde", feature = "metadata"),
50    derive(serde::Serialize, serde::Deserialize)
51)]
52#[cfg_attr(any(feature = "serde", feature = "metadata"), serde(default, deny_unknown_fields))]
53pub struct ConversionOptions {
54    /// Heading style to use in Markdown output (ATX `#` or Setext underline).
55    pub heading_style: HeadingStyle,
56    /// How to indent nested list items (spaces or tab).
57    pub list_indent_type: ListIndentType,
58    /// Number of spaces (or tabs) to use for each level of list indentation.
59    pub list_indent_width: usize,
60    /// Bullet character(s) to use for unordered list items (e.g. `"-"`, `"*"`).
61    pub bullets: String,
62    /// Character used for bold/italic emphasis markers (`*` or `_`).
63    pub strong_em_symbol: char,
64    /// Escape `*` characters in plain text to avoid unintended bold/italic.
65    pub escape_asterisks: bool,
66    /// Escape `_` characters in plain text to avoid unintended bold/italic.
67    pub escape_underscores: bool,
68    /// Escape miscellaneous Markdown metacharacters (`[]()#` etc.) in plain text.
69    pub escape_misc: bool,
70    /// Escape ASCII characters that have special meaning in certain Markdown dialects.
71    pub escape_ascii: bool,
72    /// Default language annotation for fenced code blocks that have no language hint.
73    pub code_language: String,
74    /// Automatically convert bare URLs into Markdown autolinks.
75    pub autolinks: bool,
76    /// Emit a default title when no `<title>` tag is present.
77    pub default_title: bool,
78    /// Render `<br>` elements inside table cells as literal line breaks.
79    pub br_in_tables: bool,
80    /// Emit tables without column padding (compact GFM format).
81    ///
82    /// When `true`, column widths are not computed and cells are emitted with
83    /// no trailing spaces. Separator rows use exactly `---` per column.
84    /// Produces token-efficient output suitable for RAG / LLM contexts.
85    ///
86    /// Default `false` (aligned padding preserved).
87    pub compact_tables: bool,
88    /// Style used for `<mark>` / highlighted text (e.g. `==text==`).
89    pub highlight_style: HighlightStyle,
90    /// Populate `result.metadata` with `<head>` / `<meta>` extraction
91    /// (title, description, Open Graph, Twitter Card, JSON-LD, …).
92    ///
93    /// Default `true`. Disabling skips the metadata pass only — table
94    /// extraction into `result.tables` runs unconditionally.
95    pub extract_metadata: bool,
96    /// Controls how whitespace sequences are normalised in the converted output.
97    ///
98    /// - [`WhitespaceMode::Normalized`] (default) — collapses consecutive whitespace characters
99    ///   (spaces, tabs, newlines) to a single space, matching browser rendering behaviour.
100    /// - [`WhitespaceMode::Strict`] — preserves all whitespace exactly as it appears in the
101    ///   source HTML, including runs of spaces and embedded newlines.
102    ///
103    /// Choose `Strict` only when the source HTML uses deliberate whitespace (e.g. pre-formatted
104    /// content outside `<pre>` tags). For most documents `Normalized` produces cleaner output.
105    pub whitespace_mode: WhitespaceMode,
106    /// Strip all newlines from the output, producing a single-line result.
107    pub strip_newlines: bool,
108    /// Wrap long lines at [`wrap_width`](Self::wrap_width) characters.
109    pub wrap: bool,
110    /// Maximum output line width in characters when [`wrap`](Self::wrap) is `true` (default `80`).
111    ///
112    /// Lines are broken at word boundaries so that no line exceeds this length. A value of `0`
113    /// is treated as "no limit" — equivalent to leaving [`wrap`](Self::wrap) disabled. Has no
114    /// effect when `wrap` is `false`.
115    pub wrap_width: usize,
116    /// Treat the entire document as inline content (no block-level wrappers).
117    pub convert_as_inline: bool,
118    /// Markdown notation for subscript text (e.g. `"~"`).
119    pub sub_symbol: String,
120    /// Markdown notation for superscript text (e.g. `"^"`).
121    pub sup_symbol: String,
122    /// How to encode hard line breaks (`<br>`) in Markdown.
123    pub newline_style: NewlineStyle,
124    /// Style used for fenced code blocks (backticks or tilde).
125    pub code_block_style: CodeBlockStyle,
126    /// HTML tag names whose `<img>` children are kept inline instead of block.
127    pub keep_inline_images_in: Vec<String>,
128    /// Options for the HTML pre-processing pass applied before conversion begins.
129    ///
130    /// Pre-processing runs before the HTML is handed to the converter and can perform operations
131    /// such as unwrapping redundant wrapper elements, removing tracking pixels, and normalising
132    /// vendor-specific markup. See [`PreprocessingOptions`] for the full set of knobs.
133    ///
134    /// Defaults to [`PreprocessingOptions::default()`], which enables the standard cleaning
135    /// passes. Set individual fields on [`PreprocessingOptions`] (or construct via
136    /// [`ConversionOptions::builder`]) to opt in or out of specific passes.
137    pub preprocessing: PreprocessingOptions,
138    /// Expected character encoding of the input HTML (default `"utf-8"`).
139    pub encoding: String,
140    /// Emit debug information during conversion.
141    pub debug: bool,
142    /// HTML tag names whose content is stripped from the output entirely.
143    pub strip_tags: Vec<String>,
144    /// HTML tag names that are preserved verbatim in the output.
145    pub preserve_tags: Vec<String>,
146    /// Skip conversion of `<img>` elements (omit images from output).
147    pub skip_images: bool,
148    /// URL encoding strategy for link and image destinations.
149    ///
150    /// Controls how special characters in URL destinations are escaped:
151    /// - [`UrlEscapeStyle::Angle`] (default) — wraps the destination in angle brackets when it
152    ///   contains spaces or newlines. Some parsers misinterpret `>` inside such a destination.
153    /// - [`UrlEscapeStyle::Percent`] — percent-encodes every character that is not an RFC 3986
154    ///   unreserved character or `/`, producing a destination that all Markdown parsers handle
155    ///   correctly even when the URL contains `<`, `>`, spaces, or parentheses.
156    pub url_escape_style: UrlEscapeStyle,
157    /// Link rendering style (inline or reference).
158    pub link_style: LinkStyle,
159    /// Target output format (Markdown, plain text, etc.).
160    pub output_format: OutputFormat,
161    /// Include structured document tree in result.
162    pub include_document_structure: bool,
163    /// Extract inline images from data URIs and SVGs.
164    pub extract_images: bool,
165    /// Maximum decoded image size in bytes (default 5MB).
166    pub max_image_size: u64,
167    /// Capture SVG elements as images.
168    pub capture_svg: bool,
169    /// Infer image dimensions from data.
170    pub infer_dimensions: bool,
171    /// Maximum DOM traversal depth. `None` means unlimited.
172    /// When set, subtrees beyond this depth are silently truncated.
173    pub max_depth: Option<usize>,
174    /// CSS selectors for elements to exclude entirely (element + all content).
175    ///
176    /// Unlike `strip_tags` (which removes the tag wrapper but keeps children),
177    /// excluded elements and all their descendants are dropped from the output.
178    /// Supports any CSS selector that `tl` supports: tag names, `.class`,
179    /// `#id`, `[attribute]`, etc.
180    ///
181    /// Invalid selectors are silently skipped at conversion time.
182    ///
183    /// Example: `vec![".cookie-banner".into(), "#ad-container".into(), "[role='complementary']".into()]`
184    #[cfg_attr(any(feature = "serde", feature = "metadata"), serde(default))]
185    pub exclude_selectors: Vec<String>,
186
187    /// Which conversion tier to use.
188    ///
189    /// - [`TierStrategy::Auto`] (default) — automatically choose the best path.
190    /// - [`TierStrategy::Tier2`] — always use the Tier-2 DOM-walk path.
191    /// - `TierStrategy::Tier1` — always attempt Tier-1 (testkit only).
192    #[cfg_attr(any(feature = "serde", feature = "metadata"), serde(default))]
193    pub tier_strategy: TierStrategy,
194
195    /// Optional visitor for custom traversal logic.
196    ///
197    /// When set, the visitor's callbacks are invoked for matching HTML elements
198    /// during conversion, allowing custom output, skipping, or HTML preservation.
199    /// See [`crate::visitor::HtmlVisitor`].
200    #[cfg(feature = "visitor")]
201    #[cfg_attr(any(feature = "serde", feature = "metadata"), serde(skip))]
202    pub visitor: Option<crate::visitor::VisitorHandle>,
203}
204
205impl Default for ConversionOptions {
206    fn default() -> Self {
207        Self {
208            heading_style: HeadingStyle::default(),
209            list_indent_type: ListIndentType::default(),
210            list_indent_width: 2,
211            bullets: "-*+".to_string(),
212            strong_em_symbol: '*',
213            escape_asterisks: false,
214            escape_underscores: false,
215            escape_misc: false,
216            escape_ascii: false,
217            code_language: String::new(),
218            autolinks: true,
219            default_title: false,
220            br_in_tables: false,
221            compact_tables: false,
222            highlight_style: HighlightStyle::default(),
223            extract_metadata: true,
224            whitespace_mode: WhitespaceMode::default(),
225            strip_newlines: false,
226            wrap: false,
227            wrap_width: 80,
228            convert_as_inline: false,
229            sub_symbol: String::new(),
230            sup_symbol: String::new(),
231            newline_style: NewlineStyle::Spaces,
232            code_block_style: CodeBlockStyle::default(),
233            keep_inline_images_in: Vec::new(),
234            preprocessing: PreprocessingOptions::default(),
235            encoding: "utf-8".to_string(),
236            debug: false,
237            strip_tags: Vec::new(),
238            preserve_tags: Vec::new(),
239            skip_images: false,
240            url_escape_style: UrlEscapeStyle::default(),
241            link_style: LinkStyle::default(),
242            output_format: OutputFormat::default(),
243            include_document_structure: false,
244            extract_images: false,
245            max_image_size: 5_242_880,
246            capture_svg: false,
247            infer_dimensions: true,
248            max_depth: None,
249            exclude_selectors: Vec::new(),
250            tier_strategy: TierStrategy::Auto,
251            #[cfg(feature = "visitor")]
252            visitor: None,
253        }
254    }
255}
256
257impl ConversionOptions {
258    /// Create a [`ConversionOptionsBuilder`] pre-populated with default values.
259    ///
260    /// All fields start at their documented defaults. Use the setter methods on the returned
261    /// builder to override individual fields, then call [`ConversionOptionsBuilder::build`] to
262    /// produce the final [`ConversionOptions`].
263    ///
264    /// No fields are required — calling `.build()` immediately yields a valid options struct
265    /// identical to [`ConversionOptions::default()`].
266    ///
267    /// # Examples
268    ///
269    /// ```rust
270    /// use html_to_markdown_rs::{ConversionOptions, options::validation::{HeadingStyle, WhitespaceMode}};
271    ///
272    /// let options = ConversionOptions::builder()
273    ///     .heading_style(HeadingStyle::AtxClosed)
274    ///     .wrap(true)
275    ///     .wrap_width(100)
276    ///     .whitespace_mode(WhitespaceMode::Normalized)
277    ///     .build();
278    ///
279    /// assert_eq!(options.wrap_width, 100);
280    /// ```
281    #[must_use]
282    #[cfg_attr(alef, alef(skip))]
283    pub fn builder() -> ConversionOptionsBuilder {
284        ConversionOptionsBuilder(Self::default())
285    }
286}
287
288// ── Builder ─────────────────────────────────────────────────────────────────
289
290/// Builder for [`ConversionOptions`].
291///
292/// All fields start with default values. Call `.build()` to produce the final options.
293#[derive(Debug, Clone)]
294#[cfg_attr(alef, alef(skip))]
295pub struct ConversionOptionsBuilder(ConversionOptions);
296
297macro_rules! builder_setter {
298    ($name:ident, $ty:ty) => {
299        /// Set the value.
300        #[must_use]
301        pub const fn $name(mut self, value: $ty) -> Self {
302            self.0.$name = value;
303            self
304        }
305    };
306}
307
308macro_rules! builder_setter_into {
309    ($name:ident, $ty:ty) => {
310        /// Set the value.
311        #[must_use]
312        pub fn $name(mut self, value: impl Into<$ty>) -> Self {
313            self.0.$name = value.into();
314            self
315        }
316    };
317}
318
319#[cfg_attr(alef, alef(skip))]
320impl ConversionOptionsBuilder {
321    // Output control
322    builder_setter!(output_format, OutputFormat);
323    builder_setter!(include_document_structure, bool);
324    builder_setter!(extract_metadata, bool);
325    builder_setter!(extract_images, bool);
326
327    // Markdown formatting
328    builder_setter!(heading_style, HeadingStyle);
329    builder_setter!(list_indent_type, ListIndentType);
330    builder_setter!(list_indent_width, usize);
331    builder_setter_into!(bullets, String);
332    builder_setter!(strong_em_symbol, char);
333    builder_setter!(code_block_style, CodeBlockStyle);
334    builder_setter!(newline_style, NewlineStyle);
335    builder_setter!(highlight_style, HighlightStyle);
336    builder_setter_into!(code_language, String);
337    builder_setter!(link_style, LinkStyle);
338    builder_setter!(autolinks, bool);
339    builder_setter!(default_title, bool);
340    builder_setter!(br_in_tables, bool);
341    builder_setter!(compact_tables, bool);
342    builder_setter_into!(sub_symbol, String);
343    builder_setter_into!(sup_symbol, String);
344
345    // Escaping
346    builder_setter!(escape_asterisks, bool);
347    builder_setter!(escape_underscores, bool);
348    builder_setter!(escape_misc, bool);
349    builder_setter!(escape_ascii, bool);
350
351    // Whitespace / wrapping
352    builder_setter!(whitespace_mode, WhitespaceMode);
353    builder_setter!(strip_newlines, bool);
354    builder_setter!(wrap, bool);
355    builder_setter!(wrap_width, usize);
356
357    // Element handling
358    builder_setter!(convert_as_inline, bool);
359    builder_setter!(skip_images, bool);
360    builder_setter!(url_escape_style, UrlEscapeStyle);
361
362    /// Set the list of HTML tag names whose content is stripped from output.
363    #[must_use]
364    pub fn strip_tags(mut self, tags: Vec<String>) -> Self {
365        self.0.strip_tags = tags;
366        self
367    }
368
369    /// Set the list of HTML tag names that are preserved verbatim in output.
370    #[must_use]
371    pub fn preserve_tags(mut self, tags: Vec<String>) -> Self {
372        self.0.preserve_tags = tags;
373        self
374    }
375
376    /// Set the list of HTML tag names whose `<img>` children are kept inline.
377    #[must_use]
378    pub fn keep_inline_images_in(mut self, tags: Vec<String>) -> Self {
379        self.0.keep_inline_images_in = tags;
380        self
381    }
382
383    // Image extraction config
384    builder_setter!(max_image_size, u64);
385    builder_setter!(capture_svg, bool);
386    builder_setter!(infer_dimensions, bool);
387    builder_setter!(max_depth, Option<usize>);
388
389    /// Set the list of CSS selectors for elements to exclude entirely from output.
390    #[must_use]
391    pub fn exclude_selectors(mut self, selectors: Vec<String>) -> Self {
392        self.0.exclude_selectors = selectors;
393        self
394    }
395
396    /// Set the visitor used during conversion.
397    #[cfg(feature = "visitor")]
398    #[must_use]
399    pub fn visitor(mut self, visitor: Option<crate::visitor::VisitorHandle>) -> Self {
400        self.0.visitor = visitor;
401        self
402    }
403
404    // Preprocessing
405    /// Set the pre-processing options applied to the HTML before conversion.
406    #[must_use]
407    pub const fn preprocessing(mut self, preprocessing: PreprocessingOptions) -> Self {
408        self.0.preprocessing = preprocessing;
409        self
410    }
411
412    // Encoding
413    builder_setter_into!(encoding, String);
414
415    // Debug
416    builder_setter!(debug, bool);
417
418    // Tier strategy
419    builder_setter!(tier_strategy, TierStrategy);
420
421    /// Build the final [`ConversionOptions`].
422    #[must_use]
423    pub fn build(self) -> ConversionOptions {
424        self.0
425    }
426}
427
428// ── ConversionOptionsUpdate (for binding crate compatibility) ────────────
429
430use crate::options::preprocessing::PreprocessingOptionsUpdate;
431
432/// Partial update for `ConversionOptions`.
433///
434/// Uses `Option<T>` fields for selective updates. Bindings use this to construct
435/// options from language-native types. Prefer [`ConversionOptionsBuilder`] for Rust code.
436#[derive(Debug, Clone, Default)]
437#[cfg_attr(
438    any(feature = "serde", feature = "metadata"),
439    derive(serde::Serialize, serde::Deserialize)
440)]
441#[cfg_attr(any(feature = "serde", feature = "metadata"), serde(deny_unknown_fields))]
442pub struct ConversionOptionsUpdate {
443    /// Optional override for [`ConversionOptions::heading_style`].
444    pub heading_style: Option<HeadingStyle>,
445    /// Optional override for [`ConversionOptions::list_indent_type`].
446    pub list_indent_type: Option<ListIndentType>,
447    /// Optional override for [`ConversionOptions::list_indent_width`].
448    pub list_indent_width: Option<usize>,
449    /// Optional override for [`ConversionOptions::bullets`].
450    pub bullets: Option<String>,
451    /// Optional override for [`ConversionOptions::strong_em_symbol`].
452    pub strong_em_symbol: Option<char>,
453    /// Optional override for [`ConversionOptions::escape_asterisks`].
454    pub escape_asterisks: Option<bool>,
455    /// Optional override for [`ConversionOptions::escape_underscores`].
456    pub escape_underscores: Option<bool>,
457    /// Optional override for [`ConversionOptions::escape_misc`].
458    pub escape_misc: Option<bool>,
459    /// Optional override for [`ConversionOptions::escape_ascii`].
460    pub escape_ascii: Option<bool>,
461    /// Optional override for [`ConversionOptions::code_language`].
462    pub code_language: Option<String>,
463    /// Optional override for [`ConversionOptions::autolinks`].
464    pub autolinks: Option<bool>,
465    /// Optional override for [`ConversionOptions::default_title`].
466    pub default_title: Option<bool>,
467    /// Optional override for [`ConversionOptions::br_in_tables`].
468    pub br_in_tables: Option<bool>,
469    /// Optional override for [`ConversionOptions::compact_tables`].
470    pub compact_tables: Option<bool>,
471    /// Optional override for [`ConversionOptions::highlight_style`].
472    pub highlight_style: Option<HighlightStyle>,
473    /// Optional override for [`ConversionOptions::extract_metadata`].
474    pub extract_metadata: Option<bool>,
475    /// Optional override for [`ConversionOptions::whitespace_mode`].
476    pub whitespace_mode: Option<WhitespaceMode>,
477    /// Optional override for [`ConversionOptions::strip_newlines`].
478    pub strip_newlines: Option<bool>,
479    /// Optional override for [`ConversionOptions::wrap`].
480    pub wrap: Option<bool>,
481    /// Optional override for [`ConversionOptions::wrap_width`].
482    pub wrap_width: Option<usize>,
483    /// Optional override for [`ConversionOptions::convert_as_inline`].
484    pub convert_as_inline: Option<bool>,
485    /// Optional override for [`ConversionOptions::sub_symbol`].
486    pub sub_symbol: Option<String>,
487    /// Optional override for [`ConversionOptions::sup_symbol`].
488    pub sup_symbol: Option<String>,
489    /// Optional override for [`ConversionOptions::newline_style`].
490    pub newline_style: Option<NewlineStyle>,
491    /// Optional override for [`ConversionOptions::code_block_style`].
492    pub code_block_style: Option<CodeBlockStyle>,
493    /// Optional override for [`ConversionOptions::keep_inline_images_in`].
494    pub keep_inline_images_in: Option<Vec<String>>,
495    /// Optional override for [`ConversionOptions::preprocessing`].
496    pub preprocessing: Option<PreprocessingOptionsUpdate>,
497    /// Optional override for [`ConversionOptions::encoding`].
498    pub encoding: Option<String>,
499    /// Optional override for [`ConversionOptions::debug`].
500    pub debug: Option<bool>,
501    /// Optional override for [`ConversionOptions::strip_tags`].
502    pub strip_tags: Option<Vec<String>>,
503    /// Optional override for [`ConversionOptions::preserve_tags`].
504    pub preserve_tags: Option<Vec<String>>,
505    /// Optional override for [`ConversionOptions::skip_images`].
506    pub skip_images: Option<bool>,
507    /// Optional override for [`ConversionOptions::url_escape_style`].
508    pub url_escape_style: Option<UrlEscapeStyle>,
509    /// Optional override for [`ConversionOptions::link_style`].
510    pub link_style: Option<LinkStyle>,
511    /// Optional override for [`ConversionOptions::output_format`].
512    pub output_format: Option<OutputFormat>,
513    /// Optional override for [`ConversionOptions::include_document_structure`].
514    pub include_document_structure: Option<bool>,
515    /// Optional override for [`ConversionOptions::extract_images`].
516    pub extract_images: Option<bool>,
517    /// Optional override for [`ConversionOptions::max_image_size`].
518    pub max_image_size: Option<u64>,
519    /// Optional override for [`ConversionOptions::capture_svg`].
520    pub capture_svg: Option<bool>,
521    /// Optional override for [`ConversionOptions::infer_dimensions`].
522    pub infer_dimensions: Option<bool>,
523    /// Optional override for [`ConversionOptions::max_depth`].
524    pub max_depth: Option<Option<usize>>,
525    /// Optional override for [`ConversionOptions::exclude_selectors`].
526    pub exclude_selectors: Option<Vec<String>>,
527    /// Optional override for [`ConversionOptions::tier_strategy`].
528    pub tier_strategy: Option<TierStrategy>,
529    /// Optional override for [`ConversionOptions::visitor`].
530    #[cfg(feature = "visitor")]
531    #[cfg_attr(any(feature = "serde", feature = "metadata"), serde(skip))]
532    pub visitor: Option<crate::visitor::VisitorHandle>,
533}
534
535impl ConversionOptions {
536    /// Apply a partial update to these conversion options.
537    #[cfg_attr(alef, alef(skip))]
538    pub fn apply_update(&mut self, update: ConversionOptionsUpdate) {
539        macro_rules! apply {
540            ($field:ident) => {
541                if let Some(v) = update.$field {
542                    self.$field = v;
543                }
544            };
545        }
546        apply!(heading_style);
547        apply!(list_indent_type);
548        apply!(list_indent_width);
549        apply!(bullets);
550        apply!(strong_em_symbol);
551        apply!(escape_asterisks);
552        apply!(escape_underscores);
553        apply!(escape_misc);
554        apply!(escape_ascii);
555        apply!(code_language);
556        apply!(autolinks);
557        apply!(default_title);
558        apply!(br_in_tables);
559        apply!(compact_tables);
560        apply!(highlight_style);
561        apply!(extract_metadata);
562        apply!(whitespace_mode);
563        apply!(strip_newlines);
564        apply!(wrap);
565        apply!(wrap_width);
566        apply!(convert_as_inline);
567        apply!(sub_symbol);
568        apply!(sup_symbol);
569        apply!(newline_style);
570        apply!(code_block_style);
571        apply!(keep_inline_images_in);
572        apply!(encoding);
573        apply!(debug);
574        apply!(strip_tags);
575        apply!(preserve_tags);
576        apply!(skip_images);
577        apply!(url_escape_style);
578        apply!(link_style);
579        apply!(output_format);
580        apply!(include_document_structure);
581        apply!(extract_images);
582        apply!(max_image_size);
583        apply!(capture_svg);
584        apply!(infer_dimensions);
585        apply!(max_depth);
586        apply!(exclude_selectors);
587        apply!(tier_strategy);
588        #[cfg(feature = "visitor")]
589        if let Some(visitor) = update.visitor {
590            self.visitor = Some(visitor);
591        }
592        if let Some(preprocessing) = update.preprocessing {
593            self.preprocessing.apply_update(preprocessing);
594        }
595    }
596
597    /// Create from a partial update, applying to defaults.
598    #[must_use]
599    #[cfg_attr(alef, alef(skip))]
600    pub fn from_update(update: ConversionOptionsUpdate) -> Self {
601        let mut options = Self::default();
602        options.apply_update(update);
603        options
604    }
605}
606
607impl From<ConversionOptionsUpdate> for ConversionOptions {
608    fn from(update: ConversionOptionsUpdate) -> Self {
609        Self::from_update(update)
610    }
611}
612
613// ── Tests ───────────────────────────────────────────────────────────────────
614
615#[cfg(all(test, any(feature = "serde", feature = "metadata")))]
616mod tests {
617    use super::*;
618
619    #[test]
620    fn test_conversion_options_serde() {
621        let options = ConversionOptions::builder()
622            .heading_style(HeadingStyle::AtxClosed)
623            .list_indent_width(4)
624            .bullets("*")
625            .escape_asterisks(true)
626            .whitespace_mode(WhitespaceMode::Strict)
627            .build();
628
629        let json = serde_json::to_string(&options).expect("Failed to serialize");
630        let deserialized: ConversionOptions = serde_json::from_str(&json).expect("Failed to deserialize");
631
632        assert_eq!(deserialized.list_indent_width, 4);
633        assert_eq!(deserialized.bullets, "*");
634        assert!(deserialized.escape_asterisks);
635        assert_eq!(deserialized.heading_style, HeadingStyle::AtxClosed);
636        assert_eq!(deserialized.whitespace_mode, WhitespaceMode::Strict);
637    }
638
639    #[test]
640    fn test_conversion_options_partial_deserialization() {
641        let partial_json = r#"{
642            "heading_style": "atxclosed",
643            "list_indent_width": 4,
644            "bullets": "*"
645        }"#;
646
647        let deserialized: ConversionOptions =
648            serde_json::from_str(partial_json).expect("Failed to deserialize partial JSON");
649
650        assert_eq!(deserialized.heading_style, HeadingStyle::AtxClosed);
651        assert_eq!(deserialized.list_indent_width, 4);
652        assert_eq!(deserialized.bullets, "*");
653        assert!(!deserialized.escape_asterisks);
654        assert!(!deserialized.escape_underscores);
655        assert_eq!(deserialized.list_indent_type, ListIndentType::Spaces);
656    }
657
658    #[test]
659    fn test_builder_pattern() {
660        let options = ConversionOptions::builder()
661            .heading_style(HeadingStyle::Underlined)
662            .wrap(true)
663            .wrap_width(100)
664            .include_document_structure(true)
665            .extract_images(true)
666            .build();
667
668        assert_eq!(options.heading_style, HeadingStyle::Underlined);
669        assert!(options.wrap);
670        assert_eq!(options.wrap_width, 100);
671        assert!(options.include_document_structure);
672        assert!(options.extract_images);
673    }
674}