use std::collections::BTreeMap;
use carta_ast::{
ApiVersion, Attr, Block, Document, Format, Inline, MetaValue, Target, ToCompactString,
};
use carta_core::media::{base64_decode, content_addressed_name};
use carta_core::{Error, MediaBag, Reader, ReaderOptions, Result};
use serde_json::Value;
use crate::commonmark::CommonmarkReader;
#[derive(Debug, Default, Clone, Copy)]
pub struct IpynbReader;
impl Reader for IpynbReader {
fn read(&self, input: &str, options: &ReaderOptions) -> Result<Document> {
self.read_media(input, options)
.map(|(document, _)| document)
}
fn read_media(&self, input: &str, options: &ReaderOptions) -> Result<(Document, MediaBag)> {
let notebook: Value = serde_json::from_str(input)?;
let nbformat = notebook
.get("nbformat")
.and_then(Value::as_i64)
.unwrap_or(4);
if nbformat < 4 {
return Err(Error::UnsupportedFormat(format!(
"notebook format version {nbformat} (only nbformat 4 and later are read)"
)));
}
let nbformat_minor = notebook
.get("nbformat_minor")
.and_then(Value::as_i64)
.unwrap_or(0);
let language = notebook_language(¬ebook);
let meta = build_meta(¬ebook, nbformat, nbformat_minor);
let mut media = MediaBag::new();
let mut blocks = Vec::new();
if let Some(Value::Array(cells)) = notebook.get("cells") {
for cell in cells {
if let Some(block) = cell_to_block(cell, &language, options, &mut media)? {
blocks.push(block);
}
}
}
let document = Document {
api_version: ApiVersion::default(),
meta: meta.into_iter().map(|(k, v)| (k.into(), v)).collect(),
blocks,
};
Ok((document, media))
}
}
fn notebook_language(notebook: &Value) -> String {
notebook
.get("metadata")
.and_then(|metadata| metadata.get("kernelspec"))
.and_then(|kernelspec| kernelspec.get("language"))
.and_then(Value::as_str)
.unwrap_or("python")
.to_owned()
}
fn build_meta(notebook: &Value, nbformat: i64, nbformat_minor: i64) -> BTreeMap<String, MetaValue> {
let mut jupyter: BTreeMap<String, MetaValue> = BTreeMap::new();
if let Some(Value::Object(metadata)) = notebook.get("metadata") {
for (key, value) in metadata {
jupyter.insert(key.clone(), meta_value(value));
}
}
jupyter.insert(
"nbformat".to_owned(),
MetaValue::MetaString(nbformat.to_compact_string()),
);
jupyter.insert(
"nbformat_minor".to_owned(),
MetaValue::MetaString(nbformat_minor.to_compact_string()),
);
let mut meta = BTreeMap::new();
meta.insert(
"jupyter".to_owned(),
MetaValue::MetaMap(jupyter.into_iter().map(|(k, v)| (k.into(), v)).collect()),
);
meta
}
fn meta_value(value: &Value) -> MetaValue {
match value {
Value::Null => MetaValue::MetaString(carta_ast::Text::default()),
Value::Bool(flag) => MetaValue::MetaBool(*flag),
Value::Number(number) => MetaValue::MetaString(meta_number(number).into()),
Value::String(text) => MetaValue::MetaString(text.clone().into()),
Value::Array(items) => MetaValue::MetaList(items.iter().map(meta_value).collect()),
Value::Object(map) => MetaValue::MetaMap(
map.iter()
.map(|(key, value)| (key.clone().into(), meta_value(value)))
.collect(),
),
}
}
fn meta_number(number: &serde_json::Number) -> String {
if let Some(integer) = number.as_i64() {
return integer.to_string();
}
if let Some(integer) = number.as_u64() {
return integer.to_string();
}
match number.as_f64() {
Some(value) if value.is_finite() && value.fract() == 0.0 => integer_string(value),
Some(value) => general_decimal(value),
None => number.to_string(),
}
}
fn json_number(number: &serde_json::Number) -> String {
if let Some(integer) = number.as_i64() {
return integer.to_string();
}
if let Some(integer) = number.as_u64() {
return integer.to_string();
}
match number.as_f64() {
Some(value) => general_decimal(value),
None => number.to_string(),
}
}
fn integer_string(value: f64) -> String {
if value == 0.0 {
return "0".to_owned();
}
format!("{value}")
}
fn general_decimal(value: f64) -> String {
if value == 0.0 {
return "0.0".to_owned();
}
let (digits, exponent) = shortest_digits(value.abs());
let body = if (-1..=6).contains(&exponent) {
fixed_notation(&digits, exponent)
} else {
scientific_notation(&digits, exponent)
};
if value.is_sign_negative() {
format!("-{body}")
} else {
body
}
}
fn shortest_digits(magnitude: f64) -> (String, i32) {
let formatted = format!("{magnitude:e}");
let (mantissa, exponent) = match formatted.split_once('e') {
Some((mantissa, exponent)) => (mantissa, exponent.parse::<i32>().unwrap_or(0)),
None => (formatted.as_str(), 0),
};
let digits = mantissa.chars().filter(char::is_ascii_digit).collect();
(digits, exponent)
}
fn fixed_notation(digits: &str, exponent: i32) -> String {
if exponent < 0 {
let leading_zeros = usize::try_from((-exponent - 1).max(0)).unwrap_or(0);
return format!("0.{}{digits}", "0".repeat(leading_zeros));
}
let integer_len = usize::try_from(exponent).unwrap_or(0) + 1;
if digits.len() <= integer_len {
let trailing_zeros = integer_len - digits.len();
format!("{digits}{}.0", "0".repeat(trailing_zeros))
} else {
let (integer_part, fraction) = digits.split_at(integer_len);
format!("{integer_part}.{fraction}")
}
}
fn scientific_notation(digits: &str, exponent: i32) -> String {
let (first, rest) = digits.split_at(1.min(digits.len()));
let mantissa = if rest.is_empty() {
format!("{first}.0")
} else {
format!("{first}.{rest}")
};
format!("{mantissa}e{exponent}")
}
fn json_render(value: &Value) -> String {
let mut out = String::new();
json_write(value, &mut out);
out
}
fn json_write(value: &Value, out: &mut String) {
match value {
Value::Number(number) => out.push_str(&json_number(number)),
Value::Array(items) => {
out.push('[');
for (index, item) in items.iter().enumerate() {
if index != 0 {
out.push(',');
}
json_write(item, out);
}
out.push(']');
}
Value::Object(map) => {
out.push('{');
for (index, (key, item)) in map.iter().enumerate() {
if index != 0 {
out.push(',');
}
out.push_str(&Value::String(key.clone()).to_string());
out.push(':');
json_write(item, out);
}
out.push('}');
}
other => out.push_str(&other.to_string()),
}
}
fn cell_to_block(
cell: &Value,
language: &str,
options: &ReaderOptions,
media: &mut MediaBag,
) -> Result<Option<Block>> {
let Some(kind) = cell.get("cell_type").and_then(Value::as_str) else {
return Ok(None);
};
let attr = cell_attr(cell, kind);
let block = match kind {
"markdown" => Block::Div(Box::new(attr), markdown_cell_blocks(cell, options, media)?),
"code" => Block::Div(Box::new(attr), code_cell_blocks(cell, language, media)),
"raw" => Block::Div(Box::new(attr), vec![raw_cell_block(cell)]),
_ => return Ok(None),
};
Ok(Some(block))
}
fn cell_attr(cell: &Value, kind: &str) -> Attr {
let id = cell
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
let classes = vec!["cell".to_owned(), kind.to_owned()];
let mut attributes = Vec::new();
if kind == "code"
&& let Some(count) = cell.get("execution_count").and_then(Value::as_i64)
{
attributes.push(("execution_count".to_owned(), count.to_string()));
}
if let Some(Value::Object(metadata)) = cell.get("metadata") {
for (key, value) in metadata {
attributes.push((key.clone(), attribute_value(value)));
}
}
Attr {
id: id.into(),
classes: classes.into_iter().map(Into::into).collect(),
attributes: attributes
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
}
}
fn attribute_value(value: &Value) -> String {
match value {
Value::String(text)
if text.is_empty() || is_integer_literal(text) || text == "true" || text == "false" =>
{
format!("\"{text}\"")
}
Value::String(text) => text.clone(),
other => json_render(other),
}
}
fn is_integer_literal(text: &str) -> bool {
!text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit())
}
fn markdown_cell_blocks(
cell: &Value,
options: &ReaderOptions,
media: &mut MediaBag,
) -> Result<Vec<Block>> {
let source = multiline_text(cell.get("source"));
let mut markdown_options = ReaderOptions::default();
markdown_options.extensions = options.extensions;
markdown_options.greedy_paragraphs = true;
let mut blocks = CommonmarkReader.read(&source, &markdown_options)?.blocks;
let prefix = cell
.get("id")
.map(|id| format!("{}-", id.as_str().unwrap_or_default()));
capture_attachments(cell, prefix.as_deref(), media);
let prefix = prefix.as_deref().unwrap_or_default();
carta_core::walk::for_each_image_target(&mut blocks, &mut |target| {
if let Some(bare) = target.url.strip_prefix("attachment:") {
target.url = format!("{prefix}{bare}").into();
}
});
Ok(blocks)
}
fn capture_attachments(cell: &Value, prefix: Option<&str>, media: &mut MediaBag) {
let Some(Value::Object(attachments)) = cell.get("attachments") else {
return;
};
for (reference, bundle) in attachments {
let Value::Object(by_mime) = bundle else {
continue;
};
let chosen = by_mime
.iter()
.find(|(mime, _)| is_image_like(mime))
.or_else(|| by_mime.iter().next());
let Some((mime, payload)) = chosen else {
continue;
};
let name = match prefix {
Some(prefix) => format!("{prefix}{reference}"),
None => reference.clone(),
};
media.insert(name, Some(mime.clone()), decode_payload(mime, payload));
}
}
fn code_cell_blocks(cell: &Value, language: &str, media: &mut MediaBag) -> Vec<Block> {
let source = multiline_text(cell.get("source"));
let source_attr = Attr {
id: carta_ast::Text::default(),
classes: vec![language.into()],
attributes: Vec::new(),
};
let mut blocks = vec![Block::CodeBlock(Box::new(source_attr), source.into())];
if let Some(Value::Array(outputs)) = cell.get("outputs") {
for output in outputs {
if let Some(block) = output_to_block(output, media) {
blocks.push(block);
}
}
}
blocks
}
fn raw_cell_block(cell: &Value) -> Block {
let source = multiline_text(cell.get("source"));
let metadata = cell.get("metadata");
let mime = metadata
.and_then(|metadata| metadata.get("raw_mimetype"))
.or_else(|| metadata.and_then(|metadata| metadata.get("format")))
.and_then(Value::as_str);
let format = mime.map_or_else(|| "ipynb".to_owned(), format_from_mime);
Block::RawBlock(Format(format.into()), source.into())
}
fn output_to_block(output: &Value, media: &mut MediaBag) -> Option<Block> {
match output.get("output_type").and_then(Value::as_str)? {
"stream" => Some(stream_output(output)),
"execute_result" => Some(result_output(output, true, media)),
"display_data" => Some(result_output(output, false, media)),
"error" => Some(error_output(output)),
_ => None,
}
}
fn stream_output(output: &Value) -> Block {
let name = output
.get("name")
.and_then(Value::as_str)
.unwrap_or("stdout");
let text = strip_ansi(&multiline_text(output.get("text")));
let attr = Attr {
id: carta_ast::Text::default(),
classes: vec!["output".into(), "stream".into(), name.into()],
attributes: Vec::new(),
};
Block::Div(
Box::new(attr),
vec![Block::CodeBlock(Box::default(), text.into())],
)
}
fn result_output(output: &Value, is_result: bool, media: &mut MediaBag) -> Block {
let kind = if is_result {
"execute_result"
} else {
"display_data"
};
let mut attributes = Vec::new();
if is_result && let Some(count) = output.get("execution_count").and_then(Value::as_i64) {
attributes.push(("execution_count".to_owned(), count.to_string()));
}
let attr = Attr {
id: carta_ast::Text::default(),
classes: vec!["output".into(), kind.into()],
attributes: attributes
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
};
Block::Div(
Box::new(attr),
data_to_blocks(output.get("data"), output.get("metadata"), media),
)
}
fn error_output(output: &Value) -> Block {
let ename = output
.get("ename")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
let evalue = output
.get("evalue")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
let traceback = match output.get("traceback") {
Some(Value::Array(lines)) => {
let joined = lines
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join("\n");
format!("{joined}\n")
}
Some(Value::String(text)) => text.clone(),
_ => String::new(),
};
let attr = Attr {
id: carta_ast::Text::default(),
classes: vec!["output".into(), "error".into()],
attributes: vec![
("ename".into(), ename.into()),
("evalue".into(), evalue.into()),
],
};
Block::Div(
Box::new(attr),
vec![Block::CodeBlock(
Box::default(),
strip_ansi(&traceback).into(),
)],
)
}
fn data_to_blocks(
data: Option<&Value>,
metadata: Option<&Value>,
media: &mut MediaBag,
) -> Vec<Block> {
let Some(Value::Object(data)) = data else {
return Vec::new();
};
if let Some((mime, value)) = data.iter().find(|(mime, _)| is_image_like(mime)) {
return vec![image_block(mime, value, metadata, media)];
}
if let Some((mime, value)) = data.iter().find(|(mime, _)| is_json_like(mime)) {
return vec![non_image_block(mime, value)];
}
for mime in ["text/plain", "text/html", "text/latex", "text/markdown"] {
if let Some(value) = data.get(mime) {
return vec![non_image_block(mime, value)];
}
}
Vec::new()
}
fn non_image_block(mime: &str, value: &Value) -> Block {
if is_json_like(mime) {
return Block::CodeBlock(
Box::new(Attr {
id: carta_ast::Text::default(),
classes: vec!["json".into()],
attributes: Vec::new(),
}),
json_render(value).into(),
);
}
match mime {
"text/html" => Block::RawBlock(Format("html".into()), multiline_text(Some(value)).into()),
"text/latex" => Block::RawBlock(Format("latex".into()), multiline_text(Some(value)).into()),
"text/markdown" => Block::RawBlock(
Format("markdown".into()),
multiline_text(Some(value)).into(),
),
_ => Block::CodeBlock(
Box::default(),
strip_ansi(&multiline_text(Some(value))).into(),
),
}
}
fn image_block(mime: &str, value: &Value, metadata: Option<&Value>, media: &mut MediaBag) -> Block {
let bytes = decode_payload(mime, value);
let name = content_addressed_name(mime, &bytes);
media.insert(name.clone(), Some(mime.to_owned()), bytes);
Block::Para(vec![Inline::Image(
Box::new(image_attr(mime, metadata)),
Vec::new(),
Box::new(Target {
url: name.into(),
title: carta_ast::Text::default(),
}),
)])
}
fn decode_payload(mime: &str, value: &Value) -> Vec<u8> {
let payload = multiline_text(Some(value));
if mime == "image/svg+xml" {
payload.into_bytes()
} else {
base64_decode(&payload).unwrap_or_else(|| payload.into_bytes())
}
}
fn image_attr(mime: &str, metadata: Option<&Value>) -> Attr {
let mut attributes = Vec::new();
if let Some(Value::Object(by_mime)) = metadata
&& let Some(Value::Object(entry)) = by_mime.get(mime)
{
for (key, value) in entry {
attributes.push((key.clone(), attribute_value(value)));
}
}
Attr {
id: carta_ast::Text::default(),
classes: Vec::new(),
attributes: attributes
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
}
}
fn is_image_like(mime: &str) -> bool {
mime.starts_with("image/") || mime == "application/pdf"
}
fn is_json_like(mime: &str) -> bool {
mime == "application/json" || mime.ends_with("+json")
}
fn format_from_mime(mime: &str) -> String {
match mime {
"text/html" => "html",
"text/latex" | "application/pdf" => "latex",
"text/markdown" => "markdown",
"text/restructuredtext" | "text/x-rst" => "rst",
"text/asciidoc" => "asciidoc",
other => other,
}
.to_owned()
}
fn multiline_text(value: Option<&Value>) -> String {
match value {
Some(Value::String(text)) => text.clone(),
Some(Value::Array(lines)) => lines.iter().filter_map(Value::as_str).collect(),
_ => String::new(),
}
}
fn strip_ansi(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '\u{1b}' {
out.push(ch);
continue;
}
if chars.peek() == Some(&'[') {
chars.next();
for byte in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&byte) {
break;
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use carta_core::MediaBag;
fn read(input: &str) -> Document {
IpynbReader
.read(input, &ReaderOptions::default())
.expect("notebook input parses")
}
fn read_with(input: &str, extensions: carta_core::Extensions) -> Document {
let mut options = ReaderOptions::default();
options.extensions = extensions;
IpynbReader.read(input, &options).expect("notebook parses")
}
fn read_media(input: &str) -> (Document, MediaBag) {
IpynbReader
.read_media(input, &ReaderOptions::default())
.expect("notebook input parses")
}
fn jupyter(document: &Document) -> &BTreeMap<carta_ast::Text, MetaValue> {
match document.meta.get("jupyter") {
Some(MetaValue::MetaMap(map)) => map,
_ => panic!("expected a jupyter metadata map"),
}
}
#[test]
fn empty_notebook_exposes_only_version_metadata() {
let document = read(r#"{"cells": [], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}"#);
assert!(document.blocks.is_empty());
let map = jupyter(&document);
assert_eq!(
map.get("nbformat"),
Some(&MetaValue::MetaString("4".to_owned().into()))
);
assert_eq!(
map.get("nbformat_minor"),
Some(&MetaValue::MetaString("5".to_owned().into()))
);
}
#[test]
fn missing_minor_version_defaults_to_zero() {
let document = read(r#"{"cells": [], "metadata": {}, "nbformat": 4}"#);
assert_eq!(
jupyter(&document).get("nbformat_minor"),
Some(&MetaValue::MetaString("0".to_owned().into()))
);
}
#[test]
fn metadata_scalars_normalize_and_recurse() {
let document = read(
r#"{"cells": [], "metadata": {"afloat": 3.0, "aint": 7, "abool": true,
"anull": null, "alist": [1, "two", 3.0], "amap": {"z": 1, "a": 2.0}},
"nbformat": 4, "nbformat_minor": 5}"#,
);
let map = jupyter(&document);
assert_eq!(
map.get("afloat"),
Some(&MetaValue::MetaString("3".to_owned().into()))
);
assert_eq!(
map.get("aint"),
Some(&MetaValue::MetaString("7".to_owned().into()))
);
assert_eq!(map.get("abool"), Some(&MetaValue::MetaBool(true)));
assert_eq!(
map.get("anull"),
Some(&MetaValue::MetaString(carta_ast::Text::default()))
);
assert_eq!(
map.get("alist"),
Some(&MetaValue::MetaList(vec![
MetaValue::MetaString("1".to_owned().into()),
MetaValue::MetaString("two".to_owned().into()),
MetaValue::MetaString("3".to_owned().into()),
]))
);
let Some(MetaValue::MetaMap(nested)) = map.get("amap") else {
panic!("expected a nested map");
};
assert_eq!(
nested.get("a"),
Some(&MetaValue::MetaString("2".to_owned().into()))
);
assert_eq!(
nested.get("z"),
Some(&MetaValue::MetaString("1".to_owned().into()))
);
}
#[test]
fn markdown_cell_becomes_a_div_with_parsed_blocks() {
let document = read(
r##"{"cells": [{"cell_type": "markdown", "id": "m1", "metadata": {},
"source": ["# Title\n", "\n", "text"]}],
"metadata": {}, "nbformat": 4, "nbformat_minor": 5}"##,
);
let Some(Block::Div(attr, blocks)) = document.blocks.first() else {
panic!("expected a cell div");
};
assert_eq!(attr.id, "m1");
assert_eq!(attr.classes, vec!["cell".to_owned(), "markdown".to_owned()]);
assert!(matches!(blocks.first(), Some(Block::Header(1, _, _))));
assert!(matches!(blocks.get(1), Some(Block::Para(_))));
}
#[test]
fn markdown_cell_honors_forwarded_extensions() {
let input = r#"{"cells": [{"cell_type": "markdown", "metadata": {},
"source": ["| a | b |\n|---|---|\n| 1 | 2 |\n"]}],
"metadata": {}, "nbformat": 4, "nbformat_minor": 5}"#;
let with_tables = read_with(input, carta_core::presets::GFM);
let Some(Block::Div(_, blocks)) = with_tables.blocks.first() else {
panic!("expected a cell div");
};
assert!(matches!(blocks.first(), Some(Block::Table(_))));
let strict = read_with(input, carta_core::Extensions::empty());
let Some(Block::Div(_, blocks)) = strict.blocks.first() else {
panic!("expected a cell div");
};
assert!(!matches!(blocks.first(), Some(Block::Table(_))));
}
#[test]
fn markdown_attachment_prefix_is_stripped_from_images() {
let document = read(
r#"{"cells": [{"cell_type": "markdown", "metadata": {},
"attachments": {"a.png": {"image/png": "x"}},
"source": [""]}],
"metadata": {}, "nbformat": 4, "nbformat_minor": 5}"#,
);
let Some(Block::Div(_, blocks)) = document.blocks.first() else {
panic!("expected a cell div");
};
let Some(Block::Para(inlines)) = blocks.first() else {
panic!("expected a paragraph");
};
let Some(Inline::Image(_, _, target)) = inlines.first() else {
panic!("expected an image");
};
assert_eq!(target.url, "a.png");
}
#[test]
fn markdown_attachment_reference_is_scoped_to_the_cell_id() {
let document = read(
r#"{"cells": [{"cell_type": "markdown", "id": "cell9", "metadata": {},
"attachments": {"a.png": {"image/png": "x"}},
"source": [""]}],
"metadata": {}, "nbformat": 4, "nbformat_minor": 5}"#,
);
let Some(Block::Div(_, blocks)) = document.blocks.first() else {
panic!("expected a cell div");
};
let Some(Block::Para(inlines)) = blocks.first() else {
panic!("expected a paragraph");
};
let Some(Inline::Image(_, _, target)) = inlines.first() else {
panic!("expected an image");
};
assert_eq!(target.url, "cell9-a.png");
}
#[test]
fn code_cell_emits_source_then_outputs() {
let document = read(
r#"{"cells": [{"cell_type": "code", "metadata": {"scrolled": true},
"execution_count": 5, "source": ["import os\n", "print(1)"],
"outputs": [
{"output_type": "stream", "name": "stdout", "text": ["hello\n"]},
{"output_type": "execute_result", "execution_count": 5,
"data": {"text/plain": ["42"]}, "metadata": {}},
{"output_type": "error", "ename": "E", "evalue": "v",
"traceback": ["line1", "line2"]}
]}],
"metadata": {"kernelspec": {"language": "python"}},
"nbformat": 4, "nbformat_minor": 5}"#,
);
let Some(Block::Div(attr, blocks)) = document.blocks.first() else {
panic!("expected a cell div");
};
assert_eq!(
attr.attributes,
vec![
("execution_count".into(), "5".into()),
("scrolled".into(), "true".into()),
]
);
let Some(Block::CodeBlock(source_attr, source)) = blocks.first() else {
panic!("expected a source code block");
};
assert_eq!(source_attr.classes, vec!["python".to_owned()]);
assert_eq!(source, "import os\nprint(1)");
let Some(Block::Div(stream_attr, stream_body)) = blocks.get(1) else {
panic!("expected a stream div");
};
assert_eq!(
stream_attr.classes,
vec![
"output".to_owned(),
"stream".to_owned(),
"stdout".to_owned()
]
);
assert!(matches!(
stream_body.first(),
Some(Block::CodeBlock(_, text)) if text == "hello\n"
));
let Some(Block::Div(result_attr, result_body)) = blocks.get(2) else {
panic!("expected a result div");
};
assert_eq!(
result_attr.classes,
vec!["output".to_owned(), "execute_result".to_owned()]
);
assert_eq!(
result_attr.attributes,
vec![("execution_count".into(), "5".into())]
);
assert!(matches!(
result_body.first(),
Some(Block::CodeBlock(_, text)) if text == "42"
));
let Some(Block::Div(error_attr, error_body)) = blocks.get(3) else {
panic!("expected an error div");
};
assert_eq!(
error_attr.attributes,
vec![("ename".into(), "E".into()), ("evalue".into(), "v".into()),]
);
assert!(matches!(
error_body.first(),
Some(Block::CodeBlock(_, text)) if text == "line1\nline2\n"
));
}
#[test]
fn null_execution_count_yields_no_attribute() {
let document = read(
r#"{"cells": [{"cell_type": "code", "metadata": {}, "execution_count": null,
"source": [], "outputs": []}],
"metadata": {}, "nbformat": 4, "nbformat_minor": 5}"#,
);
let Some(Block::Div(attr, _)) = document.blocks.first() else {
panic!("expected a cell div");
};
assert!(attr.attributes.is_empty());
}
#[test]
fn image_output_is_content_addressed() {
let document = read(
r#"{"cells": [{"cell_type": "code", "metadata": {}, "execution_count": 1,
"source": [], "outputs": [
{"output_type": "display_data", "data": {"image/png": "iVBORw0KGgoAAAANSUhEUg=="},
"metadata": {}}]}],
"metadata": {}, "nbformat": 4, "nbformat_minor": 5}"#,
);
let Some(Block::Div(_, body)) = first_output(&document) else {
panic!("expected an output div");
};
let Some(Block::Para(inlines)) = body.first() else {
panic!("expected a paragraph");
};
let Some(Inline::Image(_, _, target)) = inlines.first() else {
panic!("expected an image");
};
assert_eq!(target.url, "22f545ac6b50163ce39bac49094c3f64e0858403.png");
let svg = read(
r#"{"cells": [{"cell_type": "code", "metadata": {}, "execution_count": 1,
"source": [], "outputs": [
{"output_type": "display_data", "data": {"image/svg+xml": ["<svg/>"]},
"metadata": {}}]}],
"metadata": {}, "nbformat": 4, "nbformat_minor": 5}"#,
);
let Some(Block::Div(_, body)) = first_output(&svg) else {
panic!("expected an output div");
};
let Some(Block::Para(inlines)) = body.first() else {
panic!("expected a paragraph");
};
let Some(Inline::Image(_, _, target)) = inlines.first() else {
panic!("expected an image");
};
assert_eq!(target.url, "1c3ba3b813e1080e9721846f23a21c09e5c3fd27.svg");
}
#[test]
fn image_output_bytes_are_lifted_into_the_media_bag() {
let (document, media) = read_media(
r#"{"cells": [{"cell_type": "code", "metadata": {}, "execution_count": 1,
"source": [], "outputs": [
{"output_type": "display_data", "data": {"image/png": "iVBORw0KGgoAAAANSUhEUg=="},
"metadata": {}}]}],
"metadata": {}, "nbformat": 4, "nbformat_minor": 5}"#,
);
let Some(Block::Div(_, body)) = first_output(&document) else {
panic!("expected an output div");
};
let Some(Block::Para(inlines)) = body.first() else {
panic!("expected a paragraph");
};
let Some(Inline::Image(_, _, target)) = inlines.first() else {
panic!("expected an image");
};
let name = "22f545ac6b50163ce39bac49094c3f64e0858403.png";
assert_eq!(target.url, name);
assert_eq!(media.len(), 1);
let item = media.get(name).expect("image is in the bag");
assert_eq!(item.mime.as_deref(), Some("image/png"));
assert_eq!(
item.bytes,
carta_core::media::base64_decode("iVBORw0KGgoAAAANSUhEUg==").unwrap()
);
}
#[test]
fn svg_output_is_stored_as_its_source_bytes() {
let (_, media) = read_media(
r#"{"cells": [{"cell_type": "code", "metadata": {}, "execution_count": 1,
"source": [], "outputs": [
{"output_type": "display_data", "data": {"image/svg+xml": ["<svg/>"]},
"metadata": {}}]}],
"metadata": {}, "nbformat": 4, "nbformat_minor": 5}"#,
);
let name = "1c3ba3b813e1080e9721846f23a21c09e5c3fd27.svg";
let item = media.get(name).expect("svg is in the bag");
assert_eq!(item.mime.as_deref(), Some("image/svg+xml"));
assert_eq!(item.bytes, b"<svg/>");
}
#[test]
fn identical_image_outputs_share_one_bag_entry() {
let (_, media) = read_media(
r#"{"cells": [{"cell_type": "code", "metadata": {}, "execution_count": 1,
"source": [], "outputs": [
{"output_type": "display_data", "data": {"image/png": "iVBORw0KGgoAAAANSUhEUg=="},
"metadata": {}},
{"output_type": "display_data", "data": {"image/png": "iVBORw0KGgoAAAANSUhEUg=="},
"metadata": {}}]}],
"metadata": {}, "nbformat": 4, "nbformat_minor": 5}"#,
);
assert_eq!(media.len(), 1);
}
#[test]
fn markdown_attachment_bytes_are_lifted_into_the_media_bag() {
let (_, media) = read_media(
r#"{"cells": [{"cell_type": "markdown", "id": "cell9", "metadata": {},
"attachments": {"a.png": {"image/png": "iVBORw0KGgoAAAANSUhEUg=="}},
"source": [""]}],
"metadata": {}, "nbformat": 4, "nbformat_minor": 5}"#,
);
let item = media.get("cell9-a.png").expect("attachment is in the bag");
assert_eq!(item.mime.as_deref(), Some("image/png"));
assert_eq!(
item.bytes,
carta_core::media::base64_decode("iVBORw0KGgoAAAANSUhEUg==").unwrap()
);
}
#[test]
fn attachment_without_a_cell_id_uses_the_bare_reference() {
let (_, media) = read_media(
r#"{"cells": [{"cell_type": "markdown", "metadata": {},
"attachments": {"a.png": {"image/png": "iVBORw0KGgoAAAANSUhEUg=="}},
"source": [""]}],
"metadata": {}, "nbformat": 4, "nbformat_minor": 5}"#,
);
assert!(media.contains("a.png"));
}
#[test]
fn image_wins_over_text_and_smaller_mime_wins_among_images() {
let document = read(
r#"{"cells": [{"cell_type": "code", "metadata": {}, "execution_count": 1,
"source": [], "outputs": [
{"output_type": "display_data",
"data": {"image/png": "iVBORw0KGgoAAAANSUhEUg==", "image/jpeg": "iVBORw0KGgoAAAANSUhEUg==",
"text/plain": ["p"]},
"metadata": {}}]}],
"metadata": {}, "nbformat": 4, "nbformat_minor": 5}"#,
);
let Some(Block::Div(_, body)) = first_output(&document) else {
panic!("expected an output div");
};
let Some(Block::Para(inlines)) = body.first() else {
panic!("expected a paragraph");
};
let Some(Inline::Image(_, _, target)) = inlines.first() else {
panic!("expected an image");
};
assert_eq!(target.url, "22f545ac6b50163ce39bac49094c3f64e0858403.jpg");
}
#[test]
fn image_output_metadata_becomes_sorted_attributes() {
let document = read(
r#"{"cells": [{"cell_type": "code", "metadata": {}, "execution_count": 1,
"source": [], "outputs": [
{"output_type": "display_data", "data": {"image/png": "iVBORw0KGgoAAAANSUhEUg=="},
"metadata": {"image/png": {"width": 100, "height": 50, "needs_background": "light"}}}]}],
"metadata": {}, "nbformat": 4, "nbformat_minor": 5}"#,
);
let Some(Block::Div(_, body)) = first_output(&document) else {
panic!("expected an output div");
};
let Some(Block::Para(inlines)) = body.first() else {
panic!("expected a paragraph");
};
let Some(Inline::Image(attr, _, _)) = inlines.first() else {
panic!("expected an image");
};
assert_eq!(
attr.attributes,
vec![
("height".into(), "50".into()),
("needs_background".into(), "light".into()),
("width".into(), "100".into()),
]
);
}
#[test]
fn structured_json_output_is_compact_and_sorted() {
let document = read(
r#"{"cells": [{"cell_type": "code", "metadata": {}, "execution_count": 1,
"source": [], "outputs": [
{"output_type": "display_data", "data": {"application/json": {"z": 1, "a": 2.0}},
"metadata": {}}]}],
"metadata": {}, "nbformat": 4, "nbformat_minor": 5}"#,
);
let Some(Block::Div(_, body)) = first_output(&document) else {
panic!("expected an output div");
};
let Some(Block::CodeBlock(attr, text)) = body.first() else {
panic!("expected a code block");
};
assert_eq!(attr.classes, vec!["json".to_owned()]);
assert_eq!(text, r#"{"a":2.0,"z":1}"#);
}
#[test]
fn raw_cell_maps_format_to_writer_name() {
let document = read(
r#"{"cells": [
{"cell_type": "raw", "metadata": {"format": "text/html"}, "source": ["<b>x</b>"]},
{"cell_type": "raw", "metadata": {}, "source": ["plain"]}],
"metadata": {}, "nbformat": 4, "nbformat_minor": 5}"#,
);
let Some(Block::Div(attr, body)) = document.blocks.first() else {
panic!("expected a raw cell div");
};
assert_eq!(attr.attributes, vec![("format".into(), "text/html".into())]);
assert!(matches!(
body.first(),
Some(Block::RawBlock(Format(name), text)) if name == "html" && text == "<b>x</b>"
));
let Some(Block::Div(_, body)) = document.blocks.get(1) else {
panic!("expected a raw cell div");
};
assert!(matches!(
body.first(),
Some(Block::RawBlock(Format(name), _)) if name == "ipynb"
));
}
#[test]
fn unknown_cell_kinds_are_dropped() {
let document = read(
r#"{"cells": [{"cell_type": "heading", "level": 2, "metadata": {}, "source": ["H"]}],
"metadata": {}, "nbformat": 4, "nbformat_minor": 5}"#,
);
assert!(document.blocks.is_empty());
}
#[test]
fn terminal_control_sequences_are_removed_from_text_outputs() {
let esc = format!("{}u001b", '\\');
let input = format!(
r#"{{"cells": [{{"cell_type": "code", "metadata": {{}}, "execution_count": 1,
"source": [], "outputs": [
{{"output_type": "stream", "name": "stdout",
"text": ["{esc}[31mred{esc}[0m"]}}]}}],
"metadata": {{}}, "nbformat": 4, "nbformat_minor": 5}}"#
);
let document = read(&input);
let Some(Block::Div(_, body)) = first_output(&document) else {
panic!("expected an output div");
};
assert!(matches!(
body.first(),
Some(Block::CodeBlock(_, text)) if text == "red"
));
}
#[test]
fn malformed_input_is_an_error_not_a_panic() {
assert!(
IpynbReader
.read("not json", &ReaderOptions::default())
.is_err()
);
assert!(IpynbReader.read("", &ReaderOptions::default()).is_err());
}
#[test]
fn pre_v4_notebook_is_an_error_not_a_panic() {
let result = IpynbReader.read(
r#"{"nbformat": 3, "nbformat_minor": 0, "worksheets": []}"#,
&ReaderOptions::default(),
);
assert!(matches!(result, Err(Error::UnsupportedFormat(_))));
}
fn first_output(document: &Document) -> Option<&Block> {
let Some(Block::Div(_, blocks)) = document.blocks.first() else {
return None;
};
blocks.get(1)
}
}