diff --git a/README.md b/README.md index ed4cb9e4..a73400c6 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ let document = anydoc::to_document(&bytes, None)?; - **One output for every format.** Each format parses into a shared document model and renders through a single Markdown serializer, so escaping, tables, heading anchors, and footnotes behave identically whether the input was a `.doc` from 2003 or a `.pptx` from yesterday. - **Full document structure.** Headings with anchors, bold/italic/strikethrough, inline code and code blocks, links and internal cross-references, bulleted/numbered/nested/task lists with the source's own numbering, tables with merged cells and header rows, block quotes, footnotes and endnotes, and speaker notes. -- **Embedded assets.** Images and embedded objects render as their alt text in the Markdown, and the raw bytes stay available on the document model, tagged with their media type. Images with an external URL become ordinary Markdown images. +- **Embedded assets.** Images and embedded objects become Markdown image refs of the form `![alt](asset:N)`, where `N` indexes `Document.assets` (raw bytes + media type). Rewrite those hrefs after writing the files out, or consume the document model directly. Images with an external URL become ordinary Markdown images. - **Content-based format detection.** The format is read from the bytes themselves (PDF header, RTF open group, OLE stream names, ZIP package mimetype), so mislabeled files still convert correctly. - **Fast.** Pure Rust, no ML models, no external services. Median conversion time is under 5ms per document. - **Bindings that stay out of the way.** Node.js conversion runs on the libuv thread pool and never blocks the event loop; Python releases the GIL so other threads keep running. TypeScript types and Python stubs ship with the packages. diff --git a/src/render/markdown/inline.rs b/src/render/markdown/inline.rs index 1461e338..8eb3073a 100644 --- a/src/render/markdown/inline.rs +++ b/src/render/markdown/inline.rs @@ -164,10 +164,16 @@ fn render_image( escape_text(alt.trim(), ctx, EscapeOpts { in_label: true, ..Default::default() }); let _ = write!(out, "![{}]({})", alt, format_url(url)); } - // Embedded assets render as their alt text: Markdown cannot embed - // bytes, and the bytes stay available in `Document::assets`. A - // source-less image has only its alt text to offer. - ImageSource::Asset(_) | ImageSource::Unavailable => { + // Embedded assets cannot carry bytes in Markdown, but they keep a + // stable positional marker (`asset:N`) that indexes `Document::assets` + // so callers can rewrite the href after writing files out. + ImageSource::Asset(id) => { + let alt = + escape_text(alt.trim(), ctx, EscapeOpts { in_label: true, ..Default::default() }); + let _ = write!(out, "![{}](asset:{})", alt, id.0); + } + // A source-less image has only its alt text to offer. + ImageSource::Unavailable => { if !alt.trim().is_empty() { out.push_str(&escape_text( alt.trim(), diff --git a/src/render/markdown/tests.rs b/src/render/markdown/tests.rs index a4aa8067..e2195be7 100644 --- a/src/render/markdown/tests.rs +++ b/src/render/markdown/tests.rs @@ -165,6 +165,26 @@ fn sourceless_image_renders_alt_text() { assert_eq!(md, "chart\n"); } +#[test] +fn embedded_asset_image_emits_asset_href() { + use crate::model::AssetId; + let md = doc(vec![Block::Paragraph(vec![Inline::Image { + alt: "photo".into(), + source: ImageSource::Asset(AssetId(0)), + }])]); + assert_eq!(md, "![photo](asset:0)\n"); +} + +#[test] +fn embedded_asset_empty_alt_keeps_positional_marker() { + use crate::model::AssetId; + let md = doc(vec![Block::Paragraph(vec![Inline::Image { + alt: "".into(), + source: ImageSource::Asset(AssetId(3)), + }])]); + assert_eq!(md, "![](asset:3)\n"); +} + #[test] fn composite_marker_labels_are_escaped() { // M15: source-derived labels must not alter Markdown structure. diff --git a/tests/snapshots.rs b/tests/snapshots.rs index 477a1adc..ebf2b231 100644 --- a/tests/snapshots.rs +++ b/tests/snapshots.rs @@ -166,8 +166,8 @@ fn doc_inline_picture_is_retained() { ); } -/// The RTF `\pict` payload is retained as an asset (the Markdown output -/// shows only the alt text, which is empty for pictures without one). +/// The RTF `\pict` payload is retained as an asset (Markdown references it +/// as `asset:N` when the picture appears as an image inline). #[test] fn rtf_inline_picture_is_retained() { let bytes = std::fs::read(fixture_root().join("rtf").join("text.rtf")).unwrap(); diff --git a/tests/snapshots/snapshots__doc__text.doc.snap b/tests/snapshots/snapshots__doc__text.doc.snap index b6c0ef11..73c4911b 100644 --- a/tests/snapshots/snapshots__doc__text.doc.snap +++ b/tests/snapshots/snapshots__doc__text.doc.snap @@ -66,7 +66,7 @@ Jump to the bookmarked paragraph. ## Objects -Inline image: done. +Inline image: ![](asset:0) done. Text box: after the box. diff --git a/tests/snapshots/snapshots__docx__handmade-manyrefs.docx.snap b/tests/snapshots/snapshots__docx__handmade-manyrefs.docx.snap index d6669ef6..bf51ac05 100644 --- a/tests/snapshots/snapshots__docx__handmade-manyrefs.docx.snap +++ b/tests/snapshots/snapshots__docx__handmade-manyrefs.docx.snap @@ -3,3 +3,143 @@ source: tests/snapshots.rs expression: output --- Seventy references to one image follow. + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) + +![](asset:0) diff --git a/tests/snapshots/snapshots__docx__handmade-rich.docx.snap b/tests/snapshots/snapshots__docx__handmade-rich.docx.snap index 80103366..38580ca5 100644 --- a/tests/snapshots/snapshots__docx__handmade-rich.docx.snap +++ b/tests/snapshots/snapshots__docx__handmade-rich.docx.snap @@ -15,7 +15,7 @@ Rich objects follow. - Build - Ship -tiny dot image +![tiny dot image](asset:0) Embedded object: Excel.Sheet.12 diff --git a/tests/snapshots/snapshots__docx__text.docx.snap b/tests/snapshots/snapshots__docx__text.docx.snap index 655b643e..f30ec86f 100644 --- a/tests/snapshots/snapshots__docx__text.docx.snap +++ b/tests/snapshots/snapshots__docx__text.docx.snap @@ -66,7 +66,7 @@ Jump to [the bookmarked paragraph](#plainmark). ## Objects -Inline image: tiny red dot done. +Inline image: ![tiny red dot](asset:0) done. Text box: after the box. diff --git a/tests/snapshots/snapshots__epub__book.epub.snap b/tests/snapshots/snapshots__epub__book.epub.snap index 5e66bd3d..c0dd0de2 100644 --- a/tests/snapshots/snapshots__epub__book.epub.snap +++ b/tests/snapshots/snapshots__epub__book.epub.snap @@ -26,7 +26,7 @@ A list of things: See [Chapter Two](#epub-text-ch002-xhtml-chapter-two) for the table, or jump straight to [the marked paragraph](#epub-text-ch002-xhtml-markpoint). -tiny dot tiny dot +![tiny dot](asset:0) ![tiny dot](asset:0) diff --git a/tests/snapshots/snapshots__malformed__corrupt-styles--skips.docx.snap b/tests/snapshots/snapshots__malformed__corrupt-styles--skips.docx.snap index 750148a5..ac25bc1e 100644 --- a/tests/snapshots/snapshots__malformed__corrupt-styles--skips.docx.snap +++ b/tests/snapshots/snapshots__malformed__corrupt-styles--skips.docx.snap @@ -66,7 +66,7 @@ Jump to [the bookmarked paragraph](#plainmark). Objects -Inline image: tiny red dot done. +Inline image: ![tiny red dot](asset:0) done. Text box: after the box. diff --git a/tests/snapshots/snapshots__malformed__missing-styles--skips.docx.snap b/tests/snapshots/snapshots__malformed__missing-styles--skips.docx.snap index 750148a5..ac25bc1e 100644 --- a/tests/snapshots/snapshots__malformed__missing-styles--skips.docx.snap +++ b/tests/snapshots/snapshots__malformed__missing-styles--skips.docx.snap @@ -66,7 +66,7 @@ Jump to [the bookmarked paragraph](#plainmark). Objects -Inline image: tiny red dot done. +Inline image: ![tiny red dot](asset:0) done. Text box: after the box. diff --git a/tests/snapshots/snapshots__odt__text.odt.snap b/tests/snapshots/snapshots__odt__text.odt.snap index 885706ee..820ce773 100644 --- a/tests/snapshots/snapshots__odt__text.odt.snap +++ b/tests/snapshots/snapshots__odt__text.odt.snap @@ -65,7 +65,7 @@ Jump to [the bookmarked paragraph](#plainmark). ## Objects -Inline image: tiny red dot done. +Inline image: ![tiny red dot](asset:0) done. Text box: after the box. diff --git a/tests/snapshots/snapshots__rtf__text.rtf.snap b/tests/snapshots/snapshots__rtf__text.rtf.snap index f18ce8ea..8b3a8ccd 100644 --- a/tests/snapshots/snapshots__rtf__text.rtf.snap +++ b/tests/snapshots/snapshots__rtf__text.rtf.snap @@ -65,7 +65,7 @@ Jump to [the bookmarked paragraph](#plainmark). ## Objects -Inline image: done. +Inline image: ![](asset:0) done. Text box: Inside the text box.