From eb696a3fdf632d3d2167096fcaeb23593ee08c43 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:20:23 -0600 Subject: [PATCH] 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 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/automation/src/a11y/documents.rs | 7 +++++++ crates/automation/src/tools.rs | 4 ++-- crates/automation/tests/it/automation.rs | 11 ++++++++++ crates/engine/src/compare.rs | 4 ++++ crates/export/src/as_html.rs | 26 +++++++++++++++++------- crates/export/src/lib.rs | 2 +- crates/export/src/tests.rs | 18 ++++++++++++++++ 7 files changed, 62 insertions(+), 10 deletions(-) diff --git a/crates/automation/src/a11y/documents.rs b/crates/automation/src/a11y/documents.rs index 295502215a..5ae3d4fe46 100644 --- a/crates/automation/src/a11y/documents.rs +++ b/crates/automation/src/a11y/documents.rs @@ -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). diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index ba5892a451..24f81588cf 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1194,9 +1194,9 @@ pub fn tools() -> Vec { 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") diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index c9b50755e8..875531b81a 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -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("
").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(" "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(), }; diff --git a/crates/export/src/as_html.rs b/crates/export/src/as_html.rs index 7aeea595a8..3bc6709e69 100644 --- a/crates/export/src/as_html.rs +++ b/crates/export/src/as_html.rs @@ -38,11 +38,23 @@ pub(crate) fn base64(data: &[u8]) -> String { /// One HTML file: headings and paragraphs, tables as real `` 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!( - "\n\n\n\n{}\n\n\n\n", - esc(title) + "\n\n\n\n{}\n{}\n\n\n", + esc(title), + if simple { + "" + } else { + "" + } ); 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!("{run}"); } 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!("{t}

\n")), l => s.push_str(&format!("{t}\n")), @@ -84,7 +96,7 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi body = format!("{body}"); } 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!("{body}")); } s.push_str("\n"); diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index db53ee59c8..2b82919bbf 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -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}; diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 27cd8264e0..f630d98d5d 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -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 ["", "", "", "