use std::path::Path;
use super::link::Link;
use super::Mode;
use crate::config::schema::TranscludeConfig;
use crate::render::markdown::block::{classify, BlockKind, Carry};
pub struct Picture {
pub cols: u16,
pub rows: u16,
pub payload: Vec<u8>,
}
pub enum Row {
Top(String),
Body(String, BlockKind),
Bottom,
Error(String),
Image(Picture),
Reserved,
Caption(String),
}
pub struct Expansion {
pub rows: Vec<Row>,
}
impl Expansion {
pub fn len(&self) -> usize {
self.rows.len()
}
}
pub fn expand(
link: &Link,
from: &Path,
cfg: &TranscludeConfig,
measure: u16,
mode: Mode,
) -> Expansion {
let label = link.label();
let root = super::compile::search_root(from, cfg);
if let Ok(target) = super::link::resolve(link, from, &root) {
if crate::image::looks_like_image(&target) {
return picture(&target, label, cfg, measure);
}
}
let body = match super::compile::flatten_link(link, from, cfg, mode.recurses()) {
Ok(b) => b,
Err(e) => return one_error(label, e.to_string(), measure),
};
let framed = cfg.border && mode.framed();
let mut rows = Vec::new();
if framed {
rows.push(Row::Top(label));
}
let mut carry = Carry::None;
let inner = measure.saturating_sub(if cfg.border { 4 } else { 0 }).max(8);
for (i, line) in body.lines().enumerate() {
let (kind, next) = classify(line, &carry, i == 0);
carry = next;
for (a, b) in crate::render::layout::wrap_line(line, inner) {
let piece: String = line.chars().skip(a).take(b - a).collect();
rows.push(Row::Body(piece, kind.clone()));
}
}
if rows.is_empty() || matches!(rows.last(), Some(Row::Top(_))) {
rows.push(Row::Body(String::new(), BlockKind::Blank));
}
if framed {
rows.push(Row::Bottom);
}
Expansion { rows }
}
const MAX_PICTURE_ROWS: u16 = 20;
fn picture(path: &Path, label: String, cfg: &TranscludeConfig, measure: u16) -> Expansion {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => return one_error(label, e.to_string(), measure),
};
let Some(info) = crate::image::probe(&bytes) else {
return one_error(label, "not an image this build can read".into(), measure);
};
let inner = measure.saturating_sub(if cfg.border { 4 } else { 0 }).max(8);
let (cols, rows) = crate::image::fit(info, inner, MAX_PICTURE_ROWS);
let name = path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| label.clone());
let mut out = vec![Row::Image(Picture {
cols,
rows,
payload: if info.sendable { bytes } else { Vec::new() },
})];
out.extend((1..rows).map(|_| Row::Reserved));
out.push(Row::Caption(format!("{name} · {}×{}", info.width, info.height)));
Expansion { rows: out }
}
fn one_error(label: String, why: String, measure: u16) -> Expansion {
let msg = format!("![[{label}]] — {why}");
let rows = crate::render::layout::wrap_line(&msg, measure.max(8))
.into_iter()
.map(|(a, b)| Row::Error(msg.chars().skip(a).take(b - a).collect()))
.collect();
Expansion { rows }
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn vault(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicUsize, Ordering};
static N: AtomicUsize = AtomicUsize::new(0);
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let n = N.fetch_add(1, Ordering::Relaxed);
let d = std::env::temp_dir().join(format!("shoin-pv-{tag}-{t}-{n}"));
std::fs::create_dir_all(&d).unwrap();
d
}
fn body_text(e: &Expansion) -> Vec<String> {
e.rows
.iter()
.filter_map(|r| match r {
Row::Body(t, _) => Some(t.clone()),
_ => None,
})
.collect()
}
#[test]
fn expands_a_target_with_a_labelled_border() {
let d = vault("border");
std::fs::write(d.join("frag.md"), "one\ntwo\n").unwrap();
let link = Link::parse("frag").unwrap();
let e = expand(&link, &d.join("note.md"), &TranscludeConfig::default(), 72, Mode::Short);
assert!(matches!(e.rows.first(), Some(Row::Top(l)) if l == "frag"));
assert!(matches!(e.rows.last(), Some(Row::Bottom)));
assert_eq!(body_text(&e), vec!["one", "two"]);
let cfg = TranscludeConfig { border: false, ..Default::default() };
let e = expand(&link, &d.join("note.md"), &cfg, 72, Mode::Short);
assert!(matches!(e.rows.first(), Some(Row::Body(_, _))));
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn an_unresolved_embed_says_so_on_screen() {
let d = vault("missing");
let link = Link::parse("nope").unwrap();
let e = expand(&link, &d.join("note.md"), &TranscludeConfig::default(), 72, Mode::Short);
assert_eq!(e.len(), 1, "short enough to fit one row");
match &e.rows[0] {
Row::Error(msg) => assert!(msg.contains("nope"), "{msg}"),
_ => panic!("expected an error row"),
}
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn the_mode_names_read_as_a_ladder() {
assert_eq!(Mode::parse("none"), Some(Mode::Off));
assert_eq!(Mode::parse("off"), Some(Mode::Off));
assert_eq!(Mode::parse("short"), Some(Mode::Short));
assert_eq!(Mode::parse("rec"), Some(Mode::Rec));
assert_eq!(Mode::parse("long"), Some(Mode::Rec), "the obvious word works");
assert_eq!(Mode::parse("full"), Some(Mode::Full));
assert_eq!(Mode::parse("sideways"), None);
assert_eq!(Mode::Off.name(), "none");
assert_eq!(Mode::Rec.name(), "rec");
assert!(!Mode::Off.is_on());
assert!(Mode::Short.is_on() && !Mode::Short.recurses());
assert!(Mode::Rec.recurses() && Mode::Rec.framed());
assert!(Mode::Full.recurses() && !Mode::Full.framed());
assert_eq!(Mode::default(), Mode::Off);
assert_eq!(
Mode::parse(&TranscludeConfig::default().embed),
Some(Mode::Off)
);
}
#[test]
fn the_modes_show_progressively_more() {
let d = vault("modes");
std::fs::write(d.join("inner.md"), "the innermost text\n").unwrap();
std::fs::write(d.join("frag.md"), "outer text\n\n![[inner]]\n").unwrap();
let link = Link::parse("frag").unwrap();
let cfg = TranscludeConfig::default();
let from = d.join("note.md");
let short = expand(&link, &from, &cfg, 72, Mode::Short);
let body = body_text(&short).join("\n");
assert!(body.contains("outer text"));
assert!(!body.contains("the innermost text"), "short stops at the target");
assert!(body.contains("![[inner]]"), "…leaving the nested link as text");
assert!(matches!(short.rows.first(), Some(Row::Top(_))), "short is framed");
let rec = expand(&link, &from, &cfg, 72, Mode::Rec);
let body = body_text(&rec).join("\n");
assert!(body.contains("outer text"));
assert!(body.contains("the innermost text"), "rec follows the nested embed");
assert!(!body.contains("![[inner]]"), "…and consumes the link");
assert!(matches!(rec.rows.first(), Some(Row::Top(_))), "rec is framed too");
let full = expand(&link, &from, &cfg, 72, Mode::Full);
let body = body_text(&full).join("\n");
assert!(body.contains("the innermost text"), "full recurses like rec");
assert!(
!full.rows.iter().any(|r| matches!(r, Row::Top(_) | Row::Bottom)),
"…but drops the frame"
);
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn full_matches_what_compile_would_write() {
let d = vault("fullcmp");
std::fs::write(d.join("inner.md"), "# Inner\n\ninner body\n").unwrap();
std::fs::write(d.join("frag.md"), "# Outer\n\nouter body\n\n![[inner]]\n").unwrap();
let from = d.join("note.md");
std::fs::write(&from, "![[frag]]\n").unwrap();
let cfg = TranscludeConfig::default();
let shown = body_text(&expand(
&Link::parse("frag").unwrap(),
&from,
&cfg,
72,
Mode::Full,
))
.join("\n");
let compiled = crate::transclude::compile::compile(&from, &cfg).unwrap();
for line in shown.lines().filter(|l| !l.trim().is_empty()) {
assert!(
compiled.contains(line.trim()),
"full shows {line:?}, which the compiled file does not"
);
}
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn preview_shapes_its_content_the_way_compile_will() {
let d = vault("agree");
std::fs::write(d.join("frag.md"), "# Frag\n\nbody with an anchor ^abc\n").unwrap();
let link = Link::parse("frag").unwrap();
let cfg = TranscludeConfig::default();
let from = d.join("note.md");
let e = expand(&link, &from, &cfg, 72, Mode::Short);
let body = body_text(&e).join("\n");
assert!(body.contains("## Frag"), "demoted by heading_offset, as compile does");
assert!(!body.contains("^abc"), "block ids go, as compile does");
assert!(body.contains("body with an anchor"), "…and the prose stays");
std::fs::write(&from, "![[frag]]\n").unwrap();
let compiled = crate::transclude::compile::compile(&from, &cfg).unwrap();
for line in body.lines().filter(|l| !l.trim().is_empty()) {
assert!(
compiled.contains(line.trim()),
"preview shows {line:?}, which the compiled file does not"
);
}
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn an_embedded_fence_is_contained() {
let d = vault("fence");
std::fs::write(d.join("frag.md"), "```rust\nlet x = 1;\n```\nafter\n").unwrap();
let link = Link::parse("frag").unwrap();
let e = expand(&link, &d.join("note.md"), &TranscludeConfig::default(), 72, Mode::Short);
let kinds: Vec<&BlockKind> = e
.rows
.iter()
.filter_map(|r| match r {
Row::Body(_, k) => Some(k),
_ => None,
})
.collect();
assert!(matches!(kinds[0], BlockKind::FenceOpen(_)));
assert!(matches!(kinds[1], BlockKind::FenceBody { .. }));
assert!(matches!(kinds[2], BlockKind::FenceClose));
assert!(
matches!(kinds[3], BlockKind::Paragraph),
"the fence closed inside the embed"
);
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn an_empty_target_still_has_a_body_row() {
let d = vault("empty");
std::fs::write(d.join("frag.md"), "").unwrap();
let link = Link::parse("frag").unwrap();
let e = expand(&link, &d.join("note.md"), &TranscludeConfig::default(), 72, Mode::Short);
assert_eq!(e.len(), 3, "top, one blank body, bottom");
std::fs::remove_dir_all(&d).ok();
}
const PNG: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F,
0x15, 0xC4, 0x89,
];
#[test]
fn an_image_embed_reserves_room_and_says_what_it_is() {
let d = vault("image");
let mut png = PNG.to_vec();
png[16..20].copy_from_slice(&400u32.to_be_bytes());
png[20..24].copy_from_slice(&200u32.to_be_bytes());
std::fs::write(d.join("photo.png"), &png).unwrap();
let from = d.join("comp.md");
let cfg = TranscludeConfig::default();
let e = expand(&Link::parse("photo.png").unwrap(), &from, &cfg, 60, Mode::Full);
let first = e.rows.first().expect("a row");
let Row::Image(pic) = first else {
panic!("the first row carries the picture");
};
assert!(pic.cols > 0 && pic.rows > 0, "a box with room in it");
assert!(!pic.payload.is_empty(), "a PNG goes to the terminal verbatim");
assert_eq!(
e.rows.iter().filter(|r| matches!(r, Row::Reserved)).count(),
pic.rows as usize - 1,
"the rows after the first hold the box open"
);
match e.rows.last() {
Some(Row::Caption(c)) => {
assert!(c.contains("photo.png"), "got {c:?}");
assert!(c.contains("400×200"), "the caption states the pixels: {c:?}");
}
_ => panic!("a picture is captioned"),
}
assert!(
!e.rows.iter().any(|r| matches!(r, Row::Body(..))),
"no bytes were pasted in as prose"
);
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn a_file_that_only_claims_to_be_an_image_is_an_error() {
let d = vault("notimage");
std::fs::write(d.join("fake.png"), b"# actually markdown\n").unwrap();
let from = d.join("comp.md");
let e = expand(
&Link::parse("fake.png").unwrap(),
&from,
&TranscludeConfig::default(),
60,
Mode::Full,
);
assert!(matches!(e.rows.first(), Some(Row::Error(_))), "an error, not a box");
std::fs::remove_dir_all(&d).ok();
}
}