mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 02:21:45 +00:00
Add CSS-free HTML export (#5938)
* feat(export): add simple HTML mode * test(automation): reject simple HTML mode for other formats * test(export): check simple HTML without new parser dev-dependencies The parser test pulled html5ever, markup5ever_rcdom and seven more crates into Cargo.lock for one test; plain string checks cover the same markup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
7 files changed
+62
-10
No files matched your search
@@ -6,6 +6,13 @@ impl Automation {
|
||||
let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html, rtf or md)")))?;
|
||||
let html_mode = a.opt_str("html_mode")?.unwrap_or("rich");
|
||||
let format = match (format, html_mode) {
|
||||
(pdfcraft_engine::compare::OfficeFormat::Html, "rich") => pdfcraft_engine::compare::OfficeFormat::Html,
|
||||
(pdfcraft_engine::compare::OfficeFormat::Html, "simple") => pdfcraft_engine::compare::OfficeFormat::HtmlSimple,
|
||||
(_, "rich") => format,
|
||||
(_, _) => return Err(ToolError::InvalidArgs("html_mode is only available for HTML exports (rich or simple)".into())),
|
||||
};
|
||||
let export = self.doc_to_copy(a)?.export_office_checked(format);
|
||||
write_atomic(&path, &export.bytes)?;
|
||||
// Text left out to keep a hostile page bounded is said, not silent (#3120).
|
||||
|
||||
@@ -1194,9 +1194,9 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
|
||||
&["doc", "field", "event"],
|
||||
)),
|
||||
t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit, or that have pictures past what one export may hold or decode, are listed in truncated_pages, with a warning; what was left out is missing.")
|
||||
t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. HTML uses rich styling by default; set html_mode to simple for CSS-free headings, paragraphs, emphasis and tables. Simple HTML keeps text and inline images but omits colour and layout styling. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit, or that have pictures past what one export may hold or decode, are listed in truncated_pages, with a warning; what was left out is missing.")
|
||||
.cmd("export.docx")
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "html_mode": { "type": "string", "enum": ["rich", "simple"], "default": "rich" } }), &["doc", "path"])),
|
||||
t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.")
|
||||
.ro()
|
||||
.cmd("standards.pdfa")
|
||||
|
||||
@@ -7615,6 +7615,17 @@ fn exporting_to_word_html_and_rtf() {
|
||||
}
|
||||
let html = std::fs::read_to_string(dir.join("a.html")).unwrap();
|
||||
assert!(html.contains("Page 1") && html.contains("Page 3") && html.matches("<hr>").count() == 2, "{html}");
|
||||
ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "simple.html", "html_mode": "simple" }));
|
||||
let simple_html = std::fs::read_to_string(dir.join("simple.html")).unwrap();
|
||||
assert!(
|
||||
simple_html.contains("Page 1")
|
||||
&& !simple_html.contains("<style")
|
||||
&& !simple_html.contains("style=")
|
||||
&& !simple_html.contains("<span")
|
||||
&& !simple_html.contains("<div")
|
||||
);
|
||||
assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "bad.html", "html_mode": "compact" })).is_err());
|
||||
assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "bad.docx", "html_mode": "simple" })).is_err());
|
||||
assert!(std::fs::read(dir.join("a.docx")).unwrap().starts_with(b"PK"));
|
||||
assert!(std::fs::read(dir.join("a.pptx")).unwrap().starts_with(b"PK"));
|
||||
assert!(std::fs::read_to_string(dir.join("a.rtf")).unwrap().contains("Page 2"));
|
||||
|
||||
@@ -140,6 +140,8 @@ pub enum OfficeFormat {
|
||||
Docx,
|
||||
Pptx,
|
||||
Html,
|
||||
/// CSS-free HTML that preserves text and inline images but drops colours and layout styling.
|
||||
HtmlSimple,
|
||||
Rtf,
|
||||
Markdown,
|
||||
}
|
||||
@@ -150,6 +152,7 @@ impl OfficeFormat {
|
||||
OfficeFormat::Docx => "docx",
|
||||
OfficeFormat::Pptx => "pptx",
|
||||
OfficeFormat::Html => "html",
|
||||
OfficeFormat::HtmlSimple => "html",
|
||||
OfficeFormat::Rtf => "rtf",
|
||||
OfficeFormat::Markdown => "md",
|
||||
}
|
||||
@@ -301,6 +304,7 @@ impl crate::Document {
|
||||
OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title),
|
||||
OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title),
|
||||
OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(),
|
||||
OfficeFormat::HtmlSimple => pdfcraft_export::html_simple(&pages, &title).into_bytes(),
|
||||
OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(),
|
||||
OfficeFormat::Markdown => pdfcraft_export::markdown(&pages).into_bytes(),
|
||||
};
|
||||
|
||||
@@ -38,11 +38,23 @@ pub(crate) fn base64(data: &[u8]) -> String {
|
||||
/// One HTML file: headings and paragraphs, tables as real `<table>` elements, images inline, a
|
||||
/// rule between pages.
|
||||
pub fn html(pages: &[Page], title: &str) -> String {
|
||||
html_with_mode(pages, title, false)
|
||||
}
|
||||
|
||||
/// A simple HTML export with no CSS; page layout and colours are omitted.
|
||||
pub fn html_simple(pages: &[Page], title: &str) -> String {
|
||||
html_with_mode(pages, title, true)
|
||||
}
|
||||
|
||||
fn html_with_mode(pages: &[Page], title: &str, simple: bool) -> String {
|
||||
let mut s = format!(
|
||||
"<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n<style>body{{max-width:46em;margin:2em auto;font-family:sans-serif;line-height:1.45}}\
|
||||
img{{max-width:100%}}hr{{border:0;border-top:1px solid #ccc;margin:2em 0}}\
|
||||
table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;padding:2px 6px;vertical-align:top}}</style>\n</head>\n<body>\n",
|
||||
esc(title)
|
||||
"<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n{}\n</head>\n<body>\n",
|
||||
esc(title),
|
||||
if simple {
|
||||
""
|
||||
} else {
|
||||
"<style>body{max-width:46em;margin:2em auto;font-family:sans-serif;line-height:1.45}img{max-width:100%}hr{border:0;border-top:1px solid #ccc;margin:2em 0}table{border-collapse:collapse;margin:1em 0}td,th{border:1px solid #999;padding:2px 6px;vertical-align:top}</style>"
|
||||
}
|
||||
);
|
||||
for it in items(pages) {
|
||||
match it {
|
||||
@@ -59,13 +71,13 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi
|
||||
}
|
||||
// The paragraph has its first run's colour; a run in another colour says so.
|
||||
let colour = rgb8(r.block.color);
|
||||
if colour != rgb8(first.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 = hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\""));
|
||||
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")),
|
||||
@@ -84,7 +96,7 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi
|
||||
body = format!("<strong>{body}</strong>");
|
||||
}
|
||||
let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() };
|
||||
let style = hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\""));
|
||||
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");
|
||||
|
||||
@@ -27,9 +27,9 @@ pub(crate) use as_docx::hex;
|
||||
#[cfg(test)]
|
||||
use as_docx::{MAX_MEDIA, docx_table};
|
||||
pub(crate) use as_html::esc;
|
||||
pub use as_html::html;
|
||||
#[cfg(test)]
|
||||
use as_html::{allowed, base64};
|
||||
pub use as_html::{html, html_simple};
|
||||
pub use as_markdown::markdown;
|
||||
#[cfg(test)]
|
||||
use as_markdown::{emphasis, paragraph};
|
||||
|
||||
@@ -85,6 +85,24 @@ fn html_has_headings_paragraphs_and_images_in_order() {
|
||||
assert_eq!(base64(b"Ma"), "TWE=");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_html_has_no_css_or_layout_elements() {
|
||||
let mut p = page();
|
||||
let mut unicode = block("naïve 日本語.", 72.0, 300.0, 300.0, 311.0);
|
||||
unicode.bold = true;
|
||||
unicode.italic = true;
|
||||
p.blocks.push(unicode);
|
||||
let source = html_simple(&[p, table_page()], "Simple export");
|
||||
for banned in ["<style", "style=", "<div", "<span"] {
|
||||
assert!(!source.contains(banned), "{banned} in {source}");
|
||||
}
|
||||
for wanted in ["<h1", "<p>", "<strong>", "<em>", "<table", "naïve 日本語."] {
|
||||
assert!(source.contains(wanted), "no {wanted} in {source}");
|
||||
}
|
||||
// The rich export of the same pages keeps its stylesheet: only simple mode drops it.
|
||||
assert!(html(&[page()], "Rich").contains("<style"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docx_is_a_word_package() {
|
||||
let d = docx(&[page()], "Report");
|
||||
|
||||
Reference in new issue
Block a user