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}