1#![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; const PAGE_HEIGHT: f64 = 842.0; const 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
135fn 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, "&", 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 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 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 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
2068pub(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(¤t_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 | 0x3040..=0x309F | 0x30A0..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0xAC00..=0xD7AF )
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 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/&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("日本語 © 3 & 4", 1024).unwrap(),
2671 "日本語 © 3 & 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 "& &"
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}