git.lucas.co / cce-vault
notes vault library and CLI (Obsidian-compatible)
git clone https://git.lucas.co/cce-vault.git

commit41ca52bd51ea3655a791c58005be9fce939e4333
parent827f72f675
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 21:51
markdown: a line that is only an embed is a block, even mid-paragraph

A pasted image lands on its own line inside a paragraph (no blank lines
around it); Obsidian draws it as the picture, and so does live preview,
which goes by the line. The parsed document now splits such a line out as
a Block::Embed, with the lines around it staying paragraphs, so the
reading view agrees. An embed inside a sentence stays inline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/markdown.rs | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 71 insertions(+), 4 deletions(-)

diff --git a/src/markdown.rs b/src/markdown.rs
index 9b42e4f..1efc04d 100644
--- a/src/markdown.rs
+++ b/src/markdown.rs
@@ -239,8 +239,9 @@ impl<'a> Builder<'a> {
         if let Some(line) = self.inline_line.take() {
             let spans = self.take_spans();
             if !spans.is_empty() {
-                let block = paragraph_or_embed(spans, line);
-                self.top().blocks().push(block);
+                for block in paragraph_blocks(spans, line) {
+                    self.top().blocks().push(block);
+                }
             }
         }
     }
@@ -298,6 +299,59 @@ fn push_with_tags(spans: &mut Vec<Span>, text: &str, style: Style) {
     push_span(spans, &text[start..], style, None);
 }
 
+/// A paragraph's blocks: a line of it that is nothing but one embed is an
+/// [`Block::Embed`] of its own (Obsidian shows a picture wherever it sits,
+/// and a pasted image lands on its own line inside a paragraph), and the
+/// lines between stay paragraphs. One line in, as before.
+fn paragraph_blocks(spans: Vec<Span>, line: usize) -> Vec<Block> {
+    if spans.is_empty() {
+        return Vec::new();
+    }
+    // Split into source lines at the soft and hard breaks ('\n' in text).
+    let mut lines: Vec<Vec<Span>> = vec![Vec::new()];
+    for s in &spans {
+        for (i, part) in s.text.split('\n').enumerate() {
+            if i > 0 {
+                lines.push(Vec::new());
+            }
+            if !part.is_empty() {
+                lines.last_mut().expect("one line").push(Span { text: part.to_string(), ..s.clone() });
+            }
+        }
+    }
+    let embed_line = |l: &[Span]| {
+        let mut real = l.iter().filter(|s| s.link.is_some() || !s.text.trim().is_empty());
+        matches!((real.next(), real.next()), (Some(Span { link: Some(SpanLink::Embed { .. }), .. }), None))
+    };
+    if lines.len() < 2 || !lines.iter().any(|l| embed_line(l)) {
+        return vec![paragraph_or_embed(spans, line)];
+    }
+    let mut out = Vec::new();
+    let mut para: Vec<Span> = Vec::new();
+    let mut para_line = line;
+    let flush = |para: &mut Vec<Span>, at: usize, out: &mut Vec<Block>| {
+        if !para.is_empty() {
+            out.push(Block::Paragraph { spans: std::mem::take(para), line: at });
+        }
+    };
+    for (i, l) in lines.into_iter().enumerate() {
+        if embed_line(&l) {
+            flush(&mut para, para_line, &mut out);
+            let embed = l.into_iter().find(|s| s.link.is_some()).expect("an embed line has one");
+            out.push(paragraph_or_embed(vec![embed], line + i));
+        } else {
+            if para.is_empty() {
+                para_line = line + i;
+            } else {
+                para.push(Span { text: "\n".into(), style: Style::default(), link: None });
+            }
+            para.extend(l);
+        }
+    }
+    flush(&mut para, para_line, &mut out);
+    out
+}
+
 fn paragraph_or_embed(spans: Vec<Span>, line: usize) -> Block {
     if let [Span { text, link: Some(SpanLink::Embed { target, subpath }), .. }] = spans.as_slice() {
         return Block::Embed { target: target.clone(), subpath: subpath.clone(), size: embed_size(text), line };
@@ -468,8 +522,8 @@ pub fn blocks(src: &str) -> Vec<Block> {
             Event::End(TagEnd::Paragraph) => {
                 let spans = b.take_spans();
                 let line = b.line(range.start);
-                if !spans.is_empty() {
-                    b.push_block(paragraph_or_embed(spans, line));
+                for block in paragraph_blocks(spans, line) {
+                    b.push_block(block);
                 }
             }
             Event::End(TagEnd::Heading(level)) => {
@@ -771,6 +825,19 @@ mod tests {
         assert!(matches!(doc[3], Block::Rule { line: 10 }));
     }
 
+    #[test]
+    fn an_embed_on_its_own_line_inside_a_paragraph_is_a_block() {
+        let doc = blocks("Some text.\n![[pic.png|200]]\nmore *text*\nand more\n");
+        assert_eq!(doc.len(), 3, "{doc:?}");
+        assert!(matches!(&doc[0], Block::Paragraph { line: 0, .. }));
+        assert_eq!(doc[1], Block::Embed { target: "pic.png".into(), subpath: None, size: Some(EmbedSize { width: 200, height: None }), line: 1 });
+        let Block::Paragraph { spans, line: 2 } = &doc[2] else { panic!("{doc:?}") };
+        assert_eq!(plain(spans), "more text\nand more");
+        // Mid-sentence it stays inline; a plain paragraph is untouched.
+        assert!(matches!(&blocks("see ![[pic.png]] here\n")[0], Block::Paragraph { .. }));
+        assert_eq!(blocks("a\nb\n").len(), 1);
+    }
+
     #[test]
     fn embed_sizes() {
         let doc = blocks("![[a.png|300]]\n\n![[b.png|300x200]]\n\n![alt|120](c.png)\n\n![[d.png|caption]]\n");