Skip to main content

provenant/utils/
notebook.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Jupyter notebook (`.ipynb`) text extraction for scanner detection.
5//!
6//! A `.ipynb` file is a JSON document whose human-authored text (code, markdown)
7//! and program output live inside JSON string arrays. Scanning the raw JSON makes
8//! detection both miss real notices (e.g. a `(c) ...` line wrapped as
9//! `"\t(c) Foo, 2012\n",`) and emit false positives from JSON array punctuation
10//! around source lines. This module decodes the notebook into the plain cell text
11//! so license/copyright detection sees the same clean text a reader would.
12//!
13//! Like the source-map extractor, this trades exact line-number fidelity (offsets
14//! become relative to the extracted text) for correct detection content.
15
16use std::path::Path;
17
18use serde_json::Value;
19
20/// Check whether a file is a Jupyter notebook based on its extension.
21pub fn is_notebook(path: &Path) -> bool {
22    path.extension()
23        .and_then(|ext| ext.to_str())
24        .is_some_and(|ext| ext.eq_ignore_ascii_case("ipynb"))
25}
26
27/// Extract the human-readable text from a Jupyter notebook's cells.
28///
29/// Concatenates each cell's `source` (code and markdown) together with textual
30/// outputs (`stream` text and `text/plain` results). Non-text outputs such as
31/// `image/png` data are intentionally skipped to avoid scanning base64 noise.
32///
33/// Returns `Some(combined_text)` when the notebook parses and yields any text,
34/// otherwise `None` (the caller falls back to the raw content).
35pub fn extract_notebook_content(json_text: &str) -> Option<String> {
36    let json: Value = serde_json::from_str(json_text).ok()?;
37    let cells = json.get("cells")?.as_array()?;
38
39    let mut parts: Vec<String> = Vec::new();
40    for cell in cells {
41        if let Some(source) = cell.get("source").and_then(collect_text) {
42            parts.push(source);
43        }
44        if let Some(outputs) = cell.get("outputs").and_then(Value::as_array) {
45            for output in outputs {
46                // `stream` outputs (e.g. stdout) carry their text under `text`.
47                if let Some(text) = output.get("text").and_then(collect_text) {
48                    parts.push(text);
49                }
50                // `execute_result` / `display_data` carry a `text/plain` rendering.
51                if let Some(text) = output
52                    .get("data")
53                    .and_then(|data| data.get("text/plain"))
54                    .and_then(collect_text)
55                {
56                    parts.push(text);
57                }
58            }
59        }
60    }
61
62    if parts.is_empty() {
63        None
64    } else {
65        // Separate cells with exactly one newline: trim each part's own trailing
66        // newlines first so a part that already ends with `\n` doesn't add a blank
67        // line, while a part that doesn't still stays on its own line.
68        Some(
69            parts
70                .iter()
71                .map(|part| part.trim_end_matches('\n'))
72                .collect::<Vec<_>>()
73                .join("\n"),
74        )
75    }
76}
77
78/// nbformat stores text either as a single string or as an array of line strings
79/// (each element already including its trailing newline). Reconstruct the original
80/// text by concatenating array elements verbatim.
81fn collect_text(value: &Value) -> Option<String> {
82    match value {
83        Value::String(s) => (!s.is_empty()).then(|| s.clone()),
84        Value::Array(items) => {
85            let combined: String = items.iter().filter_map(Value::as_str).collect();
86            (!combined.is_empty()).then_some(combined)
87        }
88        _ => None,
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use std::path::PathBuf;
96
97    #[test]
98    fn test_is_notebook() {
99        assert!(is_notebook(&PathBuf::from("Analysis.ipynb")));
100        assert!(is_notebook(&PathBuf::from("a/b/NOTEBOOK.IPYNB")));
101        assert!(!is_notebook(&PathBuf::from("script.py")));
102        assert!(!is_notebook(&PathBuf::from("data.json")));
103    }
104
105    #[test]
106    fn test_extract_source_array_and_stream_output() {
107        // `source` as a line array; a stream output carrying a copyright notice.
108        let json = r#"{
109          "cells": [
110            {"cell_type":"code","source":["@show typeof(C)\n","C[1:10,:]\n"],
111             "outputs":[{"output_type":"stream","name":"stdout",
112               "text":["\t(c) Brendan O'Donoghue, Stanford University, 2012\n"]}]}
113          ]
114        }"#;
115        let text = extract_notebook_content(json).expect("should extract");
116        // Cell source is reconstructed as clean code (no JSON punctuation).
117        assert!(text.contains("@show typeof(C)\nC[1:10,:]"));
118        // Output text is included so genuine notices are detectable.
119        assert!(text.contains("(c) Brendan O'Donoghue, Stanford University, 2012"));
120        // No JSON array punctuation leaks into the extracted text.
121        assert!(!text.contains("\", \""));
122    }
123
124    #[test]
125    fn test_extract_skips_binary_output_data() {
126        let json = r#"{
127          "cells": [
128            {"cell_type":"code","source":"print(1)",
129             "outputs":[{"output_type":"display_data",
130               "data":{"image/png":"iVBORw0KGgoAAAANSU","text/plain":"<Figure>"}}]}
131          ]
132        }"#;
133        let text = extract_notebook_content(json).expect("should extract");
134        assert!(text.contains("print(1)"));
135        assert!(text.contains("<Figure>"));
136        assert!(!text.contains("iVBORw0KGgoAAAANSU"));
137    }
138
139    #[test]
140    fn test_extract_source_string_form() {
141        let json = r##"{"cells":[{"cell_type":"markdown","source":"# Title\nSome prose"}]}"##;
142        let text = extract_notebook_content(json).expect("should extract");
143        assert_eq!(text, "# Title\nSome prose");
144    }
145
146    #[test]
147    fn test_extract_invalid_or_empty_returns_none() {
148        assert!(extract_notebook_content("not json").is_none());
149        assert!(extract_notebook_content(r#"{"nbformat":4}"#).is_none());
150        assert!(extract_notebook_content(r#"{"cells":[]}"#).is_none());
151    }
152
153    #[test]
154    fn test_extract_no_blank_line_between_parts() {
155        // A stream output whose lines each end with `\n`, followed by another cell.
156        let json = r#"{
157          "cells": [
158            {"cell_type":"code","source":"a()","outputs":[{"output_type":"stream","text":["one\n","two\n"]}]},
159            {"cell_type":"code","source":"b()"}
160          ]
161        }"#;
162        let text = extract_notebook_content(json).expect("should extract");
163        assert!(
164            !text.contains("\n\n"),
165            "no blank lines between parts: {text:?}"
166        );
167        assert!(text.contains("two\nb()"));
168    }
169}