Skip to main content

document_svg/document/
html.rs

1//! HTML document parser, typography typesetter, and paginated vector SVG renderer.
2//!
3//! Parses HTML markup (headings, paragraphs, blockquotes, lists, tables, pre/code),
4//! performs flow-based line wrapping and page pagination, and renders standard vector SVG pages.
5
6#![allow(clippy::collapsible_if)]
7
8use std::collections::{HashMap, HashSet};
9use std::fs;
10use std::fs::File;
11use std::io::Read;
12use std::path::{Path, PathBuf};
13
14use base64::Engine;
15use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
16
17use quick_xml::Reader;
18use quick_xml::events::Event;
19
20use crate::convert::{ConvertOptions, PageConsumer};
21use crate::error::{Error, Result};
22use crate::ir::{IDENTITY, Node, Page, Paint, SourceMeta, Stroke, TextAnchor, TextRun};
23use crate::ooxml::{attribute, local_name, sniff_image_mime};
24
25const PAGE_WIDTH: f64 = 595.0; // A4 standard width (pt)
26const PAGE_HEIGHT: f64 = 842.0; // A4 standard height (pt)
27const MARGIN_LEFT: f64 = 54.0;
28const MARGIN_TOP: f64 = 54.0;
29const MARGIN_RIGHT: f64 = 54.0;
30const MARGIN_BOTTOM: f64 = 54.0;
31const CONTENT_WIDTH: f64 = PAGE_WIDTH - MARGIN_LEFT - MARGIN_RIGHT;
32const CONTENT_HEIGHT: f64 = PAGE_HEIGHT - MARGIN_TOP - MARGIN_BOTTOM;
33const DEFAULT_MAX_HTML_EVENTS: usize = 5_000_000;
34const MAX_NORMALIZED_HTML_BYTES: usize = 512 * 1024 * 1024;
35const MAX_HTML_IMAGE_REFERENCES: usize = 10_000;
36const MAX_HTML_IMAGE_ELEMENTS: usize = 10_000;
37const MAX_HTML_IMAGE_BYTES: u64 = 8 * 1024 * 1024;
38const MAX_HTML_TOTAL_IMAGE_BYTES: usize = 32 * 1024 * 1024;
39const MAX_HTML_TOTAL_DATA_URI_BYTES: usize = 48 * 1024 * 1024;
40const MAX_HTML_IMAGE_PIXELS: u64 = 40_000_000;
41const MAX_HTML_TOTAL_IMAGE_PIXELS: u64 = 100_000_000;
42
43struct HtmlWarningSink<'a> {
44    inner: &'a mut dyn PageConsumer,
45    warnings: &'a [String],
46}
47
48impl PageConsumer for HtmlWarningSink<'_> {
49    fn consume(&mut self, mut page: Page) -> Result<()> {
50        for warning in self.warnings {
51            page.warn(warning.clone());
52        }
53        self.inner.consume(page)
54    }
55}
56
57#[derive(Clone, Debug)]
58pub enum HtmlBlock {
59    Heading {
60        level: u8,
61        text: String,
62    },
63    Paragraph {
64        text: String,
65    },
66    ListItem {
67        bullet: String,
68        text: String,
69    },
70    StyledText {
71        kind: HtmlTextBlockKind,
72        runs: Vec<HtmlTextRun>,
73    },
74    CodeBlock {
75        text: String,
76    },
77    Image {
78        href: String,
79        pixel_width: u32,
80        pixel_height: u32,
81        alt: String,
82    },
83    Table(crate::table::TableData),
84    PageBreak,
85    HorizontalRule,
86}
87
88#[derive(Clone, Debug, Default, PartialEq)]
89pub struct HtmlTextStyle {
90    pub font_family: Option<String>,
91    pub font_size: Option<f64>,
92    pub bold: Option<bool>,
93    pub italic: Option<bool>,
94    pub color: Option<String>,
95}
96
97#[derive(Clone, Debug, PartialEq)]
98pub struct HtmlTextRun {
99    pub text: String,
100    pub style: HtmlTextStyle,
101}
102
103#[derive(Clone, Debug)]
104pub enum HtmlTextBlockKind {
105    Heading(u8),
106    Paragraph,
107    ListItem { bullet: String },
108}
109
110#[derive(Clone, Debug)]
111pub(crate) struct InlineHtmlImage {
112    pub href: String,
113    pub pixel_width: u32,
114    pub pixel_height: u32,
115}
116
117fn html_attribute(start: &quick_xml::events::BytesStart<'_>, name: &[u8]) -> Option<String> {
118    start
119        .attributes()
120        .with_checks(false)
121        .flatten()
122        .find_map(|item| {
123            if !local_name(item.key.as_ref()).eq_ignore_ascii_case(name) {
124                return None;
125            }
126            let fallback = String::from_utf8_lossy(item.value.as_ref()).into_owned();
127            Some(
128                item.normalized_value(quick_xml::XmlVersion::Implicit1_0)
129                    .map(|value| value.into_owned())
130                    .unwrap_or(fallback),
131            )
132        })
133}
134
135/// Select an image URL from the HTML image attributes. Browsers choose a
136/// `srcset` candidate from viewport density and layout; the SVG preview has no
137/// viewport negotiation, so it deterministically uses the first candidate when
138/// `src` is absent and reports that approximation to callers.
139fn html_image_source(element: &quick_xml::events::BytesStart<'_>) -> Option<(String, bool)> {
140    if let Some(source) = html_attribute(element, b"src")
141        && !source.trim().is_empty()
142    {
143        return Some((source, false));
144    }
145    let srcset = html_attribute(element, b"srcset")?;
146    for candidate in srcset.split(',') {
147        let source = candidate.split_whitespace().next().unwrap_or_default();
148        if !source.is_empty() {
149            return Some((source.to_owned(), true));
150        }
151    }
152    None
153}
154
155#[derive(Default)]
156struct HtmlImageBudget {
157    loaded_bytes: usize,
158    data_uri_bytes: usize,
159    pixels: u64,
160}
161
162pub(crate) fn convert(
163    path: &Path,
164    options: &ConvertOptions,
165    sink: &mut dyn PageConsumer,
166) -> Result<Vec<String>> {
167    let mut file = File::open(path)?;
168    let mut bytes = Vec::new();
169    Read::take(&mut file, options.max_input_bytes.saturating_add(1)).read_to_end(&mut bytes)?;
170    if bytes.len() as u64 > options.max_input_bytes {
171        return Err(Error::LimitExceeded(format!(
172            "HTML input exceeds maximum bytes ({})",
173            options.max_input_bytes
174        )));
175    }
176    let text = String::from_utf8(bytes)
177        .map_err(|e| Error::InvalidInput(format!("HTML file is not valid UTF-8: {e}")))?;
178    let parent = path
179        .parent()
180        .filter(|parent| !parent.as_os_str().is_empty())
181        .unwrap_or_else(|| Path::new("."));
182    let base_dir = fs::canonicalize(parent)?;
183    let normalized_limit = usize::try_from(options.max_input_bytes)
184        .unwrap_or(usize::MAX)
185        .min(MAX_NORMALIZED_HTML_BYTES);
186    let (sources, source_limit_exceeded) = collect_html_image_sources_from_html_with_limit(
187        &text,
188        options.max_xml_events,
189        normalized_limit,
190        MAX_HTML_IMAGE_REFERENCES,
191    )?;
192    let (inline_images, mut warnings) =
193        load_local_image_sources(&base_dir, sources, source_limit_exceeded)?;
194    if !inline_images.is_empty() {
195        push_html_warning_once(
196            &mut warnings,
197            "HTML image layout, CSS sizing, and inline wrapping are approximated as centered flow blocks",
198        );
199    }
200    let (blocks, parser_warnings, _) = parse_html_blocks_with_inline_images(
201        &text,
202        options.max_xml_events,
203        normalized_limit,
204        &inline_images,
205    )?;
206    for warning in parser_warnings {
207        push_html_warning_once(&mut warnings, &warning);
208    }
209    let mut warning_sink = HtmlWarningSink {
210        inner: sink,
211        warnings: &warnings,
212    };
213    render_blocks_to_pages(&blocks, &mut warning_sink, options)?;
214    Ok(warnings)
215}
216
217pub(crate) fn load_local_image_sources(
218    base_dir: &Path,
219    sources: Vec<String>,
220    source_limit_exceeded: bool,
221) -> Result<(HashMap<String, InlineHtmlImage>, Vec<String>)> {
222    let mut warnings = Vec::new();
223    if source_limit_exceeded {
224        push_html_warning_once(
225            &mut warnings,
226            "image references exceeded the supported limit; remaining images were omitted",
227        );
228    }
229    let mut budget = HtmlImageBudget::default();
230    let mut inline_images = HashMap::new();
231    let mut seen_sources = HashSet::new();
232    for source in sources {
233        if !seen_sources.insert(source.clone()) {
234            if let Some(image) = inline_images.get(&source) {
235                reserve_html_image_instance(image, &mut budget, &mut warnings);
236            }
237            continue;
238        }
239        if let Some(image) = load_local_html_image(base_dir, &source, &mut budget, &mut warnings)? {
240            inline_images.insert(source, image);
241        }
242    }
243    Ok((inline_images, warnings))
244}
245
246fn load_local_html_image(
247    base_dir: &Path,
248    source: &str,
249    budget: &mut HtmlImageBudget,
250    warnings: &mut Vec<String>,
251) -> Result<Option<InlineHtmlImage>> {
252    let Some(path) = local_html_image_path(base_dir, source) else {
253        let warning = if source.contains("://") || source.starts_with("//") {
254            "external image resources are not fetched"
255        } else {
256            "image paths outside the input directory or with unsupported URI schemes were omitted"
257        };
258        push_html_warning_once(warnings, warning);
259        return Ok(None);
260    };
261    let metadata = match fs::metadata(&path) {
262        Ok(metadata) if metadata.is_file() => metadata,
263        Ok(_) => {
264            push_html_warning_once(warnings, "non-file image resources were omitted");
265            return Ok(None);
266        }
267        Err(_) => {
268            push_html_warning_once(warnings, "missing local image resources were omitted");
269            return Ok(None);
270        }
271    };
272    if metadata.len() > MAX_HTML_IMAGE_BYTES {
273        push_html_warning_once(
274            warnings,
275            "local image resources exceeding the per-image byte limit were omitted",
276        );
277        return Ok(None);
278    }
279    let mut file = File::open(path)?;
280    let mut bytes = Vec::new();
281    Read::take(&mut file, MAX_HTML_IMAGE_BYTES.saturating_add(1)).read_to_end(&mut bytes)?;
282    if bytes.len() as u64 > MAX_HTML_IMAGE_BYTES {
283        push_html_warning_once(
284            warnings,
285            "local image resources expanded beyond the per-image byte limit and were omitted",
286        );
287        return Ok(None);
288    }
289    let Some(mime) =
290        sniff_image_mime(&bytes).filter(|mime| matches!(*mime, "image/png" | "image/jpeg"))
291    else {
292        push_html_warning_once(
293            warnings,
294            "unsupported local image types were omitted; only PNG and JPEG are embedded",
295        );
296        return Ok(None);
297    };
298    let Some((width, height)) = crate::document::mhtml::image_dimensions(&bytes, mime) else {
299        push_html_warning_once(warnings, "invalid local PNG/JPEG images were omitted");
300        return Ok(None);
301    };
302    let pixels = u64::from(width) * u64::from(height);
303    if width == 0 || height == 0 || pixels > MAX_HTML_IMAGE_PIXELS {
304        push_html_warning_once(
305            warnings,
306            "HTML images exceeding the per-image pixel limit were omitted",
307        );
308        return Ok(None);
309    }
310    let next_loaded_bytes = budget.loaded_bytes.saturating_add(bytes.len());
311    if next_loaded_bytes > MAX_HTML_TOTAL_IMAGE_BYTES {
312        push_html_warning_once(
313            warnings,
314            "HTML images exceeding the total loaded image byte limit were omitted",
315        );
316        return Ok(None);
317    }
318    let prefix = format!("data:{mime};base64,");
319    let uri_bytes = prefix
320        .len()
321        .saturating_add(bytes.len().div_ceil(3).saturating_mul(4));
322    if !reserve_html_image_dimensions(width, height, uri_bytes, budget, warnings) {
323        return Ok(None);
324    }
325    budget.loaded_bytes = next_loaded_bytes;
326    Ok(Some(InlineHtmlImage {
327        href: format!("{prefix}{}", BASE64_STANDARD.encode(&bytes)),
328        pixel_width: width,
329        pixel_height: height,
330    }))
331}
332
333fn reserve_html_image_instance(
334    image: &InlineHtmlImage,
335    budget: &mut HtmlImageBudget,
336    warnings: &mut Vec<String>,
337) -> bool {
338    reserve_html_image_dimensions(
339        image.pixel_width,
340        image.pixel_height,
341        image.href.len(),
342        budget,
343        warnings,
344    )
345}
346
347fn reserve_html_image_dimensions(
348    width: u32,
349    height: u32,
350    uri_bytes: usize,
351    budget: &mut HtmlImageBudget,
352    warnings: &mut Vec<String>,
353) -> bool {
354    let next_pixels = budget
355        .pixels
356        .saturating_add(u64::from(width) * u64::from(height));
357    if next_pixels > MAX_HTML_TOTAL_IMAGE_PIXELS {
358        push_html_warning_once(
359            warnings,
360            "HTML images exceeding the total decoded pixel limit were omitted",
361        );
362        return false;
363    }
364    let next_uri_bytes = budget.data_uri_bytes.saturating_add(uri_bytes);
365    if next_uri_bytes > MAX_HTML_TOTAL_DATA_URI_BYTES {
366        push_html_warning_once(
367            warnings,
368            "HTML images exceeding the total data URI byte limit were omitted",
369        );
370        return false;
371    }
372    budget.pixels = next_pixels;
373    budget.data_uri_bytes = next_uri_bytes;
374    true
375}
376
377fn local_html_image_path(base_dir: &Path, source: &str) -> Option<PathBuf> {
378    crate::local_resource::resolve_relative_file(base_dir, source)
379}
380
381fn hex_nibble(byte: u8) -> Option<u8> {
382    match byte {
383        b'0'..=b'9' => Some(byte - b'0'),
384        b'a'..=b'f' => Some(byte - b'a' + 10),
385        b'A'..=b'F' => Some(byte - b'A' + 10),
386        _ => None,
387    }
388}
389
390fn push_html_warning_once(warnings: &mut Vec<String>, warning: &str) {
391    if !warnings.iter().any(|existing| existing == warning) {
392        warnings.push(warning.to_owned());
393    }
394}
395
396fn is_html_void_tag(name: &[u8]) -> bool {
397    matches!(
398        name,
399        b"area"
400            | b"base"
401            | b"br"
402            | b"col"
403            | b"embed"
404            | b"hr"
405            | b"img"
406            | b"input"
407            | b"link"
408            | b"meta"
409            | b"param"
410            | b"source"
411            | b"track"
412            | b"wbr"
413    )
414}
415
416pub(crate) fn escape_bare_ampersands_limited(html: &str, max_bytes: usize) -> Result<String> {
417    if html.len() > max_bytes {
418        return Err(Error::LimitExceeded(format!(
419            "HTML input exceeds normalized size limit of {max_bytes} bytes"
420        )));
421    }
422    let mut out = String::with_capacity(html.len().min(max_bytes));
423    let mut i = 0;
424    let bytes = html.as_bytes();
425    while i < bytes.len() {
426        if bytes[i] == b'&' {
427            let mut j = i + 1;
428            let mut is_entity = false;
429            if j < bytes.len() && bytes[j] == b'#' {
430                j += 1;
431                if j < bytes.len() && (bytes[j] == b'x' || bytes[j] == b'X') {
432                    j += 1;
433                    let hex_start = j;
434                    while j < bytes.len() && bytes[j].is_ascii_hexdigit() {
435                        j += 1;
436                    }
437                    if j > hex_start && j < bytes.len() && bytes[j] == b';' {
438                        is_entity = true;
439                    }
440                } else {
441                    let dec_start = j;
442                    while j < bytes.len() && bytes[j].is_ascii_digit() {
443                        j += 1;
444                    }
445                    if j > dec_start && j < bytes.len() && bytes[j] == b';' {
446                        is_entity = true;
447                    }
448                }
449            } else {
450                let name_start = j;
451                while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
452                    j += 1;
453                }
454                if j > name_start && j < bytes.len() && bytes[j] == b';' {
455                    is_entity = true;
456                }
457            }
458
459            if is_entity {
460                push_html_bounded(&mut out, "&", max_bytes)?;
461            } else {
462                push_html_bounded(&mut out, "&amp;", max_bytes)?;
463            }
464            i += 1;
465        } else {
466            let Some(character) = html[i..].chars().next() else {
467                break;
468            };
469            let mut encoded = [0u8; 4];
470            push_html_bounded(&mut out, character.encode_utf8(&mut encoded), max_bytes)?;
471            i += character.len_utf8();
472        }
473    }
474    Ok(out)
475}
476
477fn push_html_bounded(output: &mut String, value: &str, max_bytes: usize) -> Result<()> {
478    if output.len().saturating_add(value.len()) > max_bytes {
479        return Err(Error::LimitExceeded(format!(
480            "HTML entity normalization exceeds {max_bytes} bytes"
481        )));
482    }
483    output.push_str(value);
484    Ok(())
485}
486
487pub(crate) fn normalize_html_void_tags_limited(html: &str, max_bytes: usize) -> Result<String> {
488    if html.len() > max_bytes {
489        return Err(Error::LimitExceeded(format!(
490            "HTML input exceeds normalized size limit of {max_bytes} bytes"
491        )));
492    }
493    let bytes = html.as_bytes();
494    let mut output = Vec::with_capacity(html.len().min(max_bytes));
495    let mut cursor = 0usize;
496    while cursor < bytes.len() {
497        if bytes[cursor] != b'<' || cursor + 1 >= bytes.len() {
498            push_html_bytes_bounded(&mut output, &bytes[cursor..cursor + 1], max_bytes)?;
499            cursor += 1;
500            continue;
501        }
502        if bytes[cursor..].starts_with(b"<!--") {
503            let end = find_bytes(&bytes[cursor + 4..], b"-->")
504                .map(|offset| cursor + 4 + offset + 2)
505                .ok_or_else(|| Error::InvalidInput("HTML comment is not terminated".into()))?;
506            push_html_bytes_bounded(&mut output, &bytes[cursor..=end], max_bytes)?;
507            cursor = end + 1;
508            continue;
509        }
510        if bytes[cursor..].starts_with(b"<![CDATA[") {
511            let end = find_bytes(&bytes[cursor + 9..], b"]]>")
512                .map(|offset| cursor + 9 + offset + 2)
513                .ok_or_else(|| Error::InvalidInput("HTML CDATA block is not terminated".into()))?;
514            push_html_bytes_bounded(&mut output, &bytes[cursor..=end], max_bytes)?;
515            cursor = end + 1;
516            continue;
517        }
518        if matches!(bytes[cursor + 1], b'/' | b'!' | b'?') {
519            let Some(end) = find_html_tag_end(bytes, cursor + 2) else {
520                push_html_bytes_bounded(&mut output, &bytes[cursor..], max_bytes)?;
521                break;
522            };
523            push_html_bytes_bounded(&mut output, &bytes[cursor..=end], max_bytes)?;
524            cursor = end + 1;
525            continue;
526        }
527        let name_start = cursor + 1;
528        let mut name_end = name_start;
529        while name_end < bytes.len()
530            && (bytes[name_end].is_ascii_alphanumeric()
531                || matches!(bytes[name_end], b':' | b'-' | b'_'))
532        {
533            name_end += 1;
534        }
535        if name_end == name_start {
536            push_html_bytes_bounded(&mut output, &bytes[cursor..cursor + 1], max_bytes)?;
537            cursor += 1;
538            continue;
539        }
540        let Some(end) = find_html_tag_end(bytes, name_end) else {
541            push_html_bytes_bounded(&mut output, &bytes[cursor..], max_bytes)?;
542            break;
543        };
544        let name = &bytes[name_start..name_end];
545        let is_void = is_html_void_tag(&name.to_ascii_lowercase());
546        let before_close = &bytes[name_end..end];
547        let already_self_closed = before_close
548            .iter()
549            .rev()
550            .find(|byte| !byte.is_ascii_whitespace())
551            == Some(&b'/');
552        push_html_bytes_bounded(&mut output, &bytes[cursor..end], max_bytes)?;
553        if is_void && !already_self_closed {
554            push_html_bytes_bounded(&mut output, b"/", max_bytes)?;
555        }
556        push_html_bytes_bounded(&mut output, b">", max_bytes)?;
557        cursor = end + 1;
558    }
559    String::from_utf8(output)
560        .map_err(|_| Error::InvalidInput("HTML normalization produced invalid UTF-8".into()))
561}
562
563fn push_html_bytes_bounded(output: &mut Vec<u8>, value: &[u8], max_bytes: usize) -> Result<()> {
564    if output.len().saturating_add(value.len()) > max_bytes {
565        return Err(Error::LimitExceeded(format!(
566            "HTML tag normalization exceeds {max_bytes} bytes"
567        )));
568    }
569    output.extend_from_slice(value);
570    Ok(())
571}
572
573fn find_html_tag_end(bytes: &[u8], mut cursor: usize) -> Option<usize> {
574    let mut quote = None;
575    while cursor < bytes.len() {
576        match (quote, bytes[cursor]) {
577            (Some(open), byte) if byte == open => quote = None,
578            (None, b'\'' | b'"') => quote = Some(bytes[cursor]),
579            (None, b'>') => return Some(cursor),
580            _ => {}
581        }
582        cursor += 1;
583    }
584    None
585}
586
587fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
588    haystack
589        .windows(needle.len())
590        .position(|window| window == needle)
591}
592
593fn normalize_cid_reference(source: &str) -> Option<String> {
594    let cid = source
595        .get(..4)
596        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("cid:"))
597        .then(|| &source[4..])?;
598    let cid = cid.trim().trim_matches(['<', '>']);
599    let bytes = cid.as_bytes();
600    let mut decoded = Vec::with_capacity(bytes.len());
601    let mut index = 0usize;
602    while index < bytes.len() {
603        if bytes[index] == b'%'
604            && index + 2 < bytes.len()
605            && let (Some(high), Some(low)) =
606                (hex_nibble(bytes[index + 1]), hex_nibble(bytes[index + 2]))
607        {
608            decoded.push((high << 4) | low);
609            index += 3;
610        } else {
611            decoded.push(bytes[index]);
612            index += 1;
613        }
614    }
615    String::from_utf8(decoded)
616        .ok()
617        .filter(|value| !value.is_empty())
618}
619
620fn append_html_text(target: &mut String, text: &str, in_pre: bool) {
621    if in_pre {
622        target.push_str(text);
623        return;
624    }
625    let starts_with_ws = text.starts_with(|c: char| c.is_whitespace());
626    let ends_with_ws = text.ends_with(|c: char| c.is_whitespace());
627    let words: Vec<&str> = text.split_whitespace().collect();
628    if words.is_empty() {
629        if (starts_with_ws || ends_with_ws)
630            && !target.is_empty()
631            && !target.ends_with(' ')
632            && !target.ends_with('\n')
633        {
634            target.push(' ');
635        }
636        return;
637    }
638    if starts_with_ws && !target.is_empty() && !target.ends_with(' ') && !target.ends_with('\n') {
639        target.push(' ');
640    }
641    for (idx, word) in words.iter().enumerate() {
642        if idx > 0 && !target.ends_with(' ') && !target.ends_with('\n') {
643            target.push(' ');
644        }
645        target.push_str(word);
646    }
647    if ends_with_ws && !target.ends_with(' ') && !target.ends_with('\n') {
648        target.push(' ');
649    }
650}
651
652pub fn parse_html_blocks(html: &str) -> Result<Vec<HtmlBlock>> {
653    parse_html_blocks_with_limit(html, DEFAULT_MAX_HTML_EVENTS)
654}
655
656pub(crate) fn parse_html_blocks_with_limit(
657    html: &str,
658    max_events: usize,
659) -> Result<Vec<HtmlBlock>> {
660    parse_html_blocks_with_limits(html, max_events, MAX_NORMALIZED_HTML_BYTES)
661}
662
663pub(crate) fn parse_html_blocks_with_limits(
664    html: &str,
665    max_events: usize,
666    max_normalized_bytes: usize,
667) -> Result<Vec<HtmlBlock>> {
668    parse_html_blocks_with_inline_images(html, max_events, max_normalized_bytes, &HashMap::new())
669        .map(|(blocks, _, _)| blocks)
670}
671
672pub(crate) fn collect_html_image_sources_with_limit(
673    html: &str,
674    max_events: usize,
675    max_sources: usize,
676) -> Result<(Vec<String>, bool)> {
677    let mut reader = Reader::from_str(html);
678    let mut buffer = Vec::new();
679    let mut sources = Vec::new();
680    let mut events = 0usize;
681    let mut exceeded_limit = false;
682    loop {
683        events = events.saturating_add(1);
684        if events > max_events {
685            return Err(Error::LimitExceeded(format!(
686                "HTML image source scan exceeds {max_events} parser events"
687            )));
688        }
689        match reader.read_event_into(&mut buffer)? {
690            Event::Start(ref element) | Event::Empty(ref element)
691                if local_name(element.name().as_ref()).eq_ignore_ascii_case(b"img") =>
692            {
693                if let Some((source, _)) = html_image_source(element)
694                    && !source.trim().is_empty()
695                {
696                    if sources.len() < max_sources {
697                        sources.push(source);
698                    } else {
699                        exceeded_limit = true;
700                    }
701                }
702            }
703            Event::Eof => break,
704            _ => {}
705        }
706        buffer.clear();
707    }
708    Ok((sources, exceeded_limit))
709}
710
711pub(crate) fn first_html_base_href(
712    html: &str,
713    max_events: usize,
714    max_normalized_bytes: usize,
715) -> Result<Option<String>> {
716    let normalized = normalize_html_void_tags_limited(html, max_normalized_bytes)?;
717    let normalized = escape_bare_ampersands_limited(&normalized, max_normalized_bytes)?;
718    let mut reader = Reader::from_str(&normalized);
719    let mut buffer = Vec::new();
720    let mut before_body = true;
721    let mut head_closed = false;
722    let mut template_depth = 0usize;
723    let mut events = 0usize;
724    loop {
725        events = events.saturating_add(1);
726        if events > max_events {
727            return Err(Error::LimitExceeded(format!(
728                "HTML base URI scan exceeds {max_events} parser events"
729            )));
730        }
731        match reader.read_event_into(&mut buffer)? {
732            Event::Start(ref element) | Event::Empty(ref element) => {
733                let qname = element.name();
734                let name = local_name(qname.as_ref());
735                if name.eq_ignore_ascii_case(b"body") {
736                    before_body = false;
737                } else if name.eq_ignore_ascii_case(b"head") {
738                    head_closed = false;
739                } else if name.eq_ignore_ascii_case(b"template") {
740                    template_depth = template_depth.saturating_add(1);
741                } else if before_body
742                    && !head_closed
743                    && template_depth == 0
744                    && name.eq_ignore_ascii_case(b"base")
745                {
746                    if let Some(href) = html_attribute(element, b"href") {
747                        if !href.trim().is_empty() {
748                            return Ok(Some(href.trim().to_owned()));
749                        }
750                    }
751                }
752            }
753            Event::End(ref element)
754                if local_name(element.name().as_ref()).eq_ignore_ascii_case(b"body") =>
755            {
756                before_body = false;
757            }
758            Event::End(ref element)
759                if local_name(element.name().as_ref()).eq_ignore_ascii_case(b"head") =>
760            {
761                head_closed = true;
762            }
763            Event::End(ref element)
764                if local_name(element.name().as_ref()).eq_ignore_ascii_case(b"template") =>
765            {
766                template_depth = template_depth.saturating_sub(1);
767            }
768            Event::Eof => break,
769            _ => {}
770        }
771        buffer.clear();
772    }
773    Ok(None)
774}
775
776fn collect_html_image_sources_from_html_with_limit(
777    html: &str,
778    max_events: usize,
779    max_normalized_bytes: usize,
780    max_sources: usize,
781) -> Result<(Vec<String>, bool)> {
782    let normalized = normalize_html_void_tags_limited(html, max_normalized_bytes)?;
783    let normalized = escape_bare_ampersands_limited(&normalized, max_normalized_bytes)?;
784    collect_html_image_sources_with_limit(&normalized, max_events, max_sources)
785}
786
787pub(crate) fn parse_html_blocks_with_inline_images(
788    html: &str,
789    max_events: usize,
790    max_normalized_bytes: usize,
791    inline_images: &HashMap<String, InlineHtmlImage>,
792) -> Result<(Vec<HtmlBlock>, Vec<String>, HashSet<String>)> {
793    parse_html_blocks_with_inline_images_impl(
794        html,
795        max_events,
796        max_normalized_bytes,
797        inline_images,
798        false,
799        None,
800    )
801}
802
803pub(crate) fn parse_html_blocks_with_inline_images_budgeted(
804    html: &str,
805    max_events: usize,
806    max_normalized_bytes: usize,
807    inline_images: &HashMap<String, InlineHtmlImage>,
808) -> Result<(Vec<HtmlBlock>, Vec<String>, HashSet<String>)> {
809    parse_html_blocks_with_inline_images_impl(
810        html,
811        max_events,
812        max_normalized_bytes,
813        inline_images,
814        true,
815        None,
816    )
817}
818
819pub(crate) fn parse_html_blocks_with_inline_images_resolved_budgeted(
820    html: &str,
821    max_events: usize,
822    max_normalized_bytes: usize,
823    inline_images: &HashMap<String, InlineHtmlImage>,
824    base_uri: &str,
825) -> Result<(Vec<HtmlBlock>, Vec<String>, HashSet<String>)> {
826    parse_html_blocks_with_inline_images_impl(
827        html,
828        max_events,
829        max_normalized_bytes,
830        inline_images,
831        true,
832        Some(base_uri),
833    )
834}
835
836fn parse_html_blocks_with_inline_images_impl(
837    html: &str,
838    max_events: usize,
839    max_normalized_bytes: usize,
840    inline_images: &HashMap<String, InlineHtmlImage>,
841    budget_inline_instances: bool,
842    inline_image_base_uri: Option<&str>,
843) -> Result<(Vec<HtmlBlock>, Vec<String>, HashSet<String>)> {
844    let html = normalize_html_void_tags_limited(html, max_normalized_bytes)?;
845    let clean_html = escape_bare_ampersands_limited(&html, max_normalized_bytes)?;
846    let mut reader = Reader::from_str(&clean_html);
847    let mut buf = Vec::new();
848
849    let mut blocks = Vec::new();
850    let mut warnings = Vec::new();
851    let mut used_inline_images = HashSet::new();
852    let mut inline_image_budget = HtmlImageBudget::default();
853    let mut image_element_count = 0usize;
854    let mut current_tag: Vec<Vec<u8>> = Vec::new();
855    let mut current_text = String::new();
856    let mut list_stack: Vec<usize> = Vec::new();
857    let mut ignore_depth = 0usize;
858
859    let mut in_table = false;
860    let mut in_th = false;
861    let mut in_td = false;
862    let mut row_had_th = false;
863    let mut current_colspan = 1usize;
864    let mut current_cell_align: Option<crate::table::TableAlign> = None;
865    let mut table_col_alignments: Vec<Option<crate::table::TableAlign>> = Vec::new();
866    let mut table_headers: Vec<String> = Vec::new();
867    let mut table_rows: Vec<Vec<String>> = Vec::new();
868    let mut current_row: Vec<String> = Vec::new();
869    let mut current_cell = String::new();
870
871    let flush_text = |blocks: &mut Vec<HtmlBlock>, current_text: &mut String| {
872        let trimmed = current_text.trim();
873        if !trimmed.is_empty() {
874            blocks.push(HtmlBlock::Paragraph {
875                text: trimmed.to_string(),
876            });
877        }
878        current_text.clear();
879    };
880
881    let flush_li =
882        |blocks: &mut Vec<HtmlBlock>, current_text: &mut String, list_stack: &mut Vec<usize>| {
883            let trimmed = current_text.trim();
884            if !trimmed.is_empty() {
885                let depth = list_stack.len().saturating_sub(1);
886                let indent = "  ".repeat(depth);
887                let bullet = if let Some(counter) = list_stack.last_mut() {
888                    if *counter > 0 {
889                        let b = format!("{indent}{}. ", *counter);
890                        *counter += 1;
891                        b
892                    } else {
893                        format!("{indent}• ")
894                    }
895                } else {
896                    "• ".to_string()
897                };
898                blocks.push(HtmlBlock::ListItem {
899                    bullet,
900                    text: trimmed.to_string(),
901                });
902            }
903            current_text.clear();
904        };
905
906    let mut event_count = 0usize;
907    loop {
908        event_count = event_count.saturating_add(1);
909        if event_count > max_events {
910            return Err(Error::LimitExceeded(format!(
911                "HTML document exceeds {max_events} parser events"
912            )));
913        }
914        match reader.read_event_into(&mut buf) {
915            Ok(Event::Eof) => break,
916            Ok(event) => match event {
917                Event::Start(ref e) => {
918                    let qname = e.name();
919                    let name = local_name(qname.as_ref()).to_vec();
920
921                    if matches!(
922                        name.as_slice(),
923                        b"head" | b"style" | b"script" | b"noscript" | b"template" | b"svg"
924                    ) {
925                        ignore_depth += 1;
926                    }
927
928                    if ignore_depth == 0 {
929                        let in_li = current_tag.iter().any(|t| t.as_slice() == b"li");
930                        if name == b"ol" || name == b"ul" {
931                            if in_li {
932                                flush_li(&mut blocks, &mut current_text, &mut list_stack);
933                            } else {
934                                flush_text(&mut blocks, &mut current_text);
935                            }
936                            if name == b"ol" {
937                                let start = attribute(e, b"start")
938                                    .and_then(|s| s.parse::<usize>().ok())
939                                    .unwrap_or(1);
940                                list_stack.push(start);
941                            } else {
942                                list_stack.push(0);
943                            }
944                        } else if name == b"li" {
945                            if in_li {
946                                flush_li(&mut blocks, &mut current_text, &mut list_stack);
947                            } else {
948                                flush_text(&mut blocks, &mut current_text);
949                            }
950                        } else if matches!(
951                            name.as_slice(),
952                            b"h1"
953                                | b"h2"
954                                | b"h3"
955                                | b"h4"
956                                | b"h5"
957                                | b"h6"
958                                | b"p"
959                                | b"pre"
960                                | b"blockquote"
961                                | b"table"
962                                | b"div"
963                                | b"section"
964                                | b"article"
965                                | b"main"
966                                | b"header"
967                                | b"footer"
968                                | b"aside"
969                                | b"nav"
970                                | b"figure"
971                                | b"dl"
972                                | b"dt"
973                                | b"dd"
974                        ) {
975                            flush_text(&mut blocks, &mut current_text);
976                        } else if name == b"table" {
977                            in_table = true;
978                            table_headers.clear();
979                            table_rows.clear();
980                            table_col_alignments.clear();
981                            current_row.clear();
982                            current_cell.clear();
983                        } else if name == b"tr" {
984                            current_row.clear();
985                            row_had_th = false;
986                        } else if name == b"th" || name == b"td" {
987                            if name == b"th" {
988                                in_th = true;
989                                row_had_th = true;
990                            } else {
991                                in_td = true;
992                            }
993                            current_cell.clear();
994                            current_colspan = attribute(e, b"colspan")
995                                .and_then(|s| s.parse::<usize>().ok())
996                                .unwrap_or(1)
997                                .max(1);
998                            current_cell_align = if let Some(a) = attribute(e, b"align") {
999                                match a.to_ascii_lowercase().as_str() {
1000                                    "right" => Some(crate::table::TableAlign::Right),
1001                                    "center" => Some(crate::table::TableAlign::Center),
1002                                    "left" => Some(crate::table::TableAlign::Left),
1003                                    _ => None,
1004                                }
1005                            } else if let Some(s) = attribute(e, b"style") {
1006                                let s_lower = s.to_ascii_lowercase();
1007                                if s_lower.contains("text-align: right")
1008                                    || s_lower.contains("text-align:right")
1009                                {
1010                                    Some(crate::table::TableAlign::Right)
1011                                } else if s_lower.contains("text-align: center")
1012                                    || s_lower.contains("text-align:center")
1013                                {
1014                                    Some(crate::table::TableAlign::Center)
1015                                } else if s_lower.contains("text-align: left")
1016                                    || s_lower.contains("text-align:left")
1017                                {
1018                                    Some(crate::table::TableAlign::Left)
1019                                } else {
1020                                    None
1021                                }
1022                            } else {
1023                                None
1024                            };
1025                        } else if name == b"br" {
1026                            if in_th || in_td {
1027                                current_cell.push('\n');
1028                            } else {
1029                                current_text.push('\n');
1030                            }
1031                        } else if name == b"hr" {
1032                            flush_text(&mut blocks, &mut current_text);
1033                            blocks.push(HtmlBlock::HorizontalRule);
1034                        }
1035                    }
1036
1037                    if !is_html_void_tag(&name) {
1038                        current_tag.push(name);
1039                    }
1040                }
1041                Event::End(ref e) => {
1042                    let qname = e.name();
1043                    let name = local_name(qname.as_ref());
1044
1045                    if matches!(
1046                        name,
1047                        b"head" | b"style" | b"script" | b"noscript" | b"template" | b"svg"
1048                    ) {
1049                        ignore_depth = ignore_depth.saturating_sub(1);
1050                    }
1051
1052                    if ignore_depth == 0 {
1053                        let text = current_text.trim().to_string();
1054
1055                        match name {
1056                            b"h1" => {
1057                                blocks.push(HtmlBlock::Heading { level: 1, text });
1058                                current_text.clear();
1059                            }
1060                            b"h2" => {
1061                                blocks.push(HtmlBlock::Heading { level: 2, text });
1062                                current_text.clear();
1063                            }
1064                            b"h3" => {
1065                                blocks.push(HtmlBlock::Heading { level: 3, text });
1066                                current_text.clear();
1067                            }
1068                            b"h4" | b"h5" | b"h6" => {
1069                                blocks.push(HtmlBlock::Heading { level: 4, text });
1070                                current_text.clear();
1071                            }
1072                            b"p" | b"blockquote" => {
1073                                if !text.is_empty() {
1074                                    blocks.push(HtmlBlock::Paragraph { text });
1075                                }
1076                                current_text.clear();
1077                            }
1078                            b"div" | b"section" | b"article" | b"main" | b"header" | b"footer"
1079                            | b"aside" | b"nav" | b"figure" | b"figcaption" | b"details"
1080                            | b"summary" | b"dl" => {
1081                                flush_text(&mut blocks, &mut current_text);
1082                            }
1083                            b"dt" => {
1084                                if !text.is_empty() {
1085                                    blocks.push(HtmlBlock::Heading { level: 4, text });
1086                                }
1087                                current_text.clear();
1088                            }
1089                            b"dd" => {
1090                                if !text.is_empty() {
1091                                    blocks.push(HtmlBlock::ListItem {
1092                                        bullet: "  • ".to_string(),
1093                                        text,
1094                                    });
1095                                }
1096                                current_text.clear();
1097                            }
1098                            b"li" => {
1099                                flush_li(&mut blocks, &mut current_text, &mut list_stack);
1100                            }
1101                            b"ul" | b"ol" => {
1102                                list_stack.pop();
1103                            }
1104                            b"pre" => {
1105                                if !text.is_empty() {
1106                                    blocks.push(HtmlBlock::CodeBlock { text });
1107                                }
1108                                current_text.clear();
1109                            }
1110                            b"th" | b"td" => {
1111                                let col_idx = current_row.len();
1112                                if let Some(align) = current_cell_align {
1113                                    if col_idx >= table_col_alignments.len() {
1114                                        table_col_alignments
1115                                            .resize(col_idx + current_colspan, None);
1116                                    }
1117                                    table_col_alignments[col_idx] = Some(align);
1118                                }
1119                                current_row.push(current_cell.trim().to_string());
1120                                for _ in 1..current_colspan {
1121                                    current_row.push(String::new());
1122                                }
1123                                current_cell.clear();
1124                                current_colspan = 1;
1125                                current_cell_align = None;
1126                                in_th = false;
1127                                in_td = false;
1128                            }
1129                            b"tr" => {
1130                                if !current_row.is_empty() {
1131                                    if row_had_th && table_headers.is_empty() {
1132                                        table_headers = std::mem::take(&mut current_row);
1133                                    } else {
1134                                        table_rows.push(std::mem::take(&mut current_row));
1135                                    }
1136                                }
1137                                current_row.clear();
1138                                row_had_th = false;
1139                            }
1140                            b"table" => {
1141                                in_table = false;
1142                                if !table_headers.is_empty() || !table_rows.is_empty() {
1143                                    if table_headers.is_empty() && !table_rows.is_empty() {
1144                                        table_headers = table_rows.remove(0);
1145                                    }
1146                                    let col_count = table_headers
1147                                        .len()
1148                                        .max(table_rows.iter().map(|r| r.len()).max().unwrap_or(0))
1149                                        .max(1);
1150
1151                                    let mut alignments =
1152                                        vec![crate::table::TableAlign::Left; col_count];
1153                                    for (c, align) in
1154                                        alignments.iter_mut().enumerate().take(col_count)
1155                                    {
1156                                        if let Some(Some(explicit)) = table_col_alignments.get(c) {
1157                                            *align = *explicit;
1158                                        } else {
1159                                            let is_numeric = !table_rows.is_empty()
1160                                                && table_rows.iter().all(|r| {
1161                                                    if let Some(val) = r.get(c) {
1162                                                        crate::table::is_numeric_cell(val)
1163                                                    } else {
1164                                                        true
1165                                                    }
1166                                                });
1167                                            if is_numeric {
1168                                                *align = crate::table::TableAlign::Right;
1169                                            }
1170                                        }
1171                                    }
1172
1173                                    blocks.push(HtmlBlock::Table(crate::table::TableData {
1174                                        headers: table_headers.clone(),
1175                                        rows: std::mem::take(&mut table_rows),
1176                                        alignments,
1177                                        raw_source: String::new(),
1178                                    }));
1179                                    table_headers.clear();
1180                                    table_col_alignments.clear();
1181                                }
1182                            }
1183                            _ => {}
1184                        }
1185                    }
1186
1187                    if let Some(pos) = current_tag.iter().rposition(|t| t.as_slice() == name) {
1188                        current_tag.remove(pos);
1189                    }
1190                }
1191                Event::Empty(ref e) => {
1192                    let qname = e.name();
1193                    let name = local_name(qname.as_ref());
1194                    if ignore_depth == 0 {
1195                        if name == b"hr" {
1196                            flush_text(&mut blocks, &mut current_text);
1197                            blocks.push(HtmlBlock::HorizontalRule);
1198                        } else if name == b"br" {
1199                            if in_th || in_td {
1200                                current_cell.push('\n');
1201                            } else {
1202                                current_text.push('\n');
1203                            }
1204                        } else if name.eq_ignore_ascii_case(b"img") {
1205                            image_element_count = image_element_count.saturating_add(1);
1206                            if image_element_count > MAX_HTML_IMAGE_ELEMENTS {
1207                                push_html_warning_once(
1208                                    &mut warnings,
1209                                    "HTML image elements exceeded the supported limit; remaining images were omitted",
1210                                );
1211                            } else {
1212                                let (source, from_srcset) =
1213                                    html_image_source(e).unwrap_or_default();
1214                                let alt = html_attribute(e, b"alt").unwrap_or_default();
1215                                if from_srcset {
1216                                    push_html_warning_once(
1217                                        &mut warnings,
1218                                        "HTML srcset uses its first candidate; responsive source selection is approximated",
1219                                    );
1220                                }
1221                                let image_key = normalize_cid_reference(&source)
1222                                    .or_else(|| {
1223                                        inline_images.contains_key(&source).then(|| source.clone())
1224                                    })
1225                                    .or_else(|| {
1226                                        inline_image_base_uri
1227                                            .and_then(|base| {
1228                                                crate::document::mime_images::resolve_mime_uri(
1229                                                    base, &source,
1230                                                )
1231                                            })
1232                                            .filter(|resolved| inline_images.contains_key(resolved))
1233                                    });
1234                                if let Some((key, image)) = image_key
1235                                    .as_ref()
1236                                    .and_then(|key| inline_images.get_key_value(key))
1237                                {
1238                                    if in_th || in_td {
1239                                        if !alt.is_empty() {
1240                                            current_cell.push_str(&format!("[Image: {alt}]"));
1241                                        }
1242                                        push_html_warning_once(
1243                                            &mut warnings,
1244                                            "embedded HTML images inside table cells were reduced to alt text",
1245                                        );
1246                                    } else if in_table {
1247                                        push_html_warning_once(
1248                                            &mut warnings,
1249                                            "embedded HTML images inside tables were omitted",
1250                                        );
1251                                    } else {
1252                                        let within_budget = !budget_inline_instances
1253                                            || reserve_html_image_instance(
1254                                                image,
1255                                                &mut inline_image_budget,
1256                                                &mut warnings,
1257                                            );
1258                                        if within_budget {
1259                                            flush_text(&mut blocks, &mut current_text);
1260                                            blocks.push(HtmlBlock::Image {
1261                                                href: image.href.clone(),
1262                                                pixel_width: image.pixel_width,
1263                                                pixel_height: image.pixel_height,
1264                                                alt: if alt.is_empty() {
1265                                                    "Embedded HTML image".into()
1266                                                } else {
1267                                                    alt
1268                                                },
1269                                            });
1270                                            used_inline_images.insert(key.clone());
1271                                        } else if !alt.is_empty() {
1272                                            current_text.push_str(&format!("[Image: {alt}]"));
1273                                        }
1274                                    }
1275                                } else {
1276                                    push_html_warning_once(
1277                                        &mut warnings,
1278                                        "HTML image source was omitted because it was not a validated embedded image resource",
1279                                    );
1280                                    if !alt.is_empty() {
1281                                        if in_th || in_td {
1282                                            current_cell.push_str(&format!("[Image: {alt}]"));
1283                                        } else if !in_table {
1284                                            flush_text(&mut blocks, &mut current_text);
1285                                            blocks.push(HtmlBlock::Paragraph {
1286                                                text: format!("[Image omitted: {alt}]"),
1287                                            });
1288                                        }
1289                                    }
1290                                }
1291                            }
1292                        }
1293                    }
1294                }
1295                Event::Text(ref e) => {
1296                    if ignore_depth == 0
1297                        && let Ok(raw_str) = std::str::from_utf8(e.as_ref())
1298                    {
1299                        let t = decode_html_entities(raw_str);
1300                        let in_pre = current_tag.iter().any(|t| t.as_slice() == b"pre");
1301                        if in_th || in_td {
1302                            append_html_text(&mut current_cell, &t, in_pre);
1303                        } else if !in_table {
1304                            append_html_text(&mut current_text, &t, in_pre);
1305                        }
1306                    }
1307                }
1308                Event::GeneralRef(ref e) => {
1309                    if ignore_depth == 0
1310                        && let Ok(raw_str) = std::str::from_utf8(e.as_ref())
1311                    {
1312                        let clean_name = raw_str.trim_start_matches('&').trim_end_matches(';');
1313                        let decoded = decode_named_or_numeric_entity(clean_name)
1314                            .unwrap_or_else(|| format!("&{clean_name};"));
1315                        if in_th || in_td {
1316                            current_cell.push_str(&decoded);
1317                        } else if !in_table {
1318                            current_text.push_str(&decoded);
1319                        }
1320                    }
1321                }
1322                Event::CData(ref e) => {
1323                    if ignore_depth == 0
1324                        && let Ok(raw_str) = std::str::from_utf8(e.as_ref())
1325                    {
1326                        if in_th || in_td {
1327                            current_cell.push_str(raw_str);
1328                        } else if !in_table {
1329                            current_text.push_str(raw_str);
1330                        }
1331                    }
1332                }
1333                Event::Eof => break,
1334                _ => {}
1335            },
1336            Err(error) => {
1337                return Err(Error::InvalidInput(format!(
1338                    "malformed HTML document: {error}"
1339                )));
1340            }
1341        }
1342        buf.clear();
1343    }
1344
1345    flush_text(&mut blocks, &mut current_text);
1346
1347    Ok((blocks, warnings, used_inline_images))
1348}
1349
1350pub(crate) fn render_blocks_to_pages(
1351    blocks: &[HtmlBlock],
1352    sink: &mut dyn PageConsumer,
1353    options: &ConvertOptions,
1354) -> Result<()> {
1355    if blocks.is_empty() {
1356        return Err(Error::InvalidInput(
1357            "HTML contains no renderable content".into(),
1358        ));
1359    }
1360
1361    let mut current_page_num = 1usize;
1362    let mut current_page = create_document_page(current_page_num);
1363    let mut current_y = MARGIN_TOP;
1364
1365    let flush_page =
1366        |page: &mut Page, page_num: &mut usize, sink: &mut dyn PageConsumer| -> Result<()> {
1367            sink.consume(page.clone())?;
1368            *page_num += 1;
1369            *page = create_document_page(*page_num);
1370            Ok(())
1371        };
1372
1373    for block in blocks {
1374        match block {
1375            HtmlBlock::PageBreak => {
1376                if current_page.nodes.len() > 1 {
1377                    if current_page_num >= options.max_pages {
1378                        return Err(Error::LimitExceeded(
1379                            "HTML page breaks exceeded maximum pages".into(),
1380                        ));
1381                    }
1382                    sink.consume(current_page)?;
1383                    current_page_num += 1;
1384                    current_page = create_document_page(current_page_num);
1385                    current_y = MARGIN_TOP;
1386                }
1387            }
1388            HtmlBlock::Heading { level, text } => {
1389                let (font_size, line_height, space_before, space_after) = match level {
1390                    1 => (22.0, 28.0, 24.0, 12.0),
1391                    2 => (17.0, 22.0, 20.0, 10.0),
1392                    3 => (14.0, 18.0, 16.0, 8.0),
1393                    _ => (12.5, 16.0, 12.0, 6.0),
1394                };
1395
1396                // Keep-with-next: ensure room for heading plus subsequent content
1397                if current_y + space_before + line_height + space_after + 32.0
1398                    > MARGIN_TOP + CONTENT_HEIGHT
1399                    && current_y > MARGIN_TOP
1400                {
1401                    flush_page(&mut current_page, &mut current_page_num, sink)?;
1402                    current_y = MARGIN_TOP;
1403                } else {
1404                    current_y += space_before;
1405                }
1406
1407                let wrapped_lines = wrap_text(text, CONTENT_WIDTH, font_size * 0.65);
1408                for line in wrapped_lines {
1409                    if current_y + line_height > MARGIN_TOP + CONTENT_HEIGHT {
1410                        flush_page(&mut current_page, &mut current_page_num, sink)?;
1411                        current_y = MARGIN_TOP;
1412                    }
1413                    current_page.nodes.push(Node::Text {
1414                        id: format!("heading_{}_{}", current_page_num, current_page.nodes.len()),
1415                        x: MARGIN_LEFT,
1416                        y: current_y + font_size * 0.85,
1417                        runs: vec![TextRun {
1418                            text: line,
1419                            font_family: "sans-serif".into(),
1420                            font_size,
1421                            bold: true,
1422                            italic: false,
1423                            fill: Paint::solid("#0f172a"),
1424                            baseline_shift: 0.0,
1425                            glyph_x_offsets: Vec::new(),
1426                            target_advance: None,
1427                        }],
1428                        anchor: TextAnchor::Start,
1429                        transform: IDENTITY,
1430                        opacity: 1.0,
1431                        stroke: Stroke::default(),
1432                        clip_id: None,
1433                        meta: SourceMeta {
1434                            semantic_role: format!("heading-{level}"),
1435                            ..Default::default()
1436                        },
1437                    });
1438                    current_y += line_height;
1439                }
1440                current_y += space_after;
1441            }
1442            HtmlBlock::Paragraph { text } => {
1443                let font_size = 11.0;
1444                let line_height = 16.0;
1445                let space_after = 10.0;
1446
1447                let wrapped_lines = wrap_text(text, CONTENT_WIDTH, font_size * 0.58);
1448                for line in wrapped_lines {
1449                    if current_y + line_height > MARGIN_TOP + CONTENT_HEIGHT {
1450                        flush_page(&mut current_page, &mut current_page_num, sink)?;
1451                        current_y = MARGIN_TOP;
1452                    }
1453                    current_page.nodes.push(Node::Text {
1454                        id: format!("p_{}_{}", current_page_num, current_page.nodes.len()),
1455                        x: MARGIN_LEFT,
1456                        y: current_y + font_size * 0.85,
1457                        runs: vec![TextRun {
1458                            text: line,
1459                            font_family: "sans-serif".into(),
1460                            font_size,
1461                            bold: false,
1462                            italic: false,
1463                            fill: Paint::solid("#334155"),
1464                            baseline_shift: 0.0,
1465                            glyph_x_offsets: Vec::new(),
1466                            target_advance: None,
1467                        }],
1468                        anchor: TextAnchor::Start,
1469                        transform: IDENTITY,
1470                        opacity: 1.0,
1471                        stroke: Stroke::default(),
1472                        clip_id: None,
1473                        meta: SourceMeta {
1474                            semantic_role: "paragraph".into(),
1475                            ..Default::default()
1476                        },
1477                    });
1478                    current_y += line_height;
1479                }
1480                current_y += space_after;
1481            }
1482            HtmlBlock::ListItem { bullet, text } => {
1483                let font_size = 11.0;
1484                let line_height = 15.0;
1485                let indent = 20.0;
1486
1487                if current_y + line_height > MARGIN_TOP + CONTENT_HEIGHT {
1488                    flush_page(&mut current_page, &mut current_page_num, sink)?;
1489                    current_y = MARGIN_TOP;
1490                }
1491
1492                let wrapped_lines = wrap_text(text, CONTENT_WIDTH - indent, font_size * 0.58);
1493                for (i, line) in wrapped_lines.iter().enumerate() {
1494                    if current_y + line_height > MARGIN_TOP + CONTENT_HEIGHT {
1495                        flush_page(&mut current_page, &mut current_page_num, sink)?;
1496                        current_y = MARGIN_TOP;
1497                    }
1498                    let line_text = if i == 0 {
1499                        format!("{bullet}{line}")
1500                    } else {
1501                        line.clone()
1502                    };
1503                    let x_offset = if i == 0 {
1504                        MARGIN_LEFT
1505                    } else {
1506                        MARGIN_LEFT + indent
1507                    };
1508
1509                    current_page.nodes.push(Node::Text {
1510                        id: format!("li_{}_{}", current_page_num, current_page.nodes.len()),
1511                        x: x_offset,
1512                        y: current_y + font_size * 0.85,
1513                        runs: vec![TextRun {
1514                            text: line_text,
1515                            font_family: "sans-serif".into(),
1516                            font_size,
1517                            bold: false,
1518                            italic: false,
1519                            fill: Paint::solid("#334155"),
1520                            baseline_shift: 0.0,
1521                            glyph_x_offsets: Vec::new(),
1522                            target_advance: None,
1523                        }],
1524                        anchor: TextAnchor::Start,
1525                        transform: IDENTITY,
1526                        opacity: 1.0,
1527                        stroke: Stroke::default(),
1528                        clip_id: None,
1529                        meta: SourceMeta {
1530                            semantic_role: "list-item".into(),
1531                            ..Default::default()
1532                        },
1533                    });
1534                    current_y += line_height;
1535                }
1536                current_y += 4.0;
1537            }
1538            HtmlBlock::StyledText { kind, runs } => {
1539                let (
1540                    base_font_size,
1541                    base_bold,
1542                    base_color,
1543                    line_height,
1544                    space_before,
1545                    space_after,
1546                    indent,
1547                    semantic_role,
1548                ) = match kind {
1549                    HtmlTextBlockKind::Heading(level) => {
1550                        let (size, height, before, after) = match level {
1551                            1 => (22.0, 28.0, 24.0, 12.0),
1552                            2 => (17.0, 22.0, 20.0, 10.0),
1553                            3 => (14.0, 18.0, 16.0, 8.0),
1554                            _ => (12.5, 16.0, 12.0, 6.0),
1555                        };
1556                        (
1557                            size,
1558                            true,
1559                            "#0f172a",
1560                            height,
1561                            before,
1562                            after,
1563                            0.0,
1564                            format!("heading-{level}"),
1565                        )
1566                    }
1567                    HtmlTextBlockKind::Paragraph => (
1568                        11.0,
1569                        false,
1570                        "#334155",
1571                        16.0,
1572                        0.0,
1573                        10.0,
1574                        0.0,
1575                        "paragraph".into(),
1576                    ),
1577                    HtmlTextBlockKind::ListItem { .. } => (
1578                        11.0,
1579                        false,
1580                        "#334155",
1581                        15.0,
1582                        0.0,
1583                        4.0,
1584                        20.0,
1585                        "list-item".into(),
1586                    ),
1587                };
1588                if runs.iter().all(|run| run.text.trim().is_empty()) {
1589                    continue;
1590                }
1591                if matches!(kind, HtmlTextBlockKind::Heading(_))
1592                    && current_y + space_before + line_height + space_after + 32.0
1593                        > MARGIN_TOP + CONTENT_HEIGHT
1594                    && current_y > MARGIN_TOP
1595                {
1596                    flush_page(&mut current_page, &mut current_page_num, sink)?;
1597                    current_y = MARGIN_TOP;
1598                } else {
1599                    current_y += space_before;
1600                }
1601                let wrapped_lines = wrap_styled_text(runs, CONTENT_WIDTH - indent, base_font_size);
1602                for (line_index, line_runs) in wrapped_lines.into_iter().enumerate() {
1603                    if line_runs.is_empty() {
1604                        continue;
1605                    }
1606                    let line_font_size = line_runs
1607                        .iter()
1608                        .map(|run| run.style.font_size.unwrap_or(base_font_size))
1609                        .fold(base_font_size, f64::max);
1610                    let actual_line_height = line_height.max(line_font_size * 1.35);
1611                    if current_y + actual_line_height > MARGIN_TOP + CONTENT_HEIGHT {
1612                        flush_page(&mut current_page, &mut current_page_num, sink)?;
1613                        current_y = MARGIN_TOP;
1614                    }
1615                    let mut svg_runs = Vec::with_capacity(
1616                        line_runs.len()
1617                            + usize::from(
1618                                matches!(kind, HtmlTextBlockKind::ListItem { .. })
1619                                    && line_index == 0,
1620                            ),
1621                    );
1622                    if let HtmlTextBlockKind::ListItem { bullet } = kind
1623                        && line_index == 0
1624                    {
1625                        svg_runs.push(TextRun {
1626                            text: bullet.clone(),
1627                            font_family: "sans-serif".into(),
1628                            font_size: base_font_size,
1629                            bold: base_bold,
1630                            italic: false,
1631                            fill: Paint::solid(base_color),
1632                            baseline_shift: 0.0,
1633                            glyph_x_offsets: Vec::new(),
1634                            target_advance: None,
1635                        });
1636                    }
1637                    svg_runs.extend(line_runs.into_iter().map(|run| TextRun {
1638                        text: run.text,
1639                        font_family: run.style.font_family.unwrap_or_else(|| "sans-serif".into()),
1640                        font_size: run.style.font_size.unwrap_or(base_font_size),
1641                        bold: run.style.bold.unwrap_or(base_bold),
1642                        italic: run.style.italic.unwrap_or(false),
1643                        fill: Paint::solid(run.style.color.unwrap_or_else(|| base_color.into())),
1644                        baseline_shift: 0.0,
1645                        glyph_x_offsets: Vec::new(),
1646                        target_advance: None,
1647                    }));
1648                    current_page.nodes.push(Node::Text {
1649                        id: format!("odf_text_{}_{}", current_page_num, current_page.nodes.len()),
1650                        x: MARGIN_LEFT
1651                            + if matches!(kind, HtmlTextBlockKind::ListItem { .. })
1652                                && line_index > 0
1653                            {
1654                                indent
1655                            } else {
1656                                0.0
1657                            },
1658                        y: current_y + line_font_size * 0.85,
1659                        runs: svg_runs,
1660                        anchor: TextAnchor::Start,
1661                        transform: IDENTITY,
1662                        opacity: 1.0,
1663                        stroke: Stroke::default(),
1664                        clip_id: None,
1665                        meta: SourceMeta {
1666                            semantic_role: semantic_role.clone(),
1667                            ..Default::default()
1668                        },
1669                    });
1670                    current_y += actual_line_height;
1671                }
1672                current_y += space_after;
1673            }
1674            HtmlBlock::CodeBlock { text } => {
1675                let expanded_code = expand_tab_stops(text, 4);
1676                let font_size = 10.0;
1677                let line_height = 14.0;
1678                let pad_y = 8.0;
1679                let lines: Vec<&str> = expanded_code.lines().collect();
1680                let mut line_idx = 0;
1681
1682                while line_idx < lines.len() {
1683                    let remaining_page_h = (MARGIN_TOP + CONTENT_HEIGHT) - current_y;
1684                    if remaining_page_h < line_height * 3.0 + pad_y * 2.0 && current_y > MARGIN_TOP
1685                    {
1686                        flush_page(&mut current_page, &mut current_page_num, sink)?;
1687                        current_y = MARGIN_TOP;
1688                    }
1689
1690                    let available_h = (MARGIN_TOP + CONTENT_HEIGHT) - current_y;
1691                    let max_lines =
1692                        ((available_h - pad_y * 2.0) / line_height).floor().max(1.0) as usize;
1693                    let chunk_end = (line_idx + max_lines).min(lines.len());
1694                    let chunk = &lines[line_idx..chunk_end];
1695                    let chunk_h = chunk.len() as f64 * line_height + pad_y * 2.0;
1696
1697                    // Code background box for this chunk
1698                    current_page.nodes.push(Node::Path {
1699                        id: format!("code_bg_{}_{}", current_page_num, current_page.nodes.len()),
1700                        d: format!(
1701                            "M {:.2},{:.2} h {:.2} v {:.2} h -{:.2} Z",
1702                            MARGIN_LEFT, current_y, CONTENT_WIDTH, chunk_h, CONTENT_WIDTH
1703                        ),
1704                        fill_rule: "evenodd".into(),
1705                        fill: Paint::solid("#f8fafc"),
1706                        stroke: Stroke {
1707                            paint: Paint::solid("#e2e8f0"),
1708                            width: 1.0,
1709                            ..Default::default()
1710                        },
1711                        transform: IDENTITY,
1712                        clip_id: None,
1713                        meta: SourceMeta::default(),
1714                    });
1715
1716                    let mut code_y = current_y + pad_y;
1717                    for l in chunk {
1718                        current_page.nodes.push(Node::Text {
1719                            id: format!("code_{}_{}", current_page_num, current_page.nodes.len()),
1720                            x: MARGIN_LEFT + 10.0,
1721                            y: code_y + font_size * 0.85,
1722                            runs: vec![TextRun {
1723                                text: (*l).to_string(),
1724                                font_family: "monospace".into(),
1725                                font_size,
1726                                bold: false,
1727                                italic: false,
1728                                fill: Paint::solid("#0f172a"),
1729                                baseline_shift: 0.0,
1730                                glyph_x_offsets: Vec::new(),
1731                                target_advance: None,
1732                            }],
1733                            anchor: TextAnchor::Start,
1734                            transform: IDENTITY,
1735                            opacity: 1.0,
1736                            stroke: Stroke::default(),
1737                            clip_id: None,
1738                            meta: SourceMeta {
1739                                semantic_role: "code".into(),
1740                                ..Default::default()
1741                            },
1742                        });
1743                        code_y += line_height;
1744                    }
1745
1746                    current_y += chunk_h + 12.0;
1747                    line_idx = chunk_end;
1748
1749                    if line_idx < lines.len() {
1750                        flush_page(&mut current_page, &mut current_page_num, sink)?;
1751                        current_y = MARGIN_TOP;
1752                    }
1753                }
1754            }
1755            HtmlBlock::Image {
1756                href,
1757                pixel_width,
1758                pixel_height,
1759                alt,
1760            } => {
1761                if *pixel_width == 0 || *pixel_height == 0 {
1762                    continue;
1763                }
1764                let scale = (CONTENT_WIDTH / f64::from(*pixel_width))
1765                    .min(360.0 / f64::from(*pixel_height))
1766                    .min(1.0);
1767                let draw_width = f64::from(*pixel_width) * scale;
1768                let draw_height = f64::from(*pixel_height) * scale;
1769                if current_y + draw_height > MARGIN_TOP + CONTENT_HEIGHT && current_y > MARGIN_TOP {
1770                    if current_page_num >= options.max_pages {
1771                        return Err(Error::LimitExceeded(
1772                            "HTML image blocks exceeded maximum pages".into(),
1773                        ));
1774                    }
1775                    flush_page(&mut current_page, &mut current_page_num, sink)?;
1776                    current_y = MARGIN_TOP;
1777                }
1778                let x = MARGIN_LEFT + (CONTENT_WIDTH - draw_width) * 0.5;
1779                current_page.nodes.push(Node::Image {
1780                    id: format!(
1781                        "document_image_{}_{}",
1782                        current_page_num,
1783                        current_page.nodes.len()
1784                    ),
1785                    href: href.clone(),
1786                    x,
1787                    y: current_y,
1788                    width: draw_width,
1789                    height: draw_height,
1790                    transform: IDENTITY,
1791                    opacity: 1.0,
1792                    clip_id: None,
1793                    meta: SourceMeta {
1794                        semantic_role: "document:image".into(),
1795                        alt_text: alt.clone(),
1796                        ..Default::default()
1797                    },
1798                });
1799                current_y += draw_height + 12.0;
1800            }
1801            HtmlBlock::HorizontalRule => {
1802                current_y += 10.0;
1803                if current_y + 10.0 > MARGIN_TOP + CONTENT_HEIGHT {
1804                    flush_page(&mut current_page, &mut current_page_num, sink)?;
1805                    current_y = MARGIN_TOP;
1806                }
1807                current_page.nodes.push(Node::Path {
1808                    id: format!("hr_{}_{}", current_page_num, current_page.nodes.len()),
1809                    d: format!(
1810                        "M {:.2},{:.2} h {:.2}",
1811                        MARGIN_LEFT, current_y, CONTENT_WIDTH
1812                    ),
1813                    fill_rule: "evenodd".into(),
1814                    fill: Paint::None,
1815                    stroke: Stroke {
1816                        paint: Paint::solid("#cbd5e1"),
1817                        width: 1.0,
1818                        ..Default::default()
1819                    },
1820                    transform: IDENTITY,
1821                    clip_id: None,
1822                    meta: SourceMeta::default(),
1823                });
1824                current_y += 12.0;
1825            }
1826            HtmlBlock::Table(table) => {
1827                let col_count = table
1828                    .headers
1829                    .len()
1830                    .max(table.rows.iter().map(|r| r.len()).max().unwrap_or(0))
1831                    .max(1);
1832
1833                let font_size = 9.5;
1834                let header_font_size = 10.0;
1835                let padding_x = 8.0;
1836                let cell_height = 24.0;
1837                let header_height = 26.0;
1838
1839                // Estimate relative weights for columns based on character length
1840                let mut col_char_counts = vec![4usize; col_count];
1841                for (c, h) in table.headers.iter().enumerate() {
1842                    if c < col_count {
1843                        col_char_counts[c] = col_char_counts[c].max(h.chars().count());
1844                    }
1845                }
1846                for row in &table.rows {
1847                    for (c, cell) in row.iter().enumerate() {
1848                        if c < col_count {
1849                            col_char_counts[c] = col_char_counts[c].max(cell.chars().count());
1850                        }
1851                    }
1852                }
1853
1854                let total_chars: usize = col_char_counts.iter().sum::<usize>().max(1);
1855                let mut col_widths = vec![0.0f64; col_count];
1856                for c in 0..col_count {
1857                    let ratio = col_char_counts[c] as f64 / total_chars as f64;
1858                    let w = (CONTENT_WIDTH * ratio).max(40.0);
1859                    col_widths[c] = w;
1860                }
1861                let cur_sum: f64 = col_widths.iter().sum();
1862                let factor = CONTENT_WIDTH / cur_sum.max(1.0);
1863                for w in &mut col_widths {
1864                    *w *= factor;
1865                }
1866
1867                let render_header = |page: &mut Page, y: f64, page_num: usize| {
1868                    page.nodes.push(Node::Path {
1869                        id: format!("tbl_hdr_bg_{}_{}", page_num, page.nodes.len()),
1870                        d: format!(
1871                            "M {:.2},{:.2} h {:.2} v {:.2} h -{:.2} Z",
1872                            MARGIN_LEFT, y, CONTENT_WIDTH, header_height, CONTENT_WIDTH
1873                        ),
1874                        fill_rule: "evenodd".into(),
1875                        fill: Paint::solid("#f1f5f9"),
1876                        stroke: Stroke {
1877                            paint: Paint::solid("#cbd5e1"),
1878                            width: 1.0,
1879                            ..Default::default()
1880                        },
1881                        transform: IDENTITY,
1882                        clip_id: None,
1883                        meta: SourceMeta::default(),
1884                    });
1885
1886                    let mut cur_x = MARGIN_LEFT;
1887                    for (c, header) in table.headers.iter().enumerate() {
1888                        if c >= col_widths.len() {
1889                            break;
1890                        }
1891                        let w = col_widths[c];
1892                        let align = table
1893                            .alignments
1894                            .get(c)
1895                            .copied()
1896                            .unwrap_or(crate::table::TableAlign::Left);
1897                        let (text_x, anchor) = match align {
1898                            crate::table::TableAlign::Center => {
1899                                (cur_x + w / 2.0, TextAnchor::Middle)
1900                            }
1901                            crate::table::TableAlign::Right => {
1902                                (cur_x + w - padding_x, TextAnchor::End)
1903                            }
1904                            crate::table::TableAlign::Left => {
1905                                (cur_x + padding_x, TextAnchor::Start)
1906                            }
1907                        };
1908
1909                        let max_cell_text_w = (w - padding_x * 2.0).max(10.0);
1910                        page.nodes.push(Node::Text {
1911                            id: format!("tbl_hdr_txt_{}_{}", page_num, page.nodes.len()),
1912                            x: text_x,
1913                            y: y + header_height * 0.65,
1914                            runs: vec![TextRun {
1915                                text: fit_text_to_width(
1916                                    header,
1917                                    max_cell_text_w,
1918                                    header_font_size * 0.6,
1919                                ),
1920                                font_family: "sans-serif".into(),
1921                                font_size: header_font_size,
1922                                bold: true,
1923                                italic: false,
1924                                fill: Paint::solid("#0f172a"),
1925                                baseline_shift: 0.0,
1926                                glyph_x_offsets: Vec::new(),
1927                                target_advance: None,
1928                            }],
1929                            anchor,
1930                            transform: IDENTITY,
1931                            opacity: 1.0,
1932                            stroke: Stroke::default(),
1933                            clip_id: None,
1934                            meta: SourceMeta {
1935                                semantic_role: "table-header".into(),
1936                                ..Default::default()
1937                            },
1938                        });
1939                        cur_x += w;
1940                    }
1941                };
1942
1943                current_y += 8.0;
1944                if current_y + header_height + cell_height > MARGIN_TOP + CONTENT_HEIGHT {
1945                    flush_page(&mut current_page, &mut current_page_num, sink)?;
1946                    current_y = MARGIN_TOP;
1947                }
1948
1949                if !table.headers.is_empty() {
1950                    render_header(&mut current_page, current_y, current_page_num);
1951                    current_y += header_height;
1952                }
1953
1954                for (row_idx, row) in table.rows.iter().enumerate() {
1955                    if current_y + cell_height > MARGIN_TOP + CONTENT_HEIGHT {
1956                        flush_page(&mut current_page, &mut current_page_num, sink)?;
1957                        current_y = MARGIN_TOP;
1958                        if !table.headers.is_empty() {
1959                            render_header(&mut current_page, current_y, current_page_num);
1960                            current_y += header_height;
1961                        }
1962                    }
1963
1964                    let bg_color = if row_idx % 2 == 1 {
1965                        "#f8fafc"
1966                    } else {
1967                        "#ffffff"
1968                    };
1969                    current_page.nodes.push(Node::Path {
1970                        id: format!(
1971                            "tbl_row_bg_{}_{}",
1972                            current_page_num,
1973                            current_page.nodes.len()
1974                        ),
1975                        d: format!(
1976                            "M {:.2},{:.2} h {:.2} v {:.2} h -{:.2} Z",
1977                            MARGIN_LEFT, current_y, CONTENT_WIDTH, cell_height, CONTENT_WIDTH
1978                        ),
1979                        fill_rule: "evenodd".into(),
1980                        fill: Paint::solid(bg_color),
1981                        stroke: Stroke {
1982                            paint: Paint::solid("#e2e8f0"),
1983                            width: 0.75,
1984                            ..Default::default()
1985                        },
1986                        transform: IDENTITY,
1987                        clip_id: None,
1988                        meta: SourceMeta::default(),
1989                    });
1990
1991                    let mut cur_x = MARGIN_LEFT;
1992                    for (c, cell) in row.iter().enumerate() {
1993                        if c >= col_widths.len() {
1994                            break;
1995                        }
1996                        let w = col_widths[c];
1997                        let align = table
1998                            .alignments
1999                            .get(c)
2000                            .copied()
2001                            .unwrap_or(crate::table::TableAlign::Left);
2002                        let (text_x, anchor) = match align {
2003                            crate::table::TableAlign::Center => {
2004                                (cur_x + w / 2.0, TextAnchor::Middle)
2005                            }
2006                            crate::table::TableAlign::Right => {
2007                                (cur_x + w - padding_x, TextAnchor::End)
2008                            }
2009                            crate::table::TableAlign::Left => {
2010                                (cur_x + padding_x, TextAnchor::Start)
2011                            }
2012                        };
2013
2014                        let max_cell_text_w = (w - padding_x * 2.0).max(10.0);
2015                        current_page.nodes.push(Node::Text {
2016                            id: format!(
2017                                "tbl_cell_{}_{}",
2018                                current_page_num,
2019                                current_page.nodes.len()
2020                            ),
2021                            x: text_x,
2022                            y: current_y + cell_height * 0.65,
2023                            runs: vec![TextRun {
2024                                text: fit_text_to_width(cell, max_cell_text_w, font_size * 0.58),
2025                                font_family: "sans-serif".into(),
2026                                font_size,
2027                                bold: false,
2028                                italic: false,
2029                                fill: Paint::solid("#334155"),
2030                                baseline_shift: 0.0,
2031                                glyph_x_offsets: Vec::new(),
2032                                target_advance: None,
2033                            }],
2034                            anchor,
2035                            transform: IDENTITY,
2036                            opacity: 1.0,
2037                            stroke: Stroke::default(),
2038                            clip_id: None,
2039                            meta: SourceMeta {
2040                                semantic_role: "table-cell".into(),
2041                                ..Default::default()
2042                            },
2043                        });
2044                        cur_x += w;
2045                    }
2046
2047                    current_y += cell_height;
2048                }
2049
2050                current_y += 12.0;
2051            }
2052        }
2053
2054        if current_page_num > options.max_pages {
2055            return Err(Error::LimitExceeded(
2056                "HTML pagination exceeded maximum pages".into(),
2057            ));
2058        }
2059    }
2060
2061    if current_page.nodes.len() > 1 {
2062        sink.consume(current_page)?;
2063    }
2064
2065    Ok(())
2066}
2067
2068/// Render HTML-like flow blocks and attach parser/resource warnings to every
2069/// emitted page. Format adapters that load local resources before rendering use
2070/// this helper so warnings remain visible in both the conversion report and
2071/// the page metadata.
2072pub(crate) fn render_blocks_to_pages_with_warnings(
2073    blocks: &[HtmlBlock],
2074    sink: &mut dyn PageConsumer,
2075    options: &ConvertOptions,
2076    warnings: &[String],
2077) -> Result<()> {
2078    let mut warning_sink = HtmlWarningSink {
2079        inner: sink,
2080        warnings,
2081    };
2082    render_blocks_to_pages(blocks, &mut warning_sink, options)
2083}
2084
2085pub(crate) fn wrap_text(text: &str, max_width: f64, char_width: f64) -> Vec<String> {
2086    let mut lines = Vec::new();
2087
2088    for paragraph in text.split('\n') {
2089        let trimmed = paragraph.trim();
2090        if trimmed.is_empty() {
2091            continue;
2092        }
2093
2094        let tokens = tokenize_for_wrapping(trimmed);
2095        let mut current_line = String::new();
2096        let mut current_line_width = 0.0;
2097
2098        for token in tokens {
2099            let token_width = estimate_token_width(&token, char_width);
2100            let needs_space = needs_space_between(&current_line, &token);
2101            let space_width = if needs_space { char_width } else { 0.0 };
2102
2103            if !current_line.is_empty()
2104                && (current_line_width + space_width + token_width > max_width)
2105            {
2106                lines.push(current_line);
2107                current_line = String::new();
2108                current_line_width = 0.0;
2109            }
2110
2111            if needs_space && !current_line.is_empty() {
2112                current_line.push(' ');
2113                current_line_width += char_width;
2114            }
2115
2116            current_line.push_str(&token);
2117            current_line_width += token_width;
2118        }
2119
2120        if !current_line.is_empty() {
2121            lines.push(current_line);
2122        }
2123    }
2124
2125    if lines.is_empty() {
2126        lines.push(text.to_string());
2127    }
2128
2129    lines
2130}
2131
2132pub(crate) fn wrap_styled_text(
2133    runs: &[HtmlTextRun],
2134    max_width: f64,
2135    default_font_size: f64,
2136) -> Vec<Vec<HtmlTextRun>> {
2137    let mut wrapper = StyledTextWrapper {
2138        lines: Vec::new(),
2139        line: Vec::new(),
2140        line_width: 0.0,
2141        word: Vec::new(),
2142        word_width: 0.0,
2143        pending_space: None,
2144        max_width,
2145        default_font_size,
2146    };
2147    for run in runs {
2148        for character in run.text.chars() {
2149            if character == '\n' {
2150                wrapper.flush_word();
2151                wrapper.flush_line();
2152                wrapper.pending_space = None;
2153            } else if character.is_whitespace() {
2154                wrapper.flush_word();
2155                if !wrapper.line.is_empty() {
2156                    wrapper.pending_space = Some(run.style.clone());
2157                }
2158            } else if is_cjk_char(character) {
2159                wrapper.flush_word();
2160                wrapper.append_word_character(character, &run.style);
2161                wrapper.flush_word();
2162            } else {
2163                wrapper.append_word_character(character, &run.style);
2164            }
2165        }
2166    }
2167    wrapper.flush_word();
2168    wrapper.flush_line();
2169    if wrapper.lines.is_empty() && !runs.is_empty() {
2170        wrapper.lines.push(runs.to_vec());
2171    }
2172    wrapper.lines
2173}
2174
2175struct StyledTextWrapper {
2176    lines: Vec<Vec<HtmlTextRun>>,
2177    line: Vec<HtmlTextRun>,
2178    line_width: f64,
2179    word: Vec<HtmlTextRun>,
2180    word_width: f64,
2181    pending_space: Option<HtmlTextStyle>,
2182    max_width: f64,
2183    default_font_size: f64,
2184}
2185
2186impl StyledTextWrapper {
2187    fn append_word_character(&mut self, character: char, style: &HtmlTextStyle) {
2188        append_html_text_run(&mut self.word, &character.to_string(), style);
2189        self.word_width += styled_char_width(character, style, self.default_font_size);
2190    }
2191
2192    fn flush_word(&mut self) {
2193        if self.word.is_empty() {
2194            return;
2195        }
2196        let word = std::mem::take(&mut self.word);
2197        let word_width = std::mem::take(&mut self.word_width);
2198        let space_style = self.pending_space.take();
2199        let space_width = space_style
2200            .as_ref()
2201            .map(|style| styled_char_width(' ', style, self.default_font_size))
2202            .unwrap_or(0.0);
2203        let closing_cjk_punctuation = word
2204            .iter()
2205            .flat_map(|run| run.text.chars())
2206            .all(is_cjk_closing_punct);
2207        if !self.line.is_empty()
2208            && self.line_width + space_width + word_width > self.max_width
2209            && !closing_cjk_punctuation
2210        {
2211            self.flush_line();
2212        } else if let Some(style) = space_style
2213            && !self.line.is_empty()
2214        {
2215            append_html_text_run(&mut self.line, " ", &style);
2216            self.line_width += space_width;
2217        }
2218
2219        if word_width <= self.max_width {
2220            for run in &word {
2221                self.line_width += styled_text_width(&run.text, &run.style, self.default_font_size);
2222                append_html_text_run(&mut self.line, &run.text, &run.style);
2223            }
2224        } else {
2225            for run in &word {
2226                for character in run.text.chars() {
2227                    let width = styled_char_width(character, &run.style, self.default_font_size);
2228                    if !self.line.is_empty()
2229                        && self.line_width + width > self.max_width
2230                        && !is_cjk_closing_punct(character)
2231                    {
2232                        self.flush_line();
2233                    }
2234                    append_html_text_run(&mut self.line, &character.to_string(), &run.style);
2235                    self.line_width += width;
2236                }
2237            }
2238        }
2239    }
2240
2241    fn flush_line(&mut self) {
2242        if !self.line.is_empty() {
2243            self.lines.push(std::mem::take(&mut self.line));
2244            self.line_width = 0.0;
2245        }
2246    }
2247}
2248
2249fn append_html_text_run(runs: &mut Vec<HtmlTextRun>, text: &str, style: &HtmlTextStyle) {
2250    if let Some(last) = runs.last_mut()
2251        && last.style == *style
2252    {
2253        last.text.push_str(text);
2254    } else {
2255        runs.push(HtmlTextRun {
2256            text: text.to_owned(),
2257            style: style.clone(),
2258        });
2259    }
2260}
2261
2262fn styled_char_width(character: char, style: &HtmlTextStyle, default_font_size: f64) -> f64 {
2263    let font_size = style
2264        .font_size
2265        .unwrap_or(default_font_size)
2266        .clamp(1.0, 512.0);
2267    let width = if is_cjk_char(character) {
2268        font_size * 0.99
2269    } else {
2270        font_size * 0.55
2271    };
2272    if style.bold == Some(true) {
2273        width * 1.05
2274    } else {
2275        width
2276    }
2277}
2278
2279fn styled_text_width(text: &str, style: &HtmlTextStyle, default_font_size: f64) -> f64 {
2280    text.chars()
2281        .map(|character| styled_char_width(character, style, default_font_size))
2282        .sum()
2283}
2284
2285fn is_cjk_char(c: char) -> bool {
2286    matches!(c as u32,
2287        0x3000..=0x303F | // CJK Symbols and Punctuation
2288        0x3040..=0x309F | // Hiragana
2289        0x30A0..=0x30FF | // Katakana
2290        0x3400..=0x4DBF | // CJK Unified Ideographs Extension A
2291        0x4E00..=0x9FFF | // CJK Unified Ideographs
2292        0xF900..=0xFAFF | // CJK Compatibility Ideographs
2293        0xFF00..=0xFFEF | // Halfwidth and Fullwidth Forms
2294        0xAC00..=0xD7AF   // Hangul Syllables
2295    )
2296}
2297
2298fn is_cjk_closing_punct(c: char) -> bool {
2299    matches!(
2300        c,
2301        '。' | '、'
2302            | ','
2303            | '.'
2304            | ')'
2305            | '』'
2306            | '」'
2307            | '}'
2308            | ']'
2309            | '!'
2310            | '?'
2311            | ':'
2312            | ';'
2313            | '…'
2314            | '・'
2315    )
2316}
2317
2318fn estimate_token_width(token: &str, char_width: f64) -> f64 {
2319    let mut width = 0.0;
2320    for c in token.chars() {
2321        if is_cjk_char(c) {
2322            // CJK characters are full-width (approx 1.8x Latin char_width)
2323            width += char_width * 1.8;
2324        } else {
2325            width += char_width;
2326        }
2327    }
2328    width
2329}
2330
2331fn needs_space_between(prev: &str, next: &str) -> bool {
2332    if prev.is_empty() {
2333        return false;
2334    }
2335    let last_char = prev.chars().last().unwrap_or(' ');
2336    let first_char = next.chars().next().unwrap_or(' ');
2337
2338    !is_cjk_char(last_char) && !is_cjk_char(first_char) && !last_char.is_whitespace()
2339}
2340
2341fn tokenize_for_wrapping(text: &str) -> Vec<String> {
2342    let mut tokens = Vec::new();
2343    let mut current_latin = String::new();
2344    let chars: Vec<char> = text.chars().collect();
2345    let mut i = 0;
2346
2347    while i < chars.len() {
2348        let c = chars[i];
2349        if c.is_whitespace() {
2350            if !current_latin.is_empty() {
2351                tokens.push(std::mem::take(&mut current_latin));
2352            }
2353            i += 1;
2354            continue;
2355        }
2356
2357        if is_cjk_char(c) {
2358            if !current_latin.is_empty() {
2359                tokens.push(std::mem::take(&mut current_latin));
2360            }
2361
2362            let mut cjk_token = String::new();
2363            cjk_token.push(c);
2364            while i + 1 < chars.len() && is_cjk_closing_punct(chars[i + 1]) {
2365                i += 1;
2366                cjk_token.push(chars[i]);
2367            }
2368            tokens.push(cjk_token);
2369        } else {
2370            current_latin.push(c);
2371        }
2372        i += 1;
2373    }
2374
2375    if !current_latin.is_empty() {
2376        tokens.push(current_latin);
2377    }
2378
2379    tokens
2380}
2381
2382fn create_document_page(page_num: usize) -> Page {
2383    let mut page = Page::new(page_num, PAGE_WIDTH, PAGE_HEIGHT, "html-page");
2384    page.nodes.push(Node::Path {
2385        id: format!("page_bg_{page_num}"),
2386        d: format!(
2387            "M 0,0 h {:.2} v {:.2} h -{:.2} Z",
2388            PAGE_WIDTH, PAGE_HEIGHT, PAGE_WIDTH
2389        ),
2390        fill_rule: "evenodd".into(),
2391        fill: Paint::solid("#ffffff"),
2392        stroke: Stroke::default(),
2393        transform: IDENTITY,
2394        clip_id: None,
2395        meta: SourceMeta::default(),
2396    });
2397    page
2398}
2399
2400pub fn decode_html_entities(input: &str) -> String {
2401    let mut out = String::with_capacity(input.len());
2402    let mut chars = input.chars().peekable();
2403
2404    while let Some(c) = chars.next() {
2405        if c == '&' {
2406            let mut entity = String::new();
2407            let mut closed = false;
2408            while let Some(&next_c) = chars.peek() {
2409                if next_c == ';' {
2410                    chars.next();
2411                    closed = true;
2412                    break;
2413                } else if next_c.is_alphanumeric() || next_c == '#' {
2414                    entity.push(chars.next().unwrap());
2415                    if entity.len() > 10 {
2416                        break;
2417                    }
2418                } else {
2419                    break;
2420                }
2421            }
2422
2423            if closed {
2424                if let Some(ch) = decode_named_or_numeric_entity(&entity) {
2425                    out.push_str(&ch);
2426                    continue;
2427                }
2428            }
2429            out.push('&');
2430            out.push_str(&entity);
2431            if closed {
2432                out.push(';');
2433            }
2434        } else {
2435            out.push(c);
2436        }
2437    }
2438
2439    out
2440}
2441
2442fn decode_named_or_numeric_entity(entity: &str) -> Option<String> {
2443    if let Some(stripped) = entity.strip_prefix('#') {
2444        let codepoint = if let Some(hex) = stripped
2445            .strip_prefix('x')
2446            .or_else(|| stripped.strip_prefix('X'))
2447        {
2448            u32::from_str_radix(hex, 16).ok()?
2449        } else {
2450            stripped.parse::<u32>().ok()?
2451        };
2452        return char::from_u32(codepoint).map(|ch| ch.to_string());
2453    }
2454
2455    let s = match entity {
2456        "amp" => "&",
2457        "lt" => "<",
2458        "gt" => ">",
2459        "quot" => "\"",
2460        "apos" => "'",
2461        "nbsp" => "\u{00A0}",
2462        "copy" => "©",
2463        "reg" => "®",
2464        "trade" => "™",
2465        "mdash" => "—",
2466        "ndash" => "–",
2467        "hellip" => "…",
2468        "bull" => "•",
2469        "ldquo" => "“",
2470        "rdquo" => "”",
2471        "lsquo" => "‘",
2472        "rsquo" => "’",
2473        "euro" => "€",
2474        "pound" => "£",
2475        "yen" => "¥",
2476        "cent" => "¢",
2477        "plusmn" => "±",
2478        "times" => "×",
2479        "divide" => "÷",
2480        "ne" => "≠",
2481        "le" => "≤",
2482        "ge" => "≥",
2483        "deg" => "°",
2484        "micro" => "µ",
2485        "middot" => "·",
2486        "rarr" => "→",
2487        "larr" => "←",
2488        _ => return None,
2489    };
2490    Some(s.to_string())
2491}
2492
2493fn fit_text_to_width(text: &str, max_width: f64, char_width: f64) -> String {
2494    let text_w = estimate_token_width(text, char_width);
2495    if text_w <= max_width {
2496        return text.to_string();
2497    }
2498    let ellipsis_w = estimate_token_width("…", char_width);
2499    let target_w = (max_width - ellipsis_w).max(0.0);
2500
2501    let mut result = String::new();
2502    let mut current_w = 0.0;
2503    for c in text.chars() {
2504        let cw = if is_cjk_char(c) {
2505            char_width * 1.8
2506        } else {
2507            char_width
2508        };
2509        if current_w + cw > target_w {
2510            break;
2511        }
2512        result.push(c);
2513        current_w += cw;
2514    }
2515    result.push('…');
2516    result
2517}
2518
2519pub(crate) fn expand_tab_stops(text: &str, tab_size: usize) -> String {
2520    let mut out = String::with_capacity(text.len());
2521    let mut col = 0;
2522    for ch in text.chars() {
2523        if ch == '\t' {
2524            let spaces = tab_size - (col % tab_size);
2525            for _ in 0..spaces {
2526                out.push(' ');
2527            }
2528            col += spaces;
2529        } else {
2530            out.push(ch);
2531            if ch == '\n' {
2532                col = 0;
2533            } else {
2534                col += 1;
2535            }
2536        }
2537    }
2538    out
2539}
2540
2541#[cfg(test)]
2542mod tests {
2543    use super::*;
2544
2545    #[derive(Default)]
2546    struct TestPages(Vec<Page>);
2547
2548    impl PageConsumer for TestPages {
2549        fn consume(&mut self, page: Page) -> Result<()> {
2550            self.0.push(page);
2551            Ok(())
2552        }
2553    }
2554
2555    #[test]
2556    fn html_parser_enforces_event_budget() {
2557        assert!(matches!(
2558            parse_html_blocks_with_limit("<p>text</p>", 2),
2559            Err(Error::LimitExceeded(_))
2560        ));
2561    }
2562
2563    #[test]
2564    fn html_base_href_is_bounded_and_only_read_before_body() {
2565        assert_eq!(
2566            first_html_base_href(
2567                "<html><head><base href=\"assets/&amp;icons/\"></head><body><base href=\"ignored/\"></body></html>",
2568                32,
2569                4096,
2570            )
2571            .unwrap()
2572            .as_deref(),
2573            Some("assets/&icons/")
2574        );
2575        assert!(matches!(
2576            first_html_base_href("<html><body></body></html>", 1, 4096),
2577            Err(Error::LimitExceeded(_))
2578        ));
2579        assert_eq!(
2580            first_html_base_href(
2581                "<html><head><template><base href=\"template/\"></template></head><base href=\"late/\"><body></body></html>",
2582                32,
2583                4096,
2584            )
2585            .unwrap(),
2586            None
2587        );
2588    }
2589
2590    #[test]
2591    fn mime_inline_image_references_share_the_total_render_budget() {
2592        let image = InlineHtmlImage {
2593            href: "data:image/png;base64,abcd".into(),
2594            pixel_width: 1,
2595            pixel_height: 1,
2596        };
2597        let mut budget = HtmlImageBudget {
2598            data_uri_bytes: MAX_HTML_TOTAL_DATA_URI_BYTES - image.href.len() + 1,
2599            ..Default::default()
2600        };
2601        let mut warnings = Vec::new();
2602        assert!(!reserve_html_image_instance(
2603            &image,
2604            &mut budget,
2605            &mut warnings
2606        ));
2607        assert_eq!(
2608            budget.data_uri_bytes,
2609            MAX_HTML_TOTAL_DATA_URI_BYTES - image.href.len() + 1
2610        );
2611        assert!(
2612            warnings
2613                .iter()
2614                .any(|warning| warning.contains("total data URI"))
2615        );
2616    }
2617
2618    #[test]
2619    fn image_source_scan_caps_reference_memory_and_accepts_xhtml_doctype() {
2620        let html = "<!DOCTYPE html><html><body><img src='one.png'/><img src='two.png'/><img src='three.png'/></body></html>";
2621        let (sources, exceeded) = collect_html_image_sources_with_limit(html, 100, 2).unwrap();
2622
2623        assert_eq!(sources, ["one.png", "two.png"]);
2624        assert!(exceeded);
2625    }
2626
2627    #[test]
2628    fn local_html_image_paths_decode_percent_bytes_and_stay_inside_the_base() {
2629        let temp = tempfile::tempdir().unwrap();
2630        let base = temp.path().join("site");
2631        std::fs::create_dir_all(base.join("assets")).unwrap();
2632        std::fs::write(base.join("assets/red-blue.png"), b"fixture").unwrap();
2633        let base = std::fs::canonicalize(base).unwrap();
2634
2635        assert_eq!(
2636            local_html_image_path(&base, "assets/red%2Dblue.png"),
2637            Some(base.join("assets/red-blue.png"))
2638        );
2639        assert!(local_html_image_path(&base, "../outside.png").is_none());
2640        assert!(local_html_image_path(&base, "%2e%2e/outside.png").is_none());
2641        assert!(local_html_image_path(&base, "https://example.invalid/image.png").is_none());
2642        assert!(local_html_image_path(&base, "//example.invalid/image.png").is_none());
2643    }
2644
2645    #[test]
2646    fn html_parser_caps_image_placeholders() {
2647        let html = format!(
2648            "<body>{}</body>",
2649            "<img src='remote.png' alt='remote'>".repeat(MAX_HTML_IMAGE_ELEMENTS + 3)
2650        );
2651        let (blocks, warnings, _) = parse_html_blocks_with_inline_images(
2652            &html,
2653            DEFAULT_MAX_HTML_EVENTS,
2654            MAX_NORMALIZED_HTML_BYTES,
2655            &HashMap::new(),
2656        )
2657        .unwrap();
2658
2659        assert_eq!(blocks.len(), MAX_HTML_IMAGE_ELEMENTS);
2660        assert!(
2661            warnings
2662                .iter()
2663                .any(|warning| warning.contains("image elements exceeded"))
2664        );
2665    }
2666
2667    #[test]
2668    fn ampersand_preprocessing_does_not_duplicate_multibyte_text() {
2669        assert_eq!(
2670            escape_bare_ampersands_limited("日本語 &copy; 3 & 4", 1024).unwrap(),
2671            "日本語 &copy; 3 &amp; 4"
2672        );
2673    }
2674
2675    #[test]
2676    fn ampersand_expansion_stops_at_the_normalized_output_budget() {
2677        assert_eq!(
2678            escape_bare_ampersands_limited("& &", 11).unwrap(),
2679            "&amp; &amp;"
2680        );
2681        assert!(matches!(
2682            escape_bare_ampersands_limited("& &", 10),
2683            Err(Error::LimitExceeded(_))
2684        ));
2685    }
2686
2687    #[test]
2688    fn html_void_tags_without_xml_slashes_parse_without_losing_text() {
2689        let blocks = parse_html_blocks_with_limit(
2690            "<html><head><meta charset=\"utf-8\"></head><body><p>Before<img src=\"https://invalid.test/image.png\"><br>after</p></body></html>",
2691            1_000,
2692        )
2693        .unwrap();
2694        let text = blocks
2695            .iter()
2696            .filter_map(|block| match block {
2697                HtmlBlock::Paragraph { text } => Some(text.as_str()),
2698                _ => None,
2699            })
2700            .collect::<Vec<_>>()
2701            .join(" ");
2702        assert!(text.contains("Before"));
2703        assert!(text.contains("after"));
2704        assert!(!text.contains("invalid.test"));
2705    }
2706
2707    #[test]
2708    fn explicit_page_break_creates_exactly_two_nonempty_pages() {
2709        let blocks = [
2710            HtmlBlock::Paragraph {
2711                text: "First page".into(),
2712            },
2713            HtmlBlock::PageBreak,
2714            HtmlBlock::Paragraph {
2715                text: "Second page".into(),
2716            },
2717            HtmlBlock::PageBreak,
2718        ];
2719        let mut pages = TestPages::default();
2720        render_blocks_to_pages(&blocks, &mut pages, &ConvertOptions::default()).unwrap();
2721
2722        assert_eq!(pages.0.len(), 2);
2723        assert!(pages.0[0].nodes.len() > 1);
2724        assert!(pages.0[1].nodes.len() > 1);
2725    }
2726
2727    #[test]
2728    fn expands_tabs_to_uniform_tab_stops() {
2729        assert_eq!(expand_tab_stops("a\tb", 4), "a   b");
2730        assert_eq!(expand_tab_stops("abc\td", 4), "abc d");
2731        assert_eq!(expand_tab_stops("abcd\te", 4), "abcd    e");
2732        assert_eq!(expand_tab_stops("\tline", 4), "    line");
2733    }
2734}