From ccda1b0a1ef5b635b26f13b2c4daf8c7d3e455a3 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Tue, 10 Nov 2026 11:02:47 +0800 Subject: [PATCH] fix(export): skip empty HTML runs and clamp heading levels to h6 (#6136) --- crates/export/src/as_html.rs | 173 ++++++++++++++++++++++++++--------- 1 file changed, 130 insertions(+), 43 deletions(-) diff --git a/crates/export/src/as_html.rs b/crates/export/src/as_html.rs index 8c250ca484..e2b3379ac2 100644 --- a/crates/export/src/as_html.rs +++ b/crates/export/src/as_html.rs @@ -1,5 +1,5 @@ use super::as_docx::{hex, rgb8}; -use super::layout::{Item, Page, items}; +use super::layout::{Item, Page, Run, Table, items}; /// Whether a character may appear in the output. XML 1.0 forbids the C0 controls other than tab, /// line feed and carriage return, and U+FFFE/U+FFFF. Text extracted from PDFs often holds them @@ -94,6 +94,64 @@ pub fn html_simple_with_language(pages: &[Page], title: &str, language: Option<& html_with_mode(pages, title, true, language) } +fn style_color(color: [f64; 3], simple: bool) -> String { + if simple { String::new() } else { hex(color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) } +} + +fn wrap_inline(mut text: String, bold: bool, italic: bool) -> String { + if text.trim().is_empty() { + return text; + } + if italic { + text = format!("{text}"); + } + if bold { + text = format!("{text}"); + } + text +} + +fn para_html(runs: &[Run<'_>], lvl: u8, simple: bool) -> Option { + let non_empty: Vec<_> = runs.iter().map(|r| (r.block, esc(&r.text))).filter(|(_, text)| !text.is_empty()).collect(); + let &(first, _) = non_empty.iter().find(|(_, text)| !text.trim().is_empty())?; + let mut t = String::new(); + for (block, text) in non_empty { + if text.trim().is_empty() { + t.push_str(&text); + continue; + } + let mut run = wrap_inline(text, block.bold && lvl == 0, block.italic); + // The paragraph has its first visible run's colour; a run in another colour says so. + let colour = rgb8(block.color); + if !simple && colour != rgb8(first.color) { + let [red, green, blue] = colour; + run = format!("{run}"); + } + t.push_str(&run); + } + let style = style_color(first.color, simple); + Some(match lvl.min(6) { + 0 => format!("{t}

\n"), + l => format!("{t}\n"), + }) +} + +fn table_html(t: &Table, simple: bool) -> String { + let mut s = String::from("\n"); + for row in &t.rows { + s.push_str(""); + for c in row { + let body = wrap_inline(esc(&c.text), c.bold, c.italic); + let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; + let style = style_color(c.color, simple); + s.push_str(&format!("{body}")); + } + s.push_str("\n"); + } + s.push_str("
\n"); + s +} + fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&str>) -> String { let lang = language_tag(language).map(|tag| format!(" lang=\"{}\"", esc(tag))).unwrap_or_default(); let mut s = format!( @@ -108,50 +166,11 @@ fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&s for it in items(pages) { match it { Item::Para(runs, lvl) => { - let Some(first) = runs.first().map(|r| r.block) else { continue }; - let mut t = String::new(); - for r in &runs { - let mut run = esc(&r.text); - if r.block.italic { - run = format!("{run}"); - } - if r.block.bold && lvl == 0 { - run = format!("{run}"); - } - // The paragraph has its first run's colour; a run in another colour says so. - let colour = rgb8(r.block.color); - if !simple && colour != rgb8(first.color) { - let [red, green, blue] = colour; - run = format!("{run}"); - } - t.push_str(&run); - } - let style = if simple { String::new() } else { hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) }; - match lvl { - 0 => s.push_str(&format!("{t}

\n")), - l => s.push_str(&format!("{t}\n")), + if let Some(p) = para_html(&runs, lvl, simple) { + s.push_str(&p); } } - Item::Table(t) => { - s.push_str("\n"); - for row in &t.rows { - s.push_str(""); - for c in row { - let mut body = esc(&c.text); - if c.italic { - body = format!("{body}"); - } - if c.bold { - body = format!("{body}"); - } - let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; - let style = if simple { String::new() } else { hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) }; - s.push_str(&format!("{body}")); - } - s.push_str("\n"); - } - s.push_str("
\n"); - } + Item::Table(t) => s.push_str(&table_html(&t, simple)), Item::Img(im) => { let mime = if im.ext == "jpg" { "image/jpeg" } else { "image/png" }; s.push_str(&format!("

\"\"

\n", base64(&im.bytes))); @@ -162,3 +181,71 @@ fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&s s.push_str("\n\n"); s } + +#[cfg(test)] +mod tests { + use super::{html, para_html, table_html}; + use crate::layout::{Block, Cell, Page, Run, Table}; + + fn block(text: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> Block { + Block { + text: text.into(), + rect: [x0, y0, x1, y1], + size: 12.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + } + } + + #[test] + fn empty_or_control_only_runs_and_blocks_do_not_emit_empty_tags() { + let page = Page { + width: 612.0, + height: 792.0, + blocks: vec![ + Block { bold: true, italic: true, color: [1.0, 0.0, 0.0], ..block("\u{1}\u{FFFF}", 72.0, 700.0, 90.0, 712.0) }, + Block { color: [0.0, 0.5, 0.0], ..block("Hello", 90.5, 700.0, 140.0, 712.0) }, + Block { bold: true, ..block("\u{0}\u{2}", 72.0, 660.0, 120.0, 672.0) }, + ], + images: Vec::new(), + background: None, + rules: Vec::new(), + }; + let out = html(&[page], "Clean"); + assert!(out.contains("

Hello

"), "{out}"); + assert!(!out.contains(""), "{out}"); + assert!(!out.contains(""), "{out}"); + assert_eq!(out.matches(" Visible

\n") + ); + } + + #[test] + fn heading_level_is_clamped_and_empty_styled_cells_stay_clean() { + let b = block("Deep Heading", 72.0, 700.0, 200.0, 712.0); + let runs = [Run { text: "Deep Heading".into(), block: &b }]; + assert_eq!(para_html(&runs, 9, true).as_deref(), Some("
Deep Heading
\n")); + + let table = Table { + rect: [0.0, 0.0, 100.0, 20.0], + cols: vec![0.0, 50.0], + rows: vec![vec![ + Cell { text: "\u{1}".into(), size: 11.0, bold: true, italic: true, color: [0.0; 3], span: 1 }, + Cell { text: "Val".into(), size: 11.0, bold: true, italic: false, color: [0.0; 3], span: 1 }, + ]], + ruled: None, + }; + assert_eq!(table_html(&table, true), "\n\n
Val
\n"); + } +}