use std::collections::BTreeMap;
use carta_ast::{ApiVersion, Attr, Block, Document, Format, Inline, MetaValue, Target};
use carta_core::{Error, 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> {
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 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)? {
blocks.push(block);
}
}
}
Ok(Document {
api_version: ApiVersion::default(),
meta,
blocks,
})
}
}
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_string()),
);
jupyter.insert(
"nbformat_minor".to_owned(),
MetaValue::MetaString(nbformat_minor.to_string()),
);
let mut meta = BTreeMap::new();
meta.insert("jupyter".to_owned(), MetaValue::MetaMap(jupyter));
meta
}
fn meta_value(value: &Value) -> MetaValue {
match value {
Value::Null => MetaValue::MetaString(String::new()),
Value::Bool(flag) => MetaValue::MetaBool(*flag),
Value::Number(number) => MetaValue::MetaString(meta_number(number)),
Value::String(text) => MetaValue::MetaString(text.clone()),
Value::Array(items) => MetaValue::MetaList(items.iter().map(meta_value).collect()),
Value::Object(map) => MetaValue::MetaMap(
map.iter()
.map(|(key, value)| (key.clone(), 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) -> 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(attr, markdown_cell_blocks(cell, options)?),
"code" => Block::Div(attr, code_cell_blocks(cell, language)),
"raw" => Block::Div(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,
classes,
attributes,
}
}
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) -> 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()));
strip_attachment_blocks(&mut blocks, prefix.as_deref());
Ok(blocks)
}
fn code_cell_blocks(cell: &Value, language: &str) -> Vec<Block> {
let source = multiline_text(cell.get("source"));
let source_attr = Attr {
id: String::new(),
classes: vec![language.to_owned()],
attributes: Vec::new(),
};
let mut blocks = vec![Block::CodeBlock(source_attr, source)];
if let Some(Value::Array(outputs)) = cell.get("outputs") {
for output in outputs {
if let Some(block) = output_to_block(output) {
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), source)
}
fn output_to_block(output: &Value) -> Option<Block> {
match output.get("output_type").and_then(Value::as_str)? {
"stream" => Some(stream_output(output)),
"execute_result" => Some(result_output(output, true)),
"display_data" => Some(result_output(output, false)),
"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: String::new(),
classes: vec!["output".to_owned(), "stream".to_owned(), name.to_owned()],
attributes: Vec::new(),
};
Block::Div(attr, vec![Block::CodeBlock(Attr::default(), text)])
}
fn result_output(output: &Value, is_result: bool) -> 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: String::new(),
classes: vec!["output".to_owned(), kind.to_owned()],
attributes,
};
Block::Div(
attr,
data_to_blocks(output.get("data"), output.get("metadata")),
)
}
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: String::new(),
classes: vec!["output".to_owned(), "error".to_owned()],
attributes: vec![("ename".to_owned(), ename), ("evalue".to_owned(), evalue)],
};
Block::Div(
attr,
vec![Block::CodeBlock(Attr::default(), strip_ansi(&traceback))],
)
}
fn data_to_blocks(data: Option<&Value>, metadata: Option<&Value>) -> 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)];
}
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(
Attr {
id: String::new(),
classes: vec!["json".to_owned()],
attributes: Vec::new(),
},
json_render(value),
);
}
match mime {
"text/html" => Block::RawBlock(Format("html".to_owned()), multiline_text(Some(value))),
"text/latex" => Block::RawBlock(Format("latex".to_owned()), multiline_text(Some(value))),
"text/markdown" => {
Block::RawBlock(Format("markdown".to_owned()), multiline_text(Some(value)))
}
_ => Block::CodeBlock(Attr::default(), strip_ansi(&multiline_text(Some(value)))),
}
}
fn image_block(mime: &str, value: &Value, metadata: Option<&Value>) -> Block {
let payload = multiline_text(Some(value));
let bytes = if mime == "image/svg+xml" {
payload.into_bytes()
} else {
base64_decode(&payload).unwrap_or_else(|| payload.into_bytes())
};
let name = format!("{}.{}", sha1_hex(&bytes), extension_for_mime(mime));
Block::Para(vec![Inline::Image(
image_attr(mime, metadata),
Vec::new(),
Target {
url: name,
title: String::new(),
},
)])
}
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: String::new(),
classes: Vec::new(),
attributes,
}
}
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 extension_for_mime(mime: &str) -> &str {
match mime {
"image/png" => "png",
"image/jpeg" => "jpg",
"image/gif" => "gif",
"image/svg+xml" => "svg",
"application/pdf" => "pdf",
other => other
.rsplit('/')
.next()
.and_then(|subtype| subtype.split('+').next())
.unwrap_or(other),
}
}
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
}
fn strip_attachment_blocks(blocks: &mut [Block], prefix: Option<&str>) {
for block in blocks {
match block {
Block::Plain(inlines) | Block::Para(inlines) | Block::Header(_, _, inlines) => {
strip_attachment_inlines(inlines, prefix);
}
Block::LineBlock(lines) => {
for line in lines {
strip_attachment_inlines(line, prefix);
}
}
Block::BlockQuote(inner) | Block::Div(_, inner) => {
strip_attachment_blocks(inner, prefix);
}
Block::OrderedList(_, items) | Block::BulletList(items) => {
for item in items {
strip_attachment_blocks(item, prefix);
}
}
Block::DefinitionList(items) => {
for (term, definitions) in items {
strip_attachment_inlines(term, prefix);
for definition in definitions {
strip_attachment_blocks(definition, prefix);
}
}
}
Block::Figure(_, caption, inner) => {
strip_attachment_caption(caption, prefix);
strip_attachment_blocks(inner, prefix);
}
Block::Table(table) => strip_attachment_table(table, prefix),
Block::CodeBlock(..) | Block::RawBlock(..) | Block::HorizontalRule => {}
}
}
}
fn strip_attachment_table(table: &mut carta_ast::Table, prefix: Option<&str>) {
strip_attachment_caption(&mut table.caption, prefix);
let row_groups = std::iter::once(&mut table.head.rows)
.chain(table.bodies.iter_mut().flat_map(|body| {
std::iter::once(&mut body.head).chain(std::iter::once(&mut body.body))
}))
.chain(std::iter::once(&mut table.foot.rows));
for rows in row_groups {
for row in rows {
for cell in &mut row.cells {
strip_attachment_blocks(&mut cell.content, prefix);
}
}
}
}
fn strip_attachment_caption(caption: &mut carta_ast::Caption, prefix: Option<&str>) {
if let Some(short) = &mut caption.short {
strip_attachment_inlines(short, prefix);
}
strip_attachment_blocks(&mut caption.long, prefix);
}
fn strip_attachment_inlines(inlines: &mut [Inline], prefix: Option<&str>) {
for inline in inlines {
match inline {
Inline::Image(_, alt, target) => {
if let Some(bare) = target.url.strip_prefix("attachment:") {
target.url = match prefix {
Some(prefix) => format!("{prefix}{bare}"),
None => bare.to_owned(),
};
}
strip_attachment_inlines(alt, prefix);
}
Inline::Emph(children)
| Inline::Underline(children)
| Inline::Strong(children)
| Inline::Strikeout(children)
| Inline::Superscript(children)
| Inline::Subscript(children)
| Inline::SmallCaps(children)
| Inline::Quoted(_, children)
| Inline::Link(_, children, _)
| Inline::Span(_, children) => strip_attachment_inlines(children, prefix),
Inline::Cite(citations, children) => {
for citation in citations {
strip_attachment_inlines(&mut citation.prefix, prefix);
strip_attachment_inlines(&mut citation.suffix, prefix);
}
strip_attachment_inlines(children, prefix);
}
Inline::Note(blocks) => strip_attachment_blocks(blocks, prefix),
Inline::Str(_)
| Inline::Code(..)
| Inline::Space
| Inline::SoftBreak
| Inline::LineBreak
| Inline::Math(..)
| Inline::RawInline(..) => {}
}
}
}
fn base64_decode(input: &str) -> Option<Vec<u8>> {
let symbols: Vec<u8> = input
.bytes()
.filter(|byte| !byte.is_ascii_whitespace())
.collect();
if symbols.is_empty() {
return Some(Vec::new());
}
if !symbols.len().is_multiple_of(4) {
return None;
}
let group_count = symbols.len() / 4;
let mut out = Vec::with_capacity(group_count * 3);
for (index, chunk) in symbols.chunks_exact(4).enumerate() {
let last = index + 1 == group_count;
let &[a, b, c, d] = chunk else { return None };
let v0 = sextet(a)?;
let v1 = sextet(b)?;
out.push((v0 << 2) | (v1 >> 4));
if c == b'=' {
if !last || d != b'=' {
return None;
}
continue;
}
let v2 = sextet(c)?;
out.push(((v1 & 0x0f) << 4) | (v2 >> 2));
if d == b'=' {
if !last {
return None;
}
continue;
}
let v3 = sextet(d)?;
out.push(((v2 & 0x03) << 6) | v3);
}
Some(out)
}
fn sextet(byte: u8) -> Option<u8> {
match byte {
b'A'..=b'Z' => Some(byte - b'A'),
b'a'..=b'z' => Some(byte - b'a' + 26),
b'0'..=b'9' => Some(byte - b'0' + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
#[allow(
clippy::indexing_slicing,
clippy::cast_possible_truncation,
clippy::many_single_char_names,
reason = "the schedule and chunk indices are bounded by the fixed 80-word/64-byte block sizes; \
the casts isolate the intended low bits; the single-letter names are the digest's own \
working-variable notation"
)]
fn sha1_hex(data: &[u8]) -> String {
const HEX: [u8; 16] = *b"0123456789abcdef";
let mut h: [u32; 5] = [
0x6745_2301,
0xEFCD_AB89,
0x98BA_DCFE,
0x1032_5476,
0xC3D2_E1F0,
];
let bit_len = (data.len() as u64).wrapping_mul(8);
let mut message = data.to_vec();
message.push(0x80);
while message.len() % 64 != 56 {
message.push(0);
}
message.extend_from_slice(&bit_len.to_be_bytes());
for block in message.chunks_exact(64) {
let mut w = [0u32; 80];
for (index, word) in block.chunks_exact(4).enumerate() {
w[index] = u32::from_be_bytes(word.try_into().unwrap_or([0; 4]));
}
for index in 16..80 {
w[index] = (w[index - 3] ^ w[index - 8] ^ w[index - 14] ^ w[index - 16]).rotate_left(1);
}
let [mut a, mut b, mut c, mut d, mut e] = h;
for (index, &word) in w.iter().enumerate() {
let (f, k) = match index {
0..=19 => ((b & c) | ((!b) & d), 0x5A82_7999u32),
20..=39 => (b ^ c ^ d, 0x6ED9_EBA1),
40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1B_BCDC),
_ => (b ^ c ^ d, 0xCA62_C1D6),
};
let temp = a
.rotate_left(5)
.wrapping_add(f)
.wrapping_add(e)
.wrapping_add(k)
.wrapping_add(word);
e = d;
d = c;
c = b.rotate_left(30);
b = a;
a = temp;
}
h[0] = h[0].wrapping_add(a);
h[1] = h[1].wrapping_add(b);
h[2] = h[2].wrapping_add(c);
h[3] = h[3].wrapping_add(d);
h[4] = h[4].wrapping_add(e);
}
let mut out = String::with_capacity(40);
for word in h {
for byte in word.to_be_bytes() {
out.push(HEX[usize::from(byte >> 4)] as char);
out.push(HEX[usize::from(byte & 0x0f)] as char);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
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 jupyter(document: &Document) -> &BTreeMap<String, MetaValue> {
match document.meta.get("jupyter") {
Some(MetaValue::MetaMap(map)) => map,
_ => panic!("expected a jupyter metadata map"),
}
}
#[test]
fn sha1_matches_known_vectors() {
assert_eq!(sha1_hex(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709");
assert_eq!(sha1_hex(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d");
assert_eq!(
sha1_hex(b"The quick brown fox jumps over the lazy dog"),
"2fd4e1c67a2d28fced849ee1bb76e7391b93eb12"
);
}
#[test]
fn base64_decodes_and_ignores_whitespace() {
assert_eq!(base64_decode("aGVsbG8="), Some(b"hello".to_vec()));
assert_eq!(base64_decode("aGVs\nbG8="), Some(b"hello".to_vec()));
assert_eq!(base64_decode(""), Some(Vec::new()));
assert_eq!(base64_decode("QQ"), None);
assert_eq!(base64_decode("aGVsbG8@"), None);
assert_eq!(base64_decode("a=VsbG8="), None);
}
#[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()))
);
assert_eq!(
map.get("nbformat_minor"),
Some(&MetaValue::MetaString("5".to_owned()))
);
}
#[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()))
);
}
#[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()))
);
assert_eq!(
map.get("aint"),
Some(&MetaValue::MetaString("7".to_owned()))
);
assert_eq!(map.get("abool"), Some(&MetaValue::MetaBool(true)));
assert_eq!(
map.get("anull"),
Some(&MetaValue::MetaString(String::new()))
);
assert_eq!(
map.get("alist"),
Some(&MetaValue::MetaList(vec![
MetaValue::MetaString("1".to_owned()),
MetaValue::MetaString("two".to_owned()),
MetaValue::MetaString("3".to_owned()),
]))
);
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()))
);
assert_eq!(
nested.get("z"),
Some(&MetaValue::MetaString("1".to_owned()))
);
}
#[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".to_owned(), "5".to_owned()),
("scrolled".to_owned(), "true".to_owned()),
]
);
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".to_owned(), "5".to_owned())]
);
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".to_owned(), "E".to_owned()),
("evalue".to_owned(), "v".to_owned()),
]
);
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_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".to_owned(), "50".to_owned()),
("needs_background".to_owned(), "light".to_owned()),
("width".to_owned(), "100".to_owned()),
]
);
}
#[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".to_owned(), "text/html".to_owned())]
);
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)
}
}