fix(export): skip empty HTML runs and clamp heading levels to h6 (#6136)

This commit is contained in:
jonathanwxh-cell authored and GitHub committed 2026-11-10 10:02:47 +07:00
1 parent ba8beb12b9
commit ccda1b0a1e
1 file changed
+130 -43
+130 -43
View File
@@ -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!("<em>{text}</em>");
}
if bold {
text = format!("<strong>{text}</strong>");
}
text
}
fn para_html(runs: &[Run<'_>], lvl: u8, simple: bool) -> Option<String> {
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!("<span style=\"color:#{red:02X}{green:02X}{blue:02X}\">{run}</span>");
}
t.push_str(&run);
}
let style = style_color(first.color, simple);
Some(match lvl.min(6) {
0 => format!("<p{style}>{t}</p>\n"),
l => format!("<h{l}{style}>{t}</h{l}>\n"),
})
}
fn table_html(t: &Table, simple: bool) -> String {
let mut s = String::from("<table>\n");
for row in &t.rows {
s.push_str("<tr>");
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!("<td{span}{style}>{body}</td>"));
}
s.push_str("</tr>\n");
}
s.push_str("</table>\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!("<em>{run}</em>");
}
if r.block.bold && lvl == 0 {
run = format!("<strong>{run}</strong>");
}
// 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!("<span style=\"color:#{red:02X}{green:02X}{blue:02X}\">{run}</span>");
}
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!("<p{style}>{t}</p>\n")),
l => s.push_str(&format!("<h{l}{style}>{t}</h{l}>\n")),
if let Some(p) = para_html(&runs, lvl, simple) {
s.push_str(&p);
}
}
Item::Table(t) => {
s.push_str("<table>\n");
for row in &t.rows {
s.push_str("<tr>");
for c in row {
let mut body = esc(&c.text);
if c.italic {
body = format!("<em>{body}</em>");
}
if c.bold {
body = format!("<strong>{body}</strong>");
}
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!("<td{span}{style}>{body}</td>"));
}
s.push_str("</tr>\n");
}
s.push_str("</table>\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!("<p><img alt=\"\" src=\"data:{mime};base64,{}\"></p>\n", base64(&im.bytes)));
@@ -162,3 +181,71 @@ fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&s
s.push_str("</body>\n</html>\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("<p style=\"color:#008000\">Hello</p>"), "{out}");
assert!(!out.contains("<strong></strong>"), "{out}");
assert!(!out.contains("<em></em>"), "{out}");
assert_eq!(out.matches("<p").count(), 1, "{out}");
// Styled whitespace-only paragraphs are omitted, and leading whitespace runs do not set
// the paragraph colour or emit emphasis/colour wrappers.
let ws_block = Block { bold: true, italic: true, color: [1.0, 0.0, 0.0], ..block(" ", 72.0, 700.0, 80.0, 712.0) };
let vis_block = Block { color: [0.0, 0.5, 0.0], ..block("Visible", 80.0, 700.0, 120.0, 712.0) };
assert_eq!(para_html(&[Run { text: " \t\n".into(), block: &ws_block }], 0, false), None);
assert_eq!(
para_html(&[Run { text: " ".into(), block: &ws_block }, Run { text: "Visible".into(), block: &vis_block }], 0, false).as_deref(),
Some("<p style=\"color:#008000\"> Visible</p>\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("<h6>Deep Heading</h6>\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), "<table>\n<tr><td></td><td><strong>Val</strong></td></tr>\n</table>\n");
}
}