mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-10 21:27:21 +00:00
Added OneNote sections as a document type (#1161)
This commit is contained in:
1 parent
677b650a79
commit
ce846e540e
24 files changed
+1142
-23
No files matched your search
+369
-11
@@ -15,6 +15,7 @@ use docx_to_md::{DocumentContainer, Error as DocumentError, ImageHandlingMode as
|
||||
use encoding_rs::Encoding;
|
||||
use file_format::{FileFormat, Kind};
|
||||
use futures::{Stream, StreamExt};
|
||||
use onenote_parser::Parser as OneNoteParser;
|
||||
use pdfium_render::prelude::{Pdfium, PdfiumError, PdfiumInternalError};
|
||||
use pptx_to_md::{DiagnosticSeverity, Error as PresentationError, ImageHandlingMode, MarkdownOptions, ParserConfig, PresentationContainer, PresentationFormat, PresentationMetadata, ReadingOrder};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
@@ -27,6 +28,8 @@ use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tokenizers::tokenizer::Tokenizer;
|
||||
use typed_path::{PathType, TypedPath};
|
||||
use crate::onenote_markdown;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Chunk {
|
||||
@@ -125,6 +128,12 @@ pub enum Metadata {
|
||||
|
||||
Document {
|
||||
page_number: Option<usize>,
|
||||
|
||||
/// Whether the page is one the file itself knows, such as a page of a OneNote section. The
|
||||
/// pages of a Word or OpenDocument file are only estimated from its page breaks, since such
|
||||
/// a file carries no fixed layout. The app tells the AI which of the two it reads.
|
||||
page_is_exact: bool,
|
||||
|
||||
image: Option<Base64Image>,
|
||||
},
|
||||
|
||||
@@ -309,6 +318,10 @@ const DOCX: &str = "docx";
|
||||
const ODT: &str = "odt";
|
||||
const HTML: &str = "html";
|
||||
const IMAGE_SEGMENT_SIZE_IN_CHARS: usize = 8_192; // equivalent to ~ 5500 token
|
||||
|
||||
/// How many bytes of an image fill one segment. Base64 turns three bytes into four characters, so
|
||||
/// a segment of this many bytes fills `IMAGE_SEGMENT_SIZE_IN_CHARS` exactly and needs no padding.
|
||||
const IMAGE_SEGMENT_SIZE_IN_BYTES: usize = IMAGE_SEGMENT_SIZE_IN_CHARS / 4 * 3;
|
||||
const MAX_TEXT_SEGMENT_LENGTH_IN_CHARS: usize = 100_000;
|
||||
|
||||
/// The signature which identifies a PDF file.
|
||||
@@ -754,6 +767,11 @@ enum ExtractionRoute {
|
||||
PandocHtml,
|
||||
PresentationPptx,
|
||||
PresentationOdp,
|
||||
|
||||
/// A section of a OneNote notebook. Its content is never identified, since `file-format` does not
|
||||
/// know OneNote, so only the extension leads here.
|
||||
OneNote,
|
||||
|
||||
Spreadsheet,
|
||||
Csv,
|
||||
Text,
|
||||
@@ -776,6 +794,7 @@ fn route_from_extension(ext: &str) -> Option<ExtractionRoute> {
|
||||
"csv" | "tsv" => Some(ExtractionRoute::Csv),
|
||||
"pptx" => Some(ExtractionRoute::PresentationPptx),
|
||||
"odp" => Some(ExtractionRoute::PresentationOdp),
|
||||
"one" => Some(ExtractionRoute::OneNote),
|
||||
"xlsx" | "ods" | "xls" | "xlsm" | "xlsb" | "xla" | "xlam" => Some(ExtractionRoute::Spreadsheet),
|
||||
"jpg" | "jpeg" | "png" | "gif" | "bmp" | "tiff" | "svg" | "webp" | "heic" => Some(ExtractionRoute::Image),
|
||||
|
||||
@@ -923,6 +942,7 @@ async fn stream_data(file_path: &str, extract_images: bool, stream_id: &str) ->
|
||||
ExtractionRoute::PandocHtml => convert_with_pandoc(file_path, HTML, TO_MARKDOWN).await?,
|
||||
ExtractionRoute::PresentationPptx => stream_presentation(file_path, extract_images, PresentationFormat::Pptx, stream_id).await?,
|
||||
ExtractionRoute::PresentationOdp => stream_presentation(file_path, extract_images, PresentationFormat::Odp, stream_id).await?,
|
||||
ExtractionRoute::OneNote => stream_onenote_section(file_path, extract_images, stream_id).await?,
|
||||
ExtractionRoute::Spreadsheet => stream_spreadsheet_as_csv(file_path).await?,
|
||||
ExtractionRoute::Csv => stream_text_file(file_path, true, Some("csv".to_string())).await?,
|
||||
ExtractionRoute::Text => stream_text_file(file_path, false, None).await?,
|
||||
@@ -1457,6 +1477,7 @@ async fn convert_with_pandoc(
|
||||
content,
|
||||
Metadata::Document {
|
||||
page_number: None,
|
||||
page_is_exact: false,
|
||||
image: None,
|
||||
}
|
||||
));
|
||||
@@ -1611,6 +1632,7 @@ async fn stream_document(file_path: &str, extract_images: bool, stream_id: &str)
|
||||
}
|
||||
if tx.blocking_send(Ok(Chunk::new(content, Metadata::Document {
|
||||
page_number: Some(page.page_number),
|
||||
page_is_exact: false,
|
||||
image: None,
|
||||
}))).is_err() {
|
||||
return;
|
||||
@@ -1626,6 +1648,7 @@ async fn stream_document(file_path: &str, extract_images: bool, stream_id: &str)
|
||||
let base64_image = Base64Image::new(image_id.clone(), base64_data[offset..end].to_string(), segment_index, end == base64_data.len(), Some(image.media_type.clone()));
|
||||
if tx.blocking_send(Ok(Chunk::new(String::new(), Metadata::Document {
|
||||
page_number: Some(page.page_number),
|
||||
page_is_exact: false,
|
||||
image: Some(base64_image),
|
||||
}))).is_err() {
|
||||
return;
|
||||
@@ -1840,6 +1863,187 @@ async fn stream_presentation(file_path: &str, extract_images: bool, format: Pres
|
||||
Ok(Box::pin(ReceiverStream::new(rx)))
|
||||
}
|
||||
|
||||
/// Reads a section of a OneNote notebook, one chunk per page, followed by the images of the page.
|
||||
///
|
||||
/// A page is numbered by its place in the page list of the section, subpages included, which is
|
||||
/// where the user finds it in OneNote. The parser reads the file on demand rather than as a whole,
|
||||
/// and the images are read the same way, one segment at a time.
|
||||
async fn stream_onenote_section(file_path: &str, extract_images: bool, stream_id: &str) -> Result<ChunkStream> {
|
||||
let path = file_path.to_owned();
|
||||
let stream_id = stream_id.to_owned();
|
||||
let (tx, rx) = mpsc::channel(32);
|
||||
let worker_error_tx = tx.clone();
|
||||
|
||||
// Parsing reads the file and resolves its objects synchronously, so the complete producer must
|
||||
// stay outside Tokio's asynchronous workers.
|
||||
let worker = tokio::task::spawn_blocking(move || {
|
||||
//
|
||||
// The parser keeps the cause of a failure to itself: its error tells only what went wrong as
|
||||
// text. Opening the file first is what still tells a file out of reach, e.g., one another
|
||||
// program holds open, apart from a broken section, which fails the same way on every run.
|
||||
//
|
||||
if let Err(e) = std::fs::File::open(&path) {
|
||||
let _ = tx.blocking_send(Err(ExtractionError::new(
|
||||
classify_io_error(&e),
|
||||
format!("The file could not be read: {e}"),
|
||||
).into()));
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// The path comes from this system, so it follows its rules. Letting the parser guess them
|
||||
// would read a Windows path such as C:\Notes\Work.one as a Unix path.
|
||||
//
|
||||
let path_type = if cfg!(windows) { PathType::Windows } else { PathType::Unix };
|
||||
let section = match OneNoteParser::new().parse_section(TypedPath::new(path.as_str(), path_type)) {
|
||||
Ok(section) => section,
|
||||
|
||||
//
|
||||
// A section protected with a password fails here as well: the parser cannot decrypt
|
||||
// it, and reports it like a damaged file.
|
||||
//
|
||||
Err(e) => {
|
||||
let _ = tx.blocking_send(Err(ExtractionError::new(
|
||||
ExtractionErrorCode::NotAValidDocument,
|
||||
format!("The OneNote section could not be read. It may be damaged or protected with a password: {e}"),
|
||||
).into()));
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
for warning in section.report().warnings() {
|
||||
match warning.page() {
|
||||
Some((_, page_title)) => warn!("OneNote section '{path}', page '{page_title}': {message}", message = warning.message()),
|
||||
None => warn!("OneNote section '{path}': {message}", message = warning.message()),
|
||||
}
|
||||
}
|
||||
|
||||
let mut metadata_md = onenote_markdown::section_metadata_to_markdown(§ion);
|
||||
let mut number_of_pages = 0;
|
||||
let mut number_of_characters = 0;
|
||||
|
||||
for page in section.page_series().iter().flat_map(|series| series.pages()) {
|
||||
let mut content = onenote_markdown::page_to_markdown(page);
|
||||
|
||||
//
|
||||
// Counted without the comments: the metadata of a page, which every page carries, would
|
||||
// make a page of nothing but drawings or pictures look readable.
|
||||
//
|
||||
number_of_pages += 1;
|
||||
number_of_characters += readable_character_count_outside_comments(&content);
|
||||
|
||||
if let Some(metadata) = metadata_md.take() {
|
||||
content = format!("{metadata}\n\n{content}");
|
||||
}
|
||||
|
||||
if tx.blocking_send(Ok(Chunk::new(content, Metadata::Document {
|
||||
page_number: Some(number_of_pages),
|
||||
page_is_exact: true,
|
||||
image: None,
|
||||
}))).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
if !extract_images {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (index, image) in onenote_markdown::page_images(page).into_iter().enumerate() {
|
||||
let Some((reader, media_type)) = onenote_markdown::image_data_for_models(image) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
//
|
||||
// The image ID carries the stream it belongs to, like the other readers do it. The app
|
||||
// removes the segments of a finished extraction by that prefix:
|
||||
//
|
||||
let image_id = format!("{stream_id}-{number_of_pages}-{index}");
|
||||
let mut receiver_is_gone = false;
|
||||
let reading = for_each_base64_segment(reader, |content, segment, is_end| {
|
||||
let base64_image = Base64Image::new(image_id.clone(), content, segment, is_end, Some(media_type.to_string()));
|
||||
receiver_is_gone = tx.blocking_send(Ok(Chunk::new(String::new(), Metadata::Document {
|
||||
page_number: Some(number_of_pages),
|
||||
page_is_exact: true,
|
||||
image: Some(base64_image),
|
||||
}))).is_err();
|
||||
|
||||
!receiver_is_gone
|
||||
});
|
||||
|
||||
if receiver_is_gone {
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// A missing image costs the page a picture, not its text. The segments sent so far
|
||||
// never complete, and the app drops them with the stream.
|
||||
//
|
||||
if let Err(e) = reading {
|
||||
warn!("Image {index} on page {number_of_pages} of '{path}' could not be read: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Extracted {number_of_characters} readable character(s) from {number_of_pages} page(s) of '{path}'.");
|
||||
|
||||
//
|
||||
// Without this marker, a section without any text and a broken extraction both arrive as an
|
||||
// empty document, and the AI would answer as if the file had no content at all.
|
||||
//
|
||||
if number_of_characters == 0 {
|
||||
warn!("No text could be extracted from '{path}': {number_of_pages} page(s).");
|
||||
|
||||
let _ = tx.blocking_send(Ok(Chunk::from_error(&ExtractionError::new(
|
||||
ExtractionErrorCode::NoTextExtracted,
|
||||
format!("No text could be extracted from {number_of_pages} page(s) of the OneNote section."),
|
||||
))));
|
||||
}
|
||||
});
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = worker.await {
|
||||
let _ = worker_error_tx.send(Err(ExtractionError::new(
|
||||
ExtractionErrorCode::Internal,
|
||||
format!("The OneNote parser task failed: {e}"),
|
||||
).into())).await;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Box::pin(ReceiverStream::new(rx)))
|
||||
}
|
||||
|
||||
/// Encodes what a reader yields as base64 segments of `IMAGE_SEGMENT_SIZE_IN_CHARS`, reading only as
|
||||
/// much as one segment needs, and hands each to `send` together with its index and whether it is the
|
||||
/// last one. Stops as soon as `send` returns `false`.
|
||||
///
|
||||
/// The segments add up to the encoding of the whole data: every segment but the last encodes a
|
||||
/// multiple of three bytes and therefore carries no padding.
|
||||
fn for_each_base64_segment(mut reader: impl std::io::Read, mut send: impl FnMut(String, usize, bool) -> bool) -> std::io::Result<()> {
|
||||
let mut current = read_image_segment(&mut reader)?;
|
||||
let mut index = 0;
|
||||
while !current.is_empty() {
|
||||
// Reading one segment ahead is what tells the last segment as such:
|
||||
let next = read_image_segment(&mut reader)?;
|
||||
if !send(general_purpose::STANDARD.encode(¤t), index, next.is_empty()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
current = next;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads the bytes of the next image segment, fewer only at the end of the data.
|
||||
fn read_image_segment(reader: &mut impl std::io::Read) -> std::io::Result<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
|
||||
let mut segment = Vec::with_capacity(IMAGE_SEGMENT_SIZE_IN_BYTES);
|
||||
reader.take(IMAGE_SEGMENT_SIZE_IN_BYTES as u64).read_to_end(&mut segment)?;
|
||||
Ok(segment)
|
||||
}
|
||||
|
||||
fn presentation_metadata_to_markdown(metadata: &PresentationMetadata) -> Option<String> {
|
||||
let mut fields = Vec::new();
|
||||
push_presentation_metadata_field(&mut fields, "Title", metadata.title.as_deref());
|
||||
@@ -1889,7 +2093,7 @@ fn document_metadata_to_markdown(metadata: &DocumentMetadata) -> Option<String>
|
||||
if fields.is_empty() { None } else { Some(format!("<!-- Document Metadata\n{}\n-->", fields.join("\n"))) }
|
||||
}
|
||||
|
||||
fn push_presentation_metadata_field(fields: &mut Vec<String>, label: &str, value: Option<&str>) {
|
||||
pub(crate) fn push_presentation_metadata_field(fields: &mut Vec<String>, label: &str, value: Option<&str>) {
|
||||
if let Some(value) = value {
|
||||
fields.push(format!(
|
||||
"{label}: {}",
|
||||
@@ -2072,17 +2276,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a text file through `extract_data`, the route the app calls, and collects the events
|
||||
/// it sends.
|
||||
async fn extract_text_file(content: &str) -> ExtractedTextFile {
|
||||
let directory = tempfile::tempdir().expect("a temporary directory must be available");
|
||||
let path = directory.path().join("notes.txt");
|
||||
std::fs::write(&path, content).expect("the text file must be writable");
|
||||
|
||||
/// Reads a file through `extract_data`, the route the app calls, and returns the events it sends.
|
||||
async fn extracted_events(path: &Path, extract_images: bool) -> Vec<serde_json::Value> {
|
||||
let query = ExtractDataQuery {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
stream_id: "test".to_string(),
|
||||
extract_images: false,
|
||||
extract_images,
|
||||
include_token_count: false,
|
||||
tokenizer_path: String::new(),
|
||||
};
|
||||
@@ -2091,9 +2290,21 @@ mod tests {
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.expect("the response must be readable");
|
||||
let body = String::from_utf8(body.to_vec()).expect("the response must be UTF-8");
|
||||
|
||||
body.lines()
|
||||
.filter_map(|line| line.strip_prefix("data:"))
|
||||
.map(|data| serde_json::from_str(data.trim_start()).expect("every event must be JSON"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reads a text file through `extract_data`, the route the app calls, and collects the events
|
||||
/// it sends.
|
||||
async fn extract_text_file(content: &str) -> ExtractedTextFile {
|
||||
let directory = tempfile::tempdir().expect("a temporary directory must be available");
|
||||
let path = directory.path().join("notes.txt");
|
||||
std::fs::write(&path, content).expect("the text file must be writable");
|
||||
|
||||
let mut extracted = ExtractedTextFile { lines: Vec::new(), redacted_count: 0, rule_ids: Vec::new() };
|
||||
for data in body.lines().filter_map(|line| line.strip_prefix("data:")) {
|
||||
let event: serde_json::Value = serde_json::from_str(data.trim_start()).expect("every event must be JSON");
|
||||
for event in extracted_events(&path, false).await {
|
||||
let metadata = &event["metadata"];
|
||||
if metadata.get("Text").is_some() {
|
||||
extracted.lines.push(event["content"].as_str().expect("a line must carry text").to_string());
|
||||
@@ -2182,4 +2393,151 @@ mod tests {
|
||||
assert!(comment.contains("Title: REST API"), "got: {comment}");
|
||||
assert!(!comment.contains("Custom") && !comment.contains("48800"), "got: {comment}");
|
||||
}
|
||||
|
||||
/// A section from Microsoft's test suites for the Office protocols, which are licensed under the
|
||||
/// MIT license, cf. the LICENSE next to them.
|
||||
fn onenote_fixture(name: &str) -> std::path::PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/onenote").join(name)
|
||||
}
|
||||
|
||||
/// Reads a OneNote section through `extract_data` and splits what arrives into its pages, each
|
||||
/// with its number, and the codes of the notices and errors.
|
||||
async fn extract_onenote_section(path: &Path) -> (Vec<(usize, String)>, Vec<String>) {
|
||||
let mut pages = Vec::new();
|
||||
let mut codes = Vec::new();
|
||||
for event in extracted_events(path, false).await {
|
||||
let metadata = &event["metadata"];
|
||||
if let Some(document) = metadata.get("Document") {
|
||||
let page_number = document["page_number"].as_u64().expect("a page must carry its number") as usize;
|
||||
pages.push((page_number, event["content"].as_str().expect("a page must carry text").to_string()));
|
||||
} else if let Some(error) = metadata.get("Error") {
|
||||
codes.push(error["code"].as_str().expect("an error must carry its code").to_string());
|
||||
} else {
|
||||
panic!("unexpected event for a OneNote section: {event}");
|
||||
}
|
||||
}
|
||||
|
||||
(pages, codes)
|
||||
}
|
||||
|
||||
/// Reads data through `for_each_base64_segment` and returns every segment with its index and
|
||||
/// whether it was marked as the last one.
|
||||
fn base64_segments_of(data: &[u8]) -> Vec<(String, usize, bool)> {
|
||||
let mut segments = Vec::new();
|
||||
for_each_base64_segment(std::io::Cursor::new(data), |content, index, is_end| {
|
||||
segments.push((content, index, is_end));
|
||||
true
|
||||
}).expect("reading from memory cannot fail");
|
||||
|
||||
segments
|
||||
}
|
||||
|
||||
/// An image is read one segment at a time, and the segments add up to the encoding of the whole
|
||||
/// image, with only the last one marked as such.
|
||||
#[test]
|
||||
fn the_segments_of_an_image_add_up_to_its_whole_encoding() {
|
||||
let data: Vec<u8> = (0..IMAGE_SEGMENT_SIZE_IN_BYTES * 2 + 5).map(|index| (index % 251) as u8).collect();
|
||||
let segments = base64_segments_of(&data);
|
||||
|
||||
assert_eq!(segments.iter().map(|(content, _, _)| content.len()).collect::<Vec<_>>(), vec![IMAGE_SEGMENT_SIZE_IN_CHARS, IMAGE_SEGMENT_SIZE_IN_CHARS, 8]);
|
||||
assert_eq!(segments.iter().map(|&(_, index, is_end)| (index, is_end)).collect::<Vec<_>>(), vec![(0, false), (1, false), (2, true)]);
|
||||
assert_eq!(segments.into_iter().map(|(content, _, _)| content).collect::<String>(), general_purpose::STANDARD.encode(&data));
|
||||
}
|
||||
|
||||
/// An image which fills exactly one segment is its own last segment, and an empty one sends
|
||||
/// nothing at all.
|
||||
#[test]
|
||||
fn an_image_of_one_segment_ends_there_and_an_empty_one_sends_nothing() {
|
||||
let segments = base64_segments_of(&[7; IMAGE_SEGMENT_SIZE_IN_BYTES]);
|
||||
assert_eq!(segments.len(), 1);
|
||||
assert_eq!((segments[0].0.len(), segments[0].2), (IMAGE_SEGMENT_SIZE_IN_CHARS, true));
|
||||
|
||||
assert!(base64_segments_of(&[]).is_empty());
|
||||
}
|
||||
|
||||
/// Once the app stopped listening, the rest of the image is not read anymore.
|
||||
#[test]
|
||||
fn the_segments_of_an_image_stop_once_nobody_listens() {
|
||||
let mut calls = 0;
|
||||
for_each_base64_segment(std::io::Cursor::new(vec![0; IMAGE_SEGMENT_SIZE_IN_BYTES * 3]), |_, _, _| {
|
||||
calls += 1;
|
||||
false
|
||||
}).expect("reading from memory cannot fail");
|
||||
|
||||
assert_eq!(calls, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_onenote_section_is_read_by_its_extension() {
|
||||
assert_eq!(route_from_extension("one"), Some(ExtractionRoute::OneNote));
|
||||
}
|
||||
|
||||
/// The section is named in front of the first page, and each page starts with its title, then
|
||||
/// its metadata, then its text.
|
||||
#[tokio::test]
|
||||
async fn a_page_of_a_onenote_section_arrives_with_its_title_metadata_and_text() {
|
||||
let (pages, codes) = extract_onenote_section(&onenote_fixture("OneWithoutFileData.one")).await;
|
||||
assert!(codes.is_empty(), "got: {codes:?}");
|
||||
assert_eq!(pages.len(), 1, "got: {pages:?}");
|
||||
|
||||
let (page_number, content) = &pages[0];
|
||||
assert_eq!(*page_number, 1);
|
||||
assert!(content.starts_with("<!-- Section Metadata\nTitle: OneWithoutFileData\n-->\n\n# The OneNote file without file data\n\n<!-- Page Metadata\n"), "got: {content}");
|
||||
assert!(content.contains("\nCreated: 2019-01-14T06:59:10Z\nModified: 2019-01-14T06:59:44Z\n-->"), "got: {content}");
|
||||
assert!(content.ends_with("-->\n\nThis is a test file which does not contain the file data."), "got: {content}");
|
||||
}
|
||||
|
||||
/// A page of a OneNote section is a page the file knows, not one estimated from page breaks.
|
||||
#[tokio::test]
|
||||
async fn the_pages_of_a_onenote_section_are_exact() {
|
||||
let events = extracted_events(&onenote_fixture("OneWithoutFileData.one"), false).await;
|
||||
let flags: Vec<_> = events.iter()
|
||||
.filter_map(|event| event["metadata"].get("Document"))
|
||||
.map(|document| document["page_is_exact"].clone())
|
||||
.collect();
|
||||
|
||||
assert_eq!(flags, vec![serde_json::Value::Bool(true)]);
|
||||
}
|
||||
|
||||
/// OneDrive hands out a section in a packaging of its own, cf. MS-ONESTORE 2.8.
|
||||
#[tokio::test]
|
||||
async fn a_onenote_section_downloaded_from_onedrive_is_read_as_well() {
|
||||
let (pages, codes) = extract_onenote_section(&onenote_fixture("AlternativePackaging.one")).await;
|
||||
assert!(codes.is_empty(), "got: {codes:?}");
|
||||
assert_eq!(pages.len(), 1, "got: {pages:?}");
|
||||
assert!(pages[0].1.contains("# Test Suite Sample\n"), "got: {}", pages[0].1);
|
||||
assert!(pages[0].1.ends_with("-->\n\nTest Suite Sample"), "got: {}", pages[0].1);
|
||||
}
|
||||
|
||||
/// An attached file is named, but its name is no text of the page: a page with nothing else on
|
||||
/// it has no text the AI could work with.
|
||||
#[tokio::test]
|
||||
async fn a_file_attached_to_a_onenote_page_is_named_without_counting_as_text() {
|
||||
let (pages, codes) = extract_onenote_section(&onenote_fixture("OneWithFileData.one")).await;
|
||||
assert_eq!(pages.len(), 1, "got: {pages:?}");
|
||||
assert!(pages[0].1.ends_with("<!-- Attached file: testing.docx -->"), "got: {}", pages[0].1);
|
||||
assert_eq!(codes, vec!["NO_TEXT_EXTRACTED"]);
|
||||
}
|
||||
|
||||
/// The parser cannot decrypt a section protected with a password. Such a section must be
|
||||
/// refused rather than arrive as a section without pages.
|
||||
#[tokio::test]
|
||||
async fn a_onenote_section_protected_with_a_password_is_refused() {
|
||||
let (pages, codes) = extract_onenote_section(&onenote_fixture("Encryption.one")).await;
|
||||
assert!(pages.is_empty(), "got: {pages:?}");
|
||||
assert_eq!(codes, vec!["NOT_A_VALID_DOCUMENT"]);
|
||||
}
|
||||
|
||||
/// The parser does not say why a section fails, cf. `stream_onenote_section`. A file which is
|
||||
/// readable but no section is a property of that file, though.
|
||||
#[tokio::test]
|
||||
async fn a_file_which_is_no_onenote_section_is_reported_as_an_invalid_document() {
|
||||
let directory = tempfile::tempdir().expect("a temporary directory must be available");
|
||||
let path = directory.path().join("notes.one");
|
||||
std::fs::write(&path, "This is no OneNote section.").expect("the file must be writable");
|
||||
|
||||
let (pages, codes) = extract_onenote_section(&path).await;
|
||||
assert!(pages.is_empty(), "got: {pages:?}");
|
||||
assert_eq!(codes, vec!["NOT_A_VALID_DOCUMENT"]);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ pub mod clipboard;
|
||||
pub mod runtime_api;
|
||||
pub mod runtime_certificate;
|
||||
pub mod file_data;
|
||||
mod onenote_markdown;
|
||||
pub mod prompt_injection;
|
||||
pub mod metadata;
|
||||
pub mod media;
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
//! Renders the pages of a OneNote section as Markdown and finds their images, for
|
||||
//! `stream_onenote_section` in `file_data.rs`.
|
||||
//!
|
||||
//! The text is what OneNote shows: runs marked as hidden are left out, among them the markers which
|
||||
//! carry the targets of hyperlinks. Nothing is escaped except the column separator inside a table
|
||||
//! cell, so the prompt-injection filter reads the words exactly as the AI does.
|
||||
|
||||
use std::io::Read;
|
||||
use chrono::{DateTime, SecondsFormat};
|
||||
use onenote_parser::contents::{Content, EmbeddedFile, FileDataStatus, Image, List, NoteTag, OutlineElement, OutlineItem, ParagraphStyling, RichText, Table, TableCell, TextHyperlink};
|
||||
use onenote_parser::page::{Page, PageContent};
|
||||
use onenote_parser::section::Section;
|
||||
use crate::file_data::push_presentation_metadata_field;
|
||||
|
||||
/// OneNote marks the place of the automatic number in the format of a numbered list with this
|
||||
/// character, cf. MS-ONE 2.3.20. A bulleted list does not contain it.
|
||||
const LIST_NUMBER_PLACEHOLDER: char = '\u{FFFD}';
|
||||
|
||||
/// The deepest page level OneNote knows: a page, its subpage, and the subpage of that.
|
||||
const DEEPEST_PAGE_LEVEL: usize = 3;
|
||||
|
||||
/// The metadata comment in front of the first page, naming the section the pages belong to.
|
||||
pub(crate) fn section_metadata_to_markdown(section: &Section) -> Option<String> {
|
||||
let mut fields = Vec::new();
|
||||
push_presentation_metadata_field(&mut fields, "Title", Some(section.display_name()).filter(|name| !name.trim().is_empty()));
|
||||
|
||||
if fields.is_empty() { None } else { Some(format!("<!-- Section Metadata\n{}\n-->", fields.join("\n"))) }
|
||||
}
|
||||
|
||||
/// Renders a page: its title as a heading, its metadata as a comment, and then its contents in the
|
||||
/// order OneNote stores them.
|
||||
///
|
||||
/// The title is a first-level heading and a subpage goes one level deeper, so the headings show the
|
||||
/// AI how the pages of the section belong together. Headings inside a page start below its title.
|
||||
pub(crate) fn page_to_markdown(page: &Page) -> String {
|
||||
let title_level = usize::try_from(page.level()).unwrap_or(1).clamp(1, DEEPEST_PAGE_LEVEL);
|
||||
let mut writer = PageWriter { blocks: Vec::new(), title_level };
|
||||
|
||||
if let Some(title) = page.title_text().map(clean_text).filter(|title| !title.trim().is_empty()) {
|
||||
writer.blocks.push(format!("{} {}", "#".repeat(title_level), single_line(&title)));
|
||||
}
|
||||
|
||||
writer.blocks.extend(page_metadata_to_markdown(page));
|
||||
|
||||
for content in page.contents() {
|
||||
match content {
|
||||
PageContent::Outline(outline) => writer.write_items(outline.items(), 0),
|
||||
PageContent::Image(image) => writer.write_image(image),
|
||||
PageContent::EmbeddedFile(file) => writer.write_embedded_file(file),
|
||||
|
||||
// Ink carries no text of its own. What OneNote recognized in it follows below:
|
||||
PageContent::Ink(_) | PageContent::Unknown => {},
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(recognition) = page.ink_recognition() {
|
||||
let handwriting = clean_text(&recognition.text());
|
||||
if !handwriting.trim().is_empty() {
|
||||
writer.blocks.push(format!("Handwriting recognized by OneNote:\n{handwriting}"));
|
||||
}
|
||||
}
|
||||
|
||||
writer.blocks.join("\n\n")
|
||||
}
|
||||
|
||||
/// The images of a page in the order its text names them: those placed on the page itself, and
|
||||
/// those inside its outlines and tables.
|
||||
pub(crate) fn page_images(page: &Page) -> Vec<&Image> {
|
||||
let mut images = Vec::new();
|
||||
for content in page.contents() {
|
||||
match content {
|
||||
PageContent::Image(image) => images.push(image),
|
||||
PageContent::Outline(outline) => collect_images_of_items(outline.items(), &mut images),
|
||||
PageContent::EmbeddedFile(_) | PageContent::Ink(_) | PageContent::Unknown => {},
|
||||
}
|
||||
}
|
||||
|
||||
images
|
||||
}
|
||||
|
||||
fn collect_images_of_items<'a>(items: &'a [OutlineItem], images: &mut Vec<&'a Image>) {
|
||||
for item in items {
|
||||
match item {
|
||||
OutlineItem::Element(element) => collect_images_of_element(element, images),
|
||||
OutlineItem::Group(group) => collect_images_of_items(group.outlines(), images),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_images_of_element<'a>(element: &'a OutlineElement, images: &mut Vec<&'a Image>) {
|
||||
for content in element.contents() {
|
||||
match content {
|
||||
Content::Image(image) => images.push(image),
|
||||
Content::Table(table) => {
|
||||
for cell in table.contents().iter().flat_map(|row| row.contents()) {
|
||||
for cell_element in cell.contents() {
|
||||
collect_images_of_element(cell_element, images);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Content::RichText(_) | Content::EmbeddedFile(_) | Content::Ink(_) | Content::Unknown => {},
|
||||
}
|
||||
}
|
||||
|
||||
collect_images_of_items(element.children(), images);
|
||||
}
|
||||
|
||||
/// The data of an image in a format every model with image input reads, together with its media
|
||||
/// type, or `None` when there is none.
|
||||
///
|
||||
/// OneNote keeps some images in formats which no model reads, e.g., a printout of a document as
|
||||
/// EMF. It then usually adds a picture for browsers, which takes the place of the original.
|
||||
pub(crate) fn image_data_for_models(image: &Image) -> Option<(Box<dyn Read>, &'static str)> {
|
||||
if image.data_status() == FileDataStatus::Available
|
||||
&& let Some(media_type) = image.extension().and_then(media_type_of_extension)
|
||||
&& let Some(reader) = image.read() {
|
||||
return Some((reader, media_type));
|
||||
}
|
||||
|
||||
let picture = image.web_picture().filter(|picture| picture.data_status() == FileDataStatus::Available)?;
|
||||
let media_type = picture.extension().and_then(media_type_of_extension)?;
|
||||
Some((picture.read(), media_type))
|
||||
}
|
||||
|
||||
/// The media type of an image format every model with image input reads. OneNote stores the
|
||||
/// extension with or without its dot.
|
||||
fn media_type_of_extension(extension: &str) -> Option<&'static str> {
|
||||
match extension.trim_start_matches('.').to_ascii_lowercase().as_str() {
|
||||
"png" => Some("image/png"),
|
||||
"jpg" | "jpeg" => Some("image/jpeg"),
|
||||
"gif" => Some("image/gif"),
|
||||
"webp" => Some("image/webp"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The metadata comment of a page: who wrote it, and when.
|
||||
fn page_metadata_to_markdown(page: &Page) -> Option<String> {
|
||||
let mut fields = Vec::new();
|
||||
push_presentation_metadata_field(&mut fields, "Author", page.author());
|
||||
push_presentation_metadata_field(&mut fields, "Created", format_time(page.created_time().unix_timestamp(), page.created_time().nanosecond()).as_deref());
|
||||
push_presentation_metadata_field(&mut fields, "Modified", format_time(page.updated_time().unix_timestamp(), page.updated_time().nanosecond()).as_deref());
|
||||
|
||||
if fields.is_empty() { None } else { Some(format!("<!-- Page Metadata\n{}\n-->", fields.join("\n"))) }
|
||||
}
|
||||
|
||||
/// Formats a point in time the way the metadata of documents and presentations shows it.
|
||||
fn format_time(unix_timestamp: i64, nanosecond: u32) -> Option<String> {
|
||||
DateTime::from_timestamp(unix_timestamp, nanosecond).map(|time| time.to_rfc3339_opts(SecondsFormat::Secs, true))
|
||||
}
|
||||
|
||||
/// Collects the Markdown blocks of a page, which are separated by blank lines.
|
||||
struct PageWriter {
|
||||
blocks: Vec<String>,
|
||||
title_level: usize,
|
||||
}
|
||||
|
||||
impl PageWriter {
|
||||
/// Writes the items of an outline. `list_depth` counts the list items they are nested in.
|
||||
fn write_items(&mut self, items: &[OutlineItem], list_depth: usize) {
|
||||
//
|
||||
// OneNote numbers a list itself, counting the items of the same format in a row, cf.
|
||||
// MS-ONE 2.3.20. Anything else between them starts the count anew:
|
||||
//
|
||||
let mut number = 0;
|
||||
for item in items {
|
||||
match item {
|
||||
OutlineItem::Element(element) => {
|
||||
let numbered = element.list_contents().first().filter(|list| is_numbered(list));
|
||||
number = match numbered {
|
||||
Some(list) => list.list_restart().and_then(|restart| u32::try_from(restart).ok()).unwrap_or(number + 1),
|
||||
None => 0,
|
||||
};
|
||||
|
||||
self.write_element(element, list_depth, number);
|
||||
},
|
||||
|
||||
OutlineItem::Group(group) => {
|
||||
number = 0;
|
||||
self.write_items(group.outlines(), list_depth);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes an outline element and the elements nested in it.
|
||||
///
|
||||
/// `number` is the position of the element in a numbered list, and is only read when the
|
||||
/// element is an item of one.
|
||||
fn write_element(&mut self, element: &OutlineElement, list_depth: usize, number: u32) {
|
||||
let list_marker = element.list_contents().first().map(|list| if is_numbered(list) { format!("{number}.") } else { "-".to_string() });
|
||||
|
||||
//
|
||||
// A list item's marker goes in front of its first paragraph only. Four spaces per level
|
||||
// nest an item below its parent for both kinds of marker.
|
||||
//
|
||||
let mut prefix = list_marker.as_ref().map(|marker| format!("{}{marker} ", " ".repeat(list_depth)));
|
||||
for content in element.contents() {
|
||||
match content {
|
||||
Content::RichText(text) => {
|
||||
let paragraph = rich_text_to_markdown(text);
|
||||
if paragraph.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let tags = note_tags_to_markdown(text.note_tags());
|
||||
let block = match prefix.take() {
|
||||
Some(prefix) => format!("{prefix}{tags}{}", single_line(¶graph)),
|
||||
None => match heading_level(text.paragraph_style()) {
|
||||
Some(level) => format!("{} {tags}{}", "#".repeat((self.title_level + level).min(6)), single_line(¶graph)),
|
||||
None => format!("{tags}{paragraph}"),
|
||||
},
|
||||
};
|
||||
|
||||
self.blocks.push(block);
|
||||
},
|
||||
|
||||
Content::Table(table) => self.write_table(table),
|
||||
Content::Image(image) => self.write_image(image),
|
||||
Content::EmbeddedFile(file) => self.write_embedded_file(file),
|
||||
Content::Ink(_) | Content::Unknown => {},
|
||||
}
|
||||
}
|
||||
|
||||
let child_depth = if list_marker.is_some() { list_depth + 1 } else { list_depth };
|
||||
self.write_items(element.children(), child_depth);
|
||||
}
|
||||
|
||||
/// Writes a table. OneNote knows no header row, so the first row takes that place, which a
|
||||
/// Markdown table requires.
|
||||
fn write_table(&mut self, table: &Table) {
|
||||
let rows: Vec<Vec<String>> = table.contents().iter()
|
||||
.map(|row| row.contents().iter().map(|cell| self.cell_to_markdown(cell)).collect())
|
||||
.collect();
|
||||
|
||||
let columns = rows.iter().map(Vec::len).max().unwrap_or(0);
|
||||
if columns == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut lines = Vec::with_capacity(rows.len() + 1);
|
||||
for (index, row) in rows.into_iter().enumerate() {
|
||||
let mut cells = row;
|
||||
cells.resize(columns, String::new());
|
||||
lines.push(format!("| {} |", cells.join(" | ")));
|
||||
|
||||
if index == 0 {
|
||||
lines.push(format!("|{}|", vec![" --- "; columns].join("|")));
|
||||
}
|
||||
}
|
||||
|
||||
self.blocks.push(lines.join("\n"));
|
||||
}
|
||||
|
||||
/// A cell holds outline elements of its own, while a Markdown cell holds a single line. Its
|
||||
/// paragraphs are therefore joined by line break tags, and its column separators escaped.
|
||||
fn cell_to_markdown(&self, cell: &TableCell) -> String {
|
||||
let mut writer = PageWriter { blocks: Vec::new(), title_level: self.title_level };
|
||||
for element in cell.contents() {
|
||||
writer.write_element(element, 0, 0);
|
||||
}
|
||||
|
||||
writer.blocks.join("<br>").replace('\n', "<br>").replace('|', "\\|")
|
||||
}
|
||||
|
||||
/// Writes the text OneNote recognized in an image. The image itself travels separately.
|
||||
fn write_image(&mut self, image: &Image) {
|
||||
let text = image.alt_text().or(image.text()).map(clean_text).unwrap_or_default();
|
||||
if !text.trim().is_empty() {
|
||||
self.blocks.push(format!("Text in an image: {text}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Names a file attached to the page, in a comment: its content stays in the file, so its name
|
||||
/// alone must not make a page look readable.
|
||||
fn write_embedded_file(&mut self, file: &EmbeddedFile) {
|
||||
let name = clean_text(file.filename());
|
||||
let mut fields = Vec::new();
|
||||
push_presentation_metadata_field(&mut fields, "Attached file", Some(name.as_str()).filter(|name| !name.trim().is_empty()));
|
||||
|
||||
if let Some(field) = fields.pop() {
|
||||
self.blocks.push(format!("<!-- {field} -->"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a list is numbered rather than bulleted, cf. MS-ONE 2.3.20.
|
||||
fn is_numbered(list: &List) -> bool {
|
||||
list.list_format().contains(&LIST_NUMBER_PLACEHOLDER)
|
||||
}
|
||||
|
||||
/// The level of a paragraph formatted with one of OneNote's heading styles, which are named `h1`
|
||||
/// to `h6`.
|
||||
fn heading_level(style: &ParagraphStyling) -> Option<usize> {
|
||||
let level = style.style_id()?.strip_prefix('h')?.parse::<usize>().ok()?;
|
||||
(1..=6).contains(&level).then_some(level)
|
||||
}
|
||||
|
||||
/// Renders the note tags of a paragraph. A tag with a check box becomes a Markdown task, every
|
||||
/// other tag is named by its label, e.g., "Important" or "Question".
|
||||
fn note_tags_to_markdown(tags: &[NoteTag]) -> String {
|
||||
let mut markdown = String::new();
|
||||
for tag in tags {
|
||||
let Some(definition) = tag.definition() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if definition.shape().is_checkable() {
|
||||
markdown.push_str(if tag.item_status().completed() { "[x] " } else { "[ ] " });
|
||||
} else {
|
||||
let label = single_line(&clean_text(definition.label()));
|
||||
if !label.trim().is_empty() {
|
||||
markdown.push_str(&format!("({}) ", label.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
markdown
|
||||
}
|
||||
|
||||
/// Renders the visible text of a paragraph, with its hyperlinks as Markdown links.
|
||||
///
|
||||
/// The paragraph is split into its text runs, which are formatted alike, and at the ends of its
|
||||
/// hyperlinks. Both are given as offsets in UTF-16 code units.
|
||||
fn rich_text_to_markdown(text: &RichText) -> String {
|
||||
let content = text.text();
|
||||
let styles = text.text_run_formatting();
|
||||
if styles.is_empty() {
|
||||
return clean_text(content);
|
||||
}
|
||||
|
||||
let byte_offsets = byte_offsets_by_utf16_offset(content);
|
||||
let length = byte_offsets.len() - 1;
|
||||
|
||||
//
|
||||
// Each run ends where the next one starts, and the last one at the end of the paragraph. This
|
||||
// is how OneNote stores them, cf. MS-ONE 2.3.76:
|
||||
//
|
||||
let mut runs = Vec::with_capacity(styles.len());
|
||||
let mut start = 0;
|
||||
for (index, style) in styles.iter().enumerate() {
|
||||
let end = text.text_run_indices().get(index).map_or(length, |&end| end as usize).clamp(start, length);
|
||||
runs.push((start, end, style));
|
||||
start = end;
|
||||
}
|
||||
|
||||
let links = text.hyperlinks();
|
||||
let mut boundaries = vec![0, length];
|
||||
boundaries.extend(runs.iter().map(|&(_, end, _)| end));
|
||||
boundaries.extend(links.iter().flat_map(|link| [link.start() as usize, link.end() as usize]).filter(|&offset| offset <= length));
|
||||
boundaries.sort_unstable();
|
||||
boundaries.dedup();
|
||||
|
||||
let mut markdown = String::new();
|
||||
let mut open_link: Option<&TextHyperlink> = None;
|
||||
for window in boundaries.windows(2) {
|
||||
let (start, end) = (window[0], window[1]);
|
||||
let hidden = runs.iter().find(|&&(run_start, run_end, _)| run_start <= start && start < run_end).is_some_and(|&(_, _, style)| style.hidden());
|
||||
if hidden {
|
||||
continue;
|
||||
}
|
||||
|
||||
if open_link.is_none() {
|
||||
open_link = links.iter().find(|link| link.start() as usize == start);
|
||||
if open_link.is_some() {
|
||||
markdown.push('[');
|
||||
}
|
||||
}
|
||||
|
||||
markdown.push_str(&content[byte_offsets[start]..byte_offsets[end]]);
|
||||
|
||||
if let Some(link) = open_link.filter(|link| link.end() as usize == end) {
|
||||
markdown.push_str(&format!("]({})", link.target()));
|
||||
open_link = None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(link) = open_link {
|
||||
markdown.push_str(&format!("]({})", link.target()));
|
||||
}
|
||||
|
||||
clean_text(&markdown)
|
||||
}
|
||||
|
||||
/// Maps every UTF-16 offset of a text to the byte offset of the character it falls into, plus the
|
||||
/// end of the text. An offset inside a surrogate pair maps to the start of its character, so a
|
||||
/// boundary there never splits a character.
|
||||
fn byte_offsets_by_utf16_offset(text: &str) -> Vec<usize> {
|
||||
let mut offsets = Vec::with_capacity(text.len() + 1);
|
||||
for (byte_offset, character) in text.char_indices() {
|
||||
for _ in 0..character.len_utf16() {
|
||||
offsets.push(byte_offset);
|
||||
}
|
||||
}
|
||||
|
||||
offsets.push(text.len());
|
||||
offsets
|
||||
}
|
||||
|
||||
/// Removes the control characters OneNote uses internally. A vertical tab is OneNote's line break
|
||||
/// inside a paragraph and becomes a line break.
|
||||
fn clean_text(text: &str) -> String {
|
||||
text.chars()
|
||||
.filter_map(|character| match character {
|
||||
'\u{000B}' => Some('\n'),
|
||||
'\n' | '\t' => Some(character),
|
||||
'\u{FDDF}' => None,
|
||||
_ if character.is_control() => None,
|
||||
_ => Some(character),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Puts text which Markdown needs on one line, e.g., a heading or a list item, on one line.
|
||||
fn single_line(text: &str) -> String {
|
||||
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn offsets_inside_a_surrogate_pair_never_split_its_character() {
|
||||
let text = "a😀b";
|
||||
let offsets = byte_offsets_by_utf16_offset(text);
|
||||
|
||||
assert_eq!(offsets, vec![0, 1, 1, 5, 6]);
|
||||
assert_eq!(&text[offsets[1]..offsets[3]], "😀");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_internal_control_characters_of_onenote_are_removed() {
|
||||
assert_eq!(clean_text("first\u{000B}second\u{FDDF}\u{0007}"), "first\nsecond");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_title_across_lines_ends_up_on_one_line() {
|
||||
assert_eq!(single_line(" A title\nacross lines "), "A title across lines");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_images_every_model_reads_are_handed_on() {
|
||||
assert_eq!(media_type_of_extension(".PNG"), Some("image/png"));
|
||||
assert_eq!(media_type_of_extension("jpg"), Some("image/jpeg"));
|
||||
assert_eq!(media_type_of_extension(".emf"), None);
|
||||
assert_eq!(media_type_of_extension(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn points_in_time_read_like_the_metadata_of_documents() {
|
||||
assert_eq!(format_time(0, 0).as_deref(), Some("1970-01-01T00:00:00Z"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user