mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
fix(export): skip empty HTML runs and clamp heading levels to h6 (#6136)
This commit is contained in:
1 parent
ba8beb12b9
commit
ccda1b0a1e
1 file changed
+130
-43
+130
-43
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user