diff --git a/README.md b/README.md index ed4cb9e..a73400c 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 1461e33..8eb3073 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 a4aa806..e2195be 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 477a1ad..ebf2b23 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 b6c0ef1..73c4911 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 d6669ef..bf51ac0 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 8010336..38580ca 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 655b643..f30ec86 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 5e66bd3..c0dd0de 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 750148a..ac25bc1 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 750148a..ac25bc1 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 885706e..820ce77 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 f18ce8e..8b3a8cc 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.