M10: PDF ▸ Markdown export, through doc_export_office with a .md path (#1595)

This commit is contained in:
Adrian committed 2026-10-28 15:49:39 -04:00
1 parent 67d4f18179
commit bcaead5404
8 files changed
+188 -7

No files matched your search

+1
View File
@@ -449,6 +449,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
Newest first: the date, what moved, and the new overall estimate.
- **2026-10-28 (M10, #1595):** Export to Markdown: `doc_export_office` with a `.md` or `.markdown` path writes CommonMark with GitHub pipe tables (headings, paragraphs with bold and italic, tables with their first row as the header, a rule between pages); text that Markdown would read as markup is escaped. Images, lists and links are not in the export's layout model and are left out. No menu item yet. Estimates unchanged.
- **2026-10-12 (M3, GNOME title bar, #553):** On GNOME's Wayland session, whose compositor draws no window frames (it offers no `zxdg_decoration_manager_v1`), PdfCraft no longer shows winit's fallback title bar: it turns the frame off and the tab strip becomes the header bar, as in GNOME's own apps. Minimize, maximize/restore and close are drawn in Adwaita's style, in the order and on the side GNOME's `button-layout` names (Fedora's default shows only close); the window's edges and corners resize it, and a hairline outlines it while it isn't maximized. Preferences ▸ Use system title bar (shown only there) gives the window back winit's frame at once, without a restart (part of #1001), and `PDFCRAFT_TITLEBAR=app|system` overrides the choice. KDE, Sway, Hyprland, COSMIC and X11 keep the frame their desktop draws. Not done: right-clicking the header bar doesn't open GNOME's window menu (egui can't ask for it), the window has no shadow or rounded corners, the resize edges lie just inside the window rather than outside it, and Windows still uses its system frame (the rest of #1001). Estimates unchanged.
- **2026-10-12 (M0, build label):** A local or patched build can carry a label, set at compile time with `PDFCRAFT_BUILD_LABEL`: `pdfcraft --version`, `pdfcraft-cli --version`, About and the log show `0.7.1 (titlebar)`, and `packaging/linux/package.sh` names the files `pdfcraft-0.7.1-titlebar-…` and makes the .rpm/.deb release `1.titlebar`, which upgrades over the release of the same version (and the next release over it). The version itself is unchanged, so the update check still compares it with published releases. Unlabelled builds are as before. Estimates unchanged.
- **2026-10-11 (M6, #1208):** Dropdown and list-box Field Properties expose the existing Left/Center/Right alignment picker. Existing alignment stays intact until changed; cancel, undo/redo and save/reopen are covered through the real UI without changing options, values or flags. Estimates unchanged.
+1 -1
View File
@@ -5,7 +5,7 @@ impl Automation {
let path = self.resolve(a.str("path")?, true)?;
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 or rtf)")))?;
.ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html, rtf or md)")))?;
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).
+1 -1
View File
@@ -1154,7 +1154,7 @@ 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 or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), 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 are listed in truncated_pages, with a warning; the text and images of the forms left out are 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. 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 are listed in truncated_pages, with a warning; the text and images of the forms left out are missing.")
.cmd("export.docx")
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["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.")
+5 -1
View File
@@ -6830,7 +6830,7 @@ fn exporting_to_word_html_and_rtf() {
let dir = workdir("office");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
for ext in ["docx", "pptx", "html", "rtf"] {
for ext in ["docx", "pptx", "html", "rtf", "md"] {
let r = ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": format!("a.{ext}") }));
assert_eq!(r["format"], ext);
}
@@ -6839,6 +6839,10 @@ fn exporting_to_word_html_and_rtf() {
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"));
// #1595: Markdown, with a rule between pages.
let md = std::fs::read_to_string(dir.join("a.md")).unwrap();
assert!(md.contains("Page 1") && md.contains("Page 3") && md.matches("\n---\n").count() == 2, "{md}");
assert_eq!(ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "b.markdown" }))["format"], "md");
assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err());
}
+8 -3
View File
@@ -141,6 +141,7 @@ pub enum OfficeFormat {
Pptx,
Html,
Rtf,
Markdown,
}
impl OfficeFormat {
@@ -150,6 +151,7 @@ impl OfficeFormat {
OfficeFormat::Pptx => "pptx",
OfficeFormat::Html => "html",
OfficeFormat::Rtf => "rtf",
OfficeFormat::Markdown => "md",
}
}
@@ -159,12 +161,13 @@ impl OfficeFormat {
"pptx" => Some(OfficeFormat::Pptx),
"html" | "htm" => Some(OfficeFormat::Html),
"rtf" => Some(OfficeFormat::Rtf),
"md" | "markdown" => Some(OfficeFormat::Markdown),
_ => None,
}
}
}
/// A Word, PowerPoint, HTML or RTF export ([`crate::Document::export_office_checked`]).
/// A Word, PowerPoint, HTML, RTF or Markdown export ([`crate::Document::export_office_checked`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct OfficeExport {
/// The exported file.
@@ -275,14 +278,16 @@ impl crate::Document {
/// [`Self::export_office`], and the pages whose text it had to leave out
/// ([`OfficeExport::truncated_pages`]).
pub fn export_office_checked(&self, format: OfficeFormat) -> OfficeExport {
// RTF leaves pictures out.
let (pages, truncated_pages) = self.export_pages_checked(format != OfficeFormat::Rtf, MAX_EXPORT_IMAGE_BYTES);
// RTF and Markdown leave pictures out.
let (pages, truncated_pages) =
self.export_pages_checked(!matches!(format, OfficeFormat::Rtf | OfficeFormat::Markdown), MAX_EXPORT_IMAGE_BYTES);
let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string());
let bytes = match format {
OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title),
OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title),
OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(),
OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(),
OfficeFormat::Markdown => pdfcraft_export::markdown(&pages).into_bytes(),
};
OfficeExport { bytes, truncated_pages }
}
+104
View File
@@ -0,0 +1,104 @@
use super::as_html::allowed;
use super::layout::{Cell, Item, MAX_COLS, Page, items};
/// Markdown's deepest heading.
const MAX_HEADING: u8 = 6;
/// `s` as Markdown text on one line: line breaks become spaces, control characters are dropped,
/// and every character Markdown would read as markup is escaped with a backslash.
fn md_text(s: &str) -> String {
let mut o = String::with_capacity(s.len());
for c in s.chars().filter(|c| allowed(*c)) {
match c {
'\n' | '\r' | '\t' => o.push(' '),
'\\' | '`' | '*' | '_' | '[' | ']' | '<' | '>' | '#' | '|' | '~' | '!' | '&' => {
o.push('\\');
o.push(c);
}
c => o.push(c),
}
}
o
}
/// `line` with its start escaped where Markdown would read a list item there (`- `, `+ `,
/// `1. `, `1) `); the other block markers (`#`, `>`) are escaped by [`md_text`] already.
fn block_start(line: String) -> String {
let digits = line.bytes().take_while(u8::is_ascii_digit).count();
match line.as_bytes().get(digits) {
Some(b'.' | b')') if digits > 0 => format!("{}\\{}", line.get(..digits).unwrap_or_default(), line.get(digits..).unwrap_or_default()),
Some(b'-' | b'+') if digits == 0 => format!("\\{line}"),
_ => line,
}
}
/// `text` (already escaped) in bold and/or italic. The markers go around the text without its
/// outer spaces, which Markdown would not read as emphasis.
pub(crate) fn emphasis(text: &str, bold: bool, italic: bool) -> String {
let core = text.trim();
if core.is_empty() || !(bold || italic) {
return text.to_string();
}
let lead = text.get(..text.len().saturating_sub(text.trim_start().len())).unwrap_or_default();
let trail = text.get(text.trim_end().len()..).unwrap_or_default();
let mark = match (bold, italic) {
(true, true) => "***",
(true, false) => "**",
_ => "*",
};
format!("{lead}{mark}{core}{mark}{trail}")
}
fn cell(c: &Cell) -> String {
emphasis(&md_text(&c.text), c.bold, c.italic)
}
/// Markdown (CommonMark, with GitHub's pipe tables): headings, paragraphs with bold and italic,
/// tables with their first row as the header, and a rule between pages. Images are left out.
pub fn markdown(pages: &[Page]) -> String {
let mut blocks: Vec<String> = Vec::new();
for it in items(pages) {
match it {
Item::Para(runs, lvl) => {
// A heading's weight is the heading's own, as in the HTML export.
let text: String = runs.iter().map(|r| emphasis(&md_text(&r.text), r.block.bold && lvl == 0, r.block.italic)).collect();
let text = text.trim();
if text.is_empty() {
continue;
}
blocks.push(match lvl {
0 => block_start(text.to_string()),
l => format!("{} {text}", "#".repeat(usize::from(l.min(MAX_HEADING)))),
});
}
Item::Table(t) => {
// Each row as wide as the widest; a cell spanning columns is followed by empty ones.
// Detected tables have at most MAX_COLS columns, and a span never counts for more.
let span = |c: &Cell| c.span.clamp(1, MAX_COLS);
let width = t.rows.iter().map(|r| r.iter().map(span).fold(0usize, usize::saturating_add)).max().unwrap_or(0);
if width == 0 {
continue;
}
let mut lines = Vec::with_capacity(t.rows.len().saturating_add(1));
for (i, row) in t.rows.iter().enumerate() {
let mut cells: Vec<String> = Vec::with_capacity(width);
for c in row {
cells.push(cell(c));
cells.extend(std::iter::repeat_n(String::new(), span(c).saturating_sub(1)));
}
cells.resize(width, String::new());
lines.push(format!("| {} |", cells.join(" | ")));
if i == 0 {
lines.push(format!("|{}", " --- |".repeat(width)));
}
}
blocks.push(lines.join("\n"));
}
Item::Img(_) => {}
Item::PageBreak => blocks.push("---".into()),
}
}
let mut s = blocks.join("\n\n");
s.push('\n');
s
}
+5 -1
View File
@@ -1,4 +1,4 @@
//! pdfcraft-export — Export a PDF ▸ Word, HTML, RTF (L4).
//! pdfcraft-export — Export a PDF ▸ Word, HTML, RTF, Markdown (L4).
//!
//! The engine reduces each page to [`Page`]: paragraphs (text, box, size, bold/italic) and
//! images (encoded bytes and box), in reading order. The writers turn that into a flowing
@@ -18,6 +18,7 @@ pub use zip::Zip;
mod as_docx;
mod as_html;
mod as_markdown;
mod as_rtf;
mod layout;
@@ -29,6 +30,9 @@ pub(crate) use as_html::esc;
pub use as_html::html;
#[cfg(test)]
use as_html::{allowed, base64};
#[cfg(test)]
use as_markdown::emphasis;
pub use as_markdown::markdown;
pub use as_rtf::rtf;
pub use layout::{Block, Cell, Image, Page, Table};
#[cfg(test)]
+63
View File
@@ -769,3 +769,66 @@ fn a_paragraph_is_a_heading_only_when_its_text_is() {
assert!(!xml.contains("Heading2"), "the lead-in made its paragraph a heading: {xml}");
assert_eq!(word_paragraphs(&xml).len(), 2, "{xml}");
}
/// #1595: Markdown gets the headings, paragraphs and a rule between pages, in reading order;
/// images are left out.
#[test]
fn markdown_has_headings_paragraphs_and_page_rules_in_order() {
let md = markdown(&[page(), page()]);
let order: Vec<usize> = ["# Annual Report\n\n", "## Overview\n\n", "Some \\<body\\> text \\& more", "Body text after the picture", "\n\n---\n\n"]
.iter()
.map(|x| md.find(x).unwrap_or_else(|| panic!("{x:?} missing in {md}")))
.collect();
assert!(order.windows(2).all(|w| w[0] < w[1]), "{md}");
assert_eq!(md.matches("\n---\n").count(), 1, "one rule between two pages: {md}");
assert_eq!(md.matches("# Annual Report").count(), 2, "{md}");
assert!(!md.contains("PNG") && !md.contains("!["), "images are left out: {md}");
assert!(md.ends_with(".\n") && !md.ends_with("\n\n"), "{md:?}");
}
/// #1595: text Markdown would read as markup stays text, emphasis wraps a run without its outer
/// spaces, and a detected table becomes a pipe table with its first row as the header.
#[test]
fn markdown_escapes_markup_and_keeps_emphasis_and_tables() {
let b = |t: &str, y: f64, bold: bool, italic: bool| Block {
text: t.into(),
rect: [72.0, y, 500.0, y + 11.0],
size: 11.0,
bold,
italic,
color: [0.0; 3],
rotation: 0.0,
space_around: [false; 2],
};
let p = Page {
width: 612.0,
height: 792.0,
blocks: vec![
b("- not a list item, and long enough to make eleven points the body size here", 700.0, false, false),
b("1. not a numbered item either", 650.0, false, false),
b("*stars* _under_ `tick` [link](x) a|b # #tag\u{1}\nnext", 600.0, false, false),
b("Strong words in a sentence of their own.", 550.0, true, false),
b("slanted", 500.0, false, true),
],
images: Vec::new(),
background: None,
};
let md = markdown(&[p]);
assert!(md.contains("\\- not a list item"), "{md}");
assert!(md.contains("1\\. not a numbered item"), "{md}");
assert!(md.contains("\\*stars\\* \\_under\\_ \\`tick\\` \\[link\\](x) a\\|b \\# \\#tag next"), "{md}");
assert!(md.contains("**Strong words in a sentence of their own.**"), "{md}");
// Markdown reads no emphasis where a marker sits next to a space: the outer spaces go outside.
assert_eq!(emphasis(" two words ", true, false), " **two words** ");
assert_eq!(emphasis("both", true, true), "***both***");
assert_eq!(emphasis(" ", true, true), " ", "nothing to mark");
assert!(md.contains("*slanted*"), "{md}");
assert!(!md.chars().any(|c| !allowed(c)), "{md:?}");
let md = markdown(&[table_page()]);
let rows: Vec<&str> = md.lines().filter(|l| l.starts_with('|')).collect();
assert_eq!(rows.first(), Some(&"| Meter | Unit | Reading |"), "{md}");
assert_eq!(rows.get(1), Some(&"| --- | --- | --- |"), "{md}");
assert!(rows.contains(&"| A1 | kWh | 37414.00 |"), "{md}");
assert!(rows.iter().all(|r| r.matches(" | ").count() == 2), "every row has the header's three cells: {md}");
}