Added OneNote sections as a document type (#1161)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-04 17:15:30 +01:00
1 parent 677b650a79
commit ce846e540e
24 files changed
+1141 -22

No files matched your search

@@ -12037,6 +12037,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T906183311"] = "How to update"
-- A test configuration is active. It acts like a configuration of your organization and may, for example, approve assistant plugins. AI Studio removes it the next time you start the app.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T923110805"] = "A test configuration is active. It acts like a configuration of your organization and may, for example, approve assistant plugins. AI Studio removes it the next time you start the app."
-- We use this library to read the sections of OneNote notebooks. This allows us to insert OneNote pages into prompts and take them into account in RAG processes. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T938136536"] = "We use this library to read the sections of OneNote notebooks. This allows us to insert OneNote pages into prompts and take them into account in RAG processes. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio."
-- Install Pandoc
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T986578435"] = "Install Pandoc"
@@ -378,6 +378,7 @@
<ThirdPartyComponent Name="dirs" Developer="soc, Wang Xuerui & Open Source Community" LicenseName="MIT" LicenseUrl="https://codeberg.org/dirs/dirs-rs/src/branch/main/LICENSE-MIT" RepositoryUrl="https://codeberg.org/dirs/dirs-rs" UseCase="@T("dirs determines the platform-specific local application data directory. AI Studio uses it so the Flatpak startup log is written to the same application data directory that Tauri uses.")"/>
<ThirdPartyComponent Name="rand" Developer="Rust developers & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/rust-random/rand/blob/master/LICENSE-MIT" RepositoryUrl="https://github.com/rust-random/rand" UseCase="@T("We must generate random numbers, e.g., for securing the interprocess communication between the user interface and the runtime. The rand library is great for this purpose.")"/>
<ThirdPartyComponent Name="pptx-to-md" Developer="Nils Kruthoff & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/nilskruthoff/pptx-parser/blob/master/LICENCE-MIT" RepositoryUrl="https://github.com/nilskruthoff/pptx-parser" UseCase="@T("We use this library to be able to read PowerPoint files. This allows us to insert content from slides into prompts and take PowerPoint files into account in RAG processes. We thank Nils Kruthoff for his work on this Rust crate.")"/>
<ThirdPartyComponent Name="onenote_parser" Developer="Markus Siemens, Henry Heino, Mariusz Woloszyn, Laurent Cozic, pedr & Open Source Community" LicenseName="MPL-2.0" LicenseUrl="https://github.com/msiemens/onenote.rs/blob/master/LICENSE" RepositoryUrl="https://github.com/msiemens/onenote.rs" UseCase="@T("We use this library to read the sections of OneNote notebooks. This allows us to insert OneNote pages into prompts and take them into account in RAG processes. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio.")"/>
<ThirdPartyComponent Name="base64" Developer="Marshall Pierce, Alice Maz & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/marshallpierce/rust-base64/blob/master/LICENSE-MIT" RepositoryUrl="https://github.com/marshallpierce/rust-base64" UseCase="@T("For some data transfers, we need to encode the data in base64. This Rust library is great for this purpose.")"/>
<ThirdPartyComponent Name="Rust Crypto" Developer="Artyom Pavlov, Tony Arcieri, Brian Warner, Arthur Gautier, Vlad Filippov, Friedel Ziegelmayer, Nicolas Stalder & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/RustCrypto/traits/blob/master/cipher/LICENSE-MIT" RepositoryUrl="https://github.com/RustCrypto" UseCase="@T("When transferring sensitive data between Rust runtime and .NET app, we encrypt the data. We use some libraries from the Rust Crypto project for this purpose: cipher, aes, cbc, pbkdf2, hmac, and sha2. We are thankful for the great work of the Rust Crypto project.")"/>
<ThirdPartyComponent Name="rcgen" Developer="RustTLS developers, est31 & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/rustls/rcgen/blob/main/LICENSE" RepositoryUrl="https://github.com/rustls/rcgen" UseCase="@T("For the secure communication between the user interface and the runtime, we need to create certificates. This Rust library is great for this purpose.")"/>
@@ -12039,6 +12039,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T906183311"] = "Update-Anleitung"
-- A test configuration is active. It acts like a configuration of your organization and may, for example, approve assistant plugins. AI Studio removes it the next time you start the app.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T923110805"] = "Eine Testkonfiguration ist aktiv. Sie funktioniert wie eine Konfiguration Ihrer Organisation und kann beispielsweise Assistenten-Plugins freigeben. AI Studio entfernt sie beim nächsten Start der App."
-- We use this library to read the sections of OneNote notebooks. This allows us to insert OneNote pages into prompts and take them into account in RAG processes. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T938136536"] = "Wir verwenden diese Bibliothek, um die Abschnitte von OneNote-Notizbüchern zu lesen. So ist es möglich, OneNote-Seiten in Prompts einzufügen und in RAG-Prozessen zu berücksichtigen. Der genaue, unter der MPL lizenzierte Quellcode ist im verlinkten Repository verfügbar und in den mit AI Studio gebündelten Offline-Hinweisen angegeben."
-- Install Pandoc
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T986578435"] = "Pandoc installieren"
@@ -12039,6 +12039,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T906183311"] = "How to update"
-- A test configuration is active. It acts like a configuration of your organization and may, for example, approve assistant plugins. AI Studio removes it the next time you start the app.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T923110805"] = "A test configuration is active. It acts like a configuration of your organization and may, for example, approve assistant plugins. AI Studio removes it the next time you start the app."
-- We use this library to read the sections of OneNote notebooks. This allows us to insert OneNote pages into prompts and take them into account in RAG processes. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T938136536"] = "We use this library to read the sections of OneNote notebooks. This allows us to insert OneNote pages into prompts and take them into account in RAG processes. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio."
-- Install Pandoc
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T986578435"] = "Install Pandoc"
@@ -7,6 +7,13 @@ public sealed class ContentStreamDocumentDetails
[JsonPropertyName("page_number")]
public int? PageNumber { get; set; }
/// <summary>
/// Whether the page is one the file itself knows, such as a page of a OneNote section, rather
/// than one the runtime estimated from the page breaks of a Word or OpenDocument file.
/// </summary>
[JsonPropertyName("page_is_exact")]
public bool PageIsExact { get; set; }
[JsonPropertyName("image")]
public ContentStreamPptxImageData? Image { get; set; }
}
@@ -26,12 +26,13 @@ public sealed class DocumentManager
//
// A Word or OpenDocument file carries no fixed page layout, so the runtime derives these
// boundaries from page breaks and heuristics. We note the estimate as a comment rather
// than as a heading: a heading would sit on the same level as the document's own first
// level headings, leaving the AI unable to tell the structure of the document apart from
// our boundaries. The presentation reader marks its slides the same way.
// boundaries from page breaks and heuristics and says so. The pages of a OneNote section,
// on the other hand, are those of the file. We note the page as a comment rather than as
// a heading: a heading would sit on the same level as the document's own first level
// headings, leaving the AI unable to tell the structure of the document apart from our
// boundaries. The presentation reader marks its slides the same way.
//
this.currentPageContent.AppendLine($"<!-- Estimated page {pageNumber} -->");
this.currentPageContent.AppendLine(metadata.Document?.PageIsExact is true ? $"<!-- Page {pageNumber} -->" : $"<!-- Estimated page {pageNumber} -->");
this.currentPageContent.AppendLine();
this.currentPageContent.Append(content);
@@ -95,9 +95,10 @@ internal static class FileExclusions
private static readonly string[] ALWAYS_EXCLUDED_FILES = ["Thumbs.db", "ehthumbs.db", "ehthumbs_vista.db", "desktop.ini", ".DS_Store", "Icon\r", OFFICE_LOCK_FILE_PATTERN];
/// <summary>
/// Folders nobody ever wanted as content: what operating systems and ZIP files on macOS leave behind.
/// Folders nobody ever wanted as content: what operating systems and ZIP files on macOS leave behind,
/// and the recycle bin of a OneNote notebook, which holds the pages the user deleted.
/// </summary>
private static readonly string[] ALWAYS_EXCLUDED_FOLDERS = ["__MACOSX", "$RECYCLE.BIN", "System Volume Information"];
private static readonly string[] ALWAYS_EXCLUDED_FOLDERS = ["__MACOSX", "$RECYCLE.BIN", "System Volume Information", "OneNote_RecycleBin"];
private static readonly char[] SEPARATORS = [';', '\r', '\n'];
@@ -84,6 +84,10 @@ public static class FileTypes
// The legacy binary ".ppt" is missing on purpose: AI Studio has no reader for it, so offering
// it would only let users attach a file which cannot be read.
public static readonly FileTypeFilter POWER_POINT = FileTypeFilter.Leaf("PowerPoint", "pptx", "odp");
// Only the sections of a notebook carry its pages. The table of contents (".onetoc2") holds none,
// and a package (".onepkg") would have to be unpacked into memory as a whole.
public static readonly FileTypeFilter ONENOTE = FileTypeFilter.Leaf("OneNote", "one");
public static readonly FileTypeFilter MAIL = FileTypeFilter.Leaf(TB("Mail"), "eml", "msg", "mbox");
public static readonly FileTypeFilter LATEX = FileTypeFilter.Leaf("LaTeX", "tex", "bib", "sty", "cls", "log");
@@ -96,7 +100,7 @@ public static class FileTypes
public static readonly FileTypeFilter POWER_POINT_PRESENTATION = FileTypeFilter.Leaf("PowerPoint", "pptx");
public static readonly FileTypeFilter OFFICE_FILES = FileTypeFilter.Parent(TB("Office Files"),
WORD, SPREADSHEET, POWER_POINT, PDF);
WORD, SPREADSHEET, POWER_POINT, ONENOTE, PDF);
public static readonly FileTypeFilter DOCUMENT = FileTypeFilter.Parent(TB("Document"),
TEXT, TABULAR, OFFICE_FILES, SOURCE_CODE, LATEX);
@@ -2,6 +2,7 @@
- Added a setting for the size of the interface, which you find in the app settings. You can choose a size for each of your screens, and AI Studio switches to it when you move its window to another screen. Thanks, Paul Koudelka (`PaulKoudelka`), for this contribution.
- Added formulas in code blocks to the chat: when an AI model puts a single formula into a code block marked as LaTeX, the chat now shows the formula instead of its code. A code block with a LaTeX document or with text in it stays code.
- Added buttons to copy and save the formulas in the chat. When you point at a formula, you can copy its LaTeX code or save it as a LaTeX file, and the export menu of an answer offers its formulas as well.
- Added support for OneNote sections (`.one`): you can attach them to chats and assistants and use them in your local data sources. AI Studio reads every page, including the text OneNote recognized in pictures and handwriting.
- Improved the documentation for IT departments, which now lives in our [wiki](https://github.com/MindWorkAI/AI-Studio/wiki/Enterprise-IT). It is sorted by task, explains the setup on Windows, Linux, and macOS, and offers complete configurations for typical needs, such as using only local models. The link in the app settings leads there, and the wiki also holds the installation guide in English and German.
- Improved the German translation throughout: we proofread all texts and corrected wrong labels as well as spelling and grammar mistakes. The texts now always address you formally and use the same terms everywhere.
- Improved the security of the connection between the app window and AI Studio.
@@ -84,6 +84,36 @@ public sealed class ContentStreamPageNumberTests
}
}
/// <remarks>
/// The runtime estimates the pages of a Word or OpenDocument file from its page breaks, while a
/// OneNote section knows its pages. The AI is told which of the two it reads.
/// </remarks>
[Test]
public void OnlyAnEstimatedDocumentPageIsMarkedAsEstimated()
{
var estimatedStreamId = NewStreamId();
var exactStreamId = NewStreamId();
try
{
ContentStreamSseHandler.ProcessEvent(DocumentEvent(estimatedStreamId, 1, "A page of a Word file."));
ContentStreamSseHandler.ProcessEvent(DocumentEvent(exactStreamId, 1, "A page of a OneNote section.", pageIsExact: true));
var estimated = ContentStreamSseHandler.Clear(estimatedStreamId);
var exact = ContentStreamSseHandler.Clear(exactStreamId);
using (Assert.EnterMultipleScope())
{
Assert.That(estimated?.Content, Does.StartWith("<!-- Estimated page 1 -->"));
Assert.That(exact?.Content, Does.StartWith("<!-- Page 1 -->"));
}
}
finally
{
ContentStreamSseHandler.Clear(estimatedStreamId);
ContentStreamSseHandler.Clear(exactStreamId);
}
}
[Test]
public void TheLastDocumentPageIsReleasedWithItsNumber()
{
@@ -176,11 +206,11 @@ public sealed class ContentStreamPageNumberTests
Metadata = new ContentStreamPdfMetadata { Pdf = new() { PageNumber = pageNumber } },
};
private static ContentStreamSseEvent DocumentEvent(string streamId, int pageNumber, string content) => new()
private static ContentStreamSseEvent DocumentEvent(string streamId, int pageNumber, string content, bool pageIsExact = false) => new()
{
Content = content,
StreamId = streamId,
Metadata = new ContentStreamDocumentMetadata { Document = new() { PageNumber = pageNumber } },
Metadata = new ContentStreamDocumentMetadata { Document = new() { PageNumber = pageNumber, PageIsExact = pageIsExact } },
};
//
+1
View File
@@ -145,6 +145,7 @@ public sealed class FileExclusionsTests
[TestCase("Windows 11.pvm")]
[TestCase("__MACOSX")]
[TestCase("$RECYCLE.BIN")]
[TestCase("OneNote_RecycleBin")]
public void TheDefaultListExcludesTheseFolders(string folderName)
{
var folderPath = Path.Combine(Path.GetTempPath(), "test-data", folderName);
+13
View File
@@ -188,6 +188,19 @@ public class FileTypesTests
path => Assert.That(validation.ValidateFilePath(path), Is.Not.Null));
}
/// <summary>
/// A section of a OneNote notebook is a document, but the table of contents and the package of a
/// notebook are not: the first holds no pages, and the runtime does not unpack the second. Both
/// are binary, which the content check sees by their NUL bytes.
/// </summary>
[Test]
public void OnlyTheSectionsOfAOneNoteNotebookAreDocuments()
{
AssertPath([0xE4, 0x52, 0x5C, 0x7B, 0x8C, 0xD8, 0xA7, 0x4D, 0x00, 0x00, 0x00, 0x00], "Meeting notes.one", true, FileTypes.DOCUMENT);
AssertPath([0xA1, 0x2F, 0xFF, 0x43, 0xD9, 0xEF, 0x76, 0x4C, 0x00, 0x00, 0x00, 0x00], "Open Notebook.onetoc2", false, FileTypes.DOCUMENT);
AssertPath([0x4D, 0x53, 0x43, 0x46, 0x00, 0x00, 0x00, 0x00], "Notebook.onepkg", false, FileTypes.DOCUMENT);
}
[TestCase("recording.mp3")]
[TestCase("meeting.mp4")]
[TestCase("voice-recording.webm")]
@@ -20,6 +20,7 @@ public sealed class LocalRetrievalContentTypeTests
[TestCase("pdf", ExpectedResult = RetrievalContentType.TEXT_DOCUMENT)]
[TestCase("xlsx", ExpectedResult = RetrievalContentType.TEXT_SPREADSHEET)]
[TestCase("pptx", ExpectedResult = RetrievalContentType.TEXT_PRESENTATION)]
[TestCase("one", ExpectedResult = RetrievalContentType.TEXT_DOCUMENT)]
[TestCase("unknown", ExpectedResult = RetrievalContentType.TEXT_DOCUMENT)]
public RetrievalContentType AFileTypeTellsWhatItsPassagesAre(string fileType) => DataSourceLocalRetrievalService.GetRetrievalContentType(fileType);
}
+80 -1
View File
@@ -2178,6 +2178,17 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-primitive-derive"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba7795da175654fe16979af73f81f26a8ea27638d8d9823d317016888a63dc4c"
dependencies = [
"num-traits",
"quote",
"syn 2.0.117",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
@@ -3103,6 +3114,9 @@ name = "hashbrown"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "heapless"
@@ -4186,6 +4200,15 @@ dependencies = [
"imgref",
]
[[package]]
name = "lru"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
dependencies = [
"hashbrown 0.17.0",
]
[[package]]
name = "lz4_flex"
version = "0.14.0"
@@ -4307,6 +4330,7 @@ dependencies = [
"objc2-app-kit",
"objc2-foundation 0.3.2",
"once_cell",
"onenote_parser",
"pbkdf2",
"pdfium-render",
"pptx-to-md",
@@ -4341,6 +4365,7 @@ dependencies = [
"tokio-stream",
"toml 1.1.4+spec-1.1.0",
"tower",
"typed-path 0.12.4",
"webkit2gtk",
"webm-iterable",
"whoami",
@@ -5033,6 +5058,39 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "onenote_parser"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb37f8419671b12472a214dd12d100fef0c8bd44c81e9936266c0d71d7bb4977"
dependencies = [
"bytes",
"encoding_rs",
"enum-primitive-derive",
"itertools 0.14.0",
"log",
"lru",
"num-traits",
"onenote_parser-macros",
"sanitize-filename",
"thiserror 2.0.18",
"time",
"typed-path 0.12.4",
"uuid",
"widestring",
]
[[package]]
name = "onenote_parser-macros"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "951e3499c92baf2181914e9eb2cbbdba13aff0707e7d1a2912da07a51d6fa944"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "onig"
version = "6.5.3"
@@ -6521,6 +6579,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "sanitize-filename"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d"
dependencies = [
"regex",
]
[[package]]
name = "schannel"
version = "0.1.23"
@@ -8432,6 +8499,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typed-path"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c"
[[package]]
name = "typed-path"
version = "0.13.0"
@@ -9116,6 +9189,12 @@ dependencies = [
"safe_arch",
]
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "winapi"
version = "0.3.9"
@@ -10261,7 +10340,7 @@ dependencies = [
"indexmap 2.14.0",
"memchr",
"nix 0.31.3",
"typed-path",
"typed-path 0.13.0",
"windows-sys 0.61.2",
"zopfli",
]
+9
View File
@@ -65,6 +65,15 @@ whoami = "2.1.2"
cfg-if = "1.0.4"
pptx-to-md = "2.0.0"
docx-to-md = "0.2.0"
# Reads the sections of OneNote notebooks (.one) page by page. It reads the file on demand instead of
# loading it into memory, and so does every image it hands out. It is licensed under the MPL-2.0, which
# is why its notices ship with the app, cf. resources/notices/THIRD_PARTY_DOCUMENT_NOTICES.md. Packages
# (.onepkg) stay unsupported: the `onepkg` feature unpacks the whole archive into memory.
onenote_parser = "2.1.0"
# The OneNote parser takes the path of a section as a `TypedPath` of this version without re-exporting it.
typed-path = "0.12.4"
tempfile = "3.27.0"
strum_macros = "0.28.0"
sysinfo = "0.39.6"
@@ -0,0 +1,121 @@
# Document reader third-party notices
These notices are bundled offline with MindWork AI Studio.
## onenote_parser 2.1.0
Copyright (c) 2020-2026 Markus Siemens, Henry Heino, Mariusz Woloszyn, Laurent Cozic, pedr, and the open-source community of onenote.rs.
MindWork AI Studio uses the unmodified crates onenote_parser 2.1.0 and onenote_parser-macros 2.1.0 to read OneNote sections. The exact corresponding source is:
- https://github.com/msiemens/onenote.rs/tree/v2.1.0
- https://crates.io/api/v1/crates/onenote_parser/2.1.0/download
- https://crates.io/api/v1/crates/onenote_parser-macros/2.1.0/download
If a future AI Studio release modifies MPL-covered onenote_parser files, those modifications must be identified and made available separately under MPL-2.0. No such modifications are present in this release.
Mozilla Public License Version 2.0
1. Definitions
1.1. “Contributor” means each individual or legal entity that creates, contributes to the creation of, or owns Covered Software.
1.2. “Contributor Version” means the combination of the Contributions of others (if any) used by a Contributor and that particular Contributor’s Contribution.
1.3. “Contribution” means Covered Software of a particular Contributor.
1.4. “Covered Software” means Source Code Form to which the initial Contributor has attached the notice in Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source Code Form, in each case including portions thereof.
1.5. “Incompatible With Secondary Licenses” means that the initial Contributor has attached the notice described in Exhibit B to the Covered Software; or that the Covered Software was made available under the terms of version 1.1 or earlier of the License, but not also under the terms of a Secondary License.
1.6. “Executable Form” means any form of the work other than Source Code Form.
1.7. “Larger Work” means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software.
1.8. “License” means this document.
1.9. “Licensable” means having the right to grant, to the maximum extent possible, whether at the time of the initial grant or subsequently, any and all of the rights conveyed by this License.
1.10. “Modifications” means any of the following: any file in Source Code Form that results from an addition to, deletion from, or modification of the contents of Covered Software; or any new file in Source Code Form that contains any Covered Software.
1.11. “Patent Claims” of a Contributor means any patent claim(s), including without limitation, method, process, and apparatus claims, in any patent Licensable by such Contributor that would be infringed, but for the grant of the License, by the making, using, selling, offering for sale, having made, import, or transfer of either its Contributions or its Contributor Version.
1.12. “Secondary License” means either the GNU General Public License, Version 2.0, the GNU Lesser General Public License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later versions of those licenses.
1.13. “Source Code Form” means the form of the work preferred for making modifications.
1.14. “You” means an individual or a legal entity exercising rights under this License. For legal entities, “You” includes any entity that controls, is controlled by, or is under common control with You. “Control” means ownership of more than fifty percent of the outstanding shares or beneficial ownership of such entity.
2. License Grants and Conditions
2.1. Grants. Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license under intellectual property rights (other than patent or trademark) Licensable by such Contributor to use, reproduce, make available, modify, display, perform, distribute, and otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or as part of a Larger Work; and under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, import, and otherwise transfer either its Contributions or its Contributor Version.
2.2. Effective Date. The licenses granted in Section 2.1 with respect to any Contribution become effective for each Contribution on the date the Contributor first distributes such Contribution.
2.3. Limitations on Grant Scope. No license is granted in the trademarks, service marks, or logos of any Contributor. Except as otherwise provided in this License, no Contributor grants additional rights by implication, estoppel, or otherwise.
2.4. Subsequent Licenses. No Contributor makes additional grants as a result of Your choice to distribute the Covered Software under a subsequent version of this License or the terms of a Secondary License.
2.5. Representation. Each Contributor represents that the Contributor believes its Contributions are its original creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use. This License is not intended to limit any rights You have under applicable copyright doctrines of fair use, fair dealing, or other equivalents.
2.7. Conditions. Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1.
3. Responsibilities
3.1. Distribution of Source Form. All distribution of Covered Software in Source Code Form, including any Modifications that You create or to which You contribute, must be under the terms of this License. You must inform recipients that the Source Code Form of the Covered Software is governed by the terms of this License, and how they can obtain a copy of this License. You may not attempt to alter or restrict the recipients’ rights in the Source Code Form.
3.2. Distribution of Executable Form. If You distribute Covered Software in Executable Form then such Covered Software must also be made available in Source Code Form, as described in Section 3.1, and You must inform recipients how they can obtain a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more than the cost of distribution to the recipient; and You may distribute such Executable Form under the terms of this License, or sublicense it under different terms, provided that the license for the Executable Form does not attempt to limit or alter the recipients’ rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work. You may create and distribute a Larger Work under terms of Your choice, provided that You also comply with the requirements of this License for the Covered Software. If the Larger Work is a combination of Covered Software with a work governed by one or more Secondary Licenses, and the Covered Software is not Incompatible With Secondary Licenses, this License permits You to additionally distribute such Covered Software under the terms of such Secondary License(s), so that the recipient of the Larger Work may, at their option, further distribute the Covered Software under the terms of either this License or such Secondary License(s).
3.4. Notices. You may not remove or alter the substance of any license notices contained within the Source Code Form of the Covered Software, except that You may alter any license notices to the extent required to remedy known factual inaccuracies. You must include a copy of this License with every copy of the Covered Software You distribute. You may add additional accurate notices of copyright ownership.
3.5. Application of Additional Terms. You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability obligations to one or more recipients of Covered Software. However, You may do so only on Your own behalf, and not on behalf of any Contributor.
4. Inability to Comply Due to Statute or Regulation
If it is impossible for You to comply with any of the terms of this License with respect to some or all of the Covered Software due to statute, judicial order, or regulation then You must comply with the terms of this License to the maximum extent possible and describe the limitations and the code they affect. Such description must be placed in a text file included with all distributions of the Covered Software under this License.
5. Termination
5.1. The rights granted under this License will terminate automatically if You fail to comply with any of its terms. However, if You become compliant, then the rights granted under this License from a particular Contributor are reinstated provisionally, unless and until such Contributor explicitly and finally terminates Your grants, and on an ongoing basis, if such Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days after You have come back into compliance. Moreover, Your grants from a particular Contributor are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by some reasonable means, this is the first time You have received notice of non-compliance with this License from such Contributor, and You become compliant prior to 30 days after Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent infringement claim alleging that a Contributor Version directly or indirectly infringes any patent, then the rights granted to You by any and all Contributors for the Covered Software under Section 2.1 will terminate.
5.3. In the event of termination under Sections 5.1 or 5.2, all end user license agreements (excluding distributors and resellers) which have been validly granted by You or Your distributors under this License prior to termination shall survive termination.
6. Disclaimer of Warranty
Covered Software is provided under this License on an “as is” basis, without warranty of any kind, either expressed, implied, or statutory, including, without limitation, warranties that the Covered Software is free of defects, merchantable, fit for a particular purpose or non-infringing. The entire risk as to the quality and performance of the Covered Software is with You.
7. Limitation of Liability
Under no circumstances and under no legal theory, whether tort, contract, or otherwise, shall any Contributor, or anyone who distributes Covered Software as permitted above, be liable to You for any direct, indirect, special, incidental, or consequential damages of any character including, without limitation, damages for lost profits, loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses, even if such party shall have been informed of the possibility of such damages.
8. Litigation
Any litigation relating to this License may be brought only in the courts of a jurisdiction where the defendant maintains its principal place of business and such litigation shall be governed by laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this Section shall prevent a party’s ability to bring cross-claims or counter-claims.
9. Miscellaneous
This License represents the complete agreement concerning the subject matter hereof. If any provision is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. Any law or regulation which provides that the language of a contract shall be construed against the drafter shall not be used to construe this License against a Contributor.
10. Versions of the License
10.1. New Versions. Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other than the license steward has the right to modify or publish new versions of this License.
10.2. Effect of New Versions. You may distribute the Covered Software under the terms of the version of the License under which You originally received the Covered Software, or under the terms of any subsequent version published by the license steward.
10.3. Modified Versions. If you create software not governed by this License, and you want to create a new license for such software, you may create and use a modified version of this License if you rename the license and remove any references to the name of the license steward (except to note that such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses. If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under the terms of this version of the License, the notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
Exhibit B - “Incompatible With Secondary Licenses” Notice
This Source Code Form is “Incompatible With Secondary Licenses”, as defined by the Mozilla Public License, v. 2.0.
+369 -11
View File
@@ -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(&section);
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(&current), 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"]);
}
}
+1
View File
@@ -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;
+455
View File
@@ -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(&paragraph)),
None => match heading_level(text.paragraph_style()) {
Some(level) => format!("{} {tags}{}", "#".repeat((self.title_level + level).min(6)), single_line(&paragraph)),
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"));
}
}
Binary file not shown.
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
The OneNote sections in this folder come unchanged from Microsoft's test suites for the Office protocols,
https://github.com/OfficeDev/Interop-TestSuites/, folder FileSyncandWOPI/Source/MS-ONESTORE/TestSuite/Resources.
That repository is licensed as follows:
---
The MIT License (MIT)
Copyright (c) 2015 Office
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
Binary file not shown.