diff --git a/AGENTS.md b/AGENTS.md index 331b7b39..6ce4f70d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,7 @@ bytes ─▶ magic/open_strategy ─▶ DecodedFile ─▶ Document ─▶ Eleme | `src/odr/internal/common/` | Reusable impls: `Path`/`AbsPath`, base `Document`, filesystem, `style`, table cursor/range, temp files. | | `src/odr/internal/util/` | Helpers: `byte_stream_util`, `string_util`, `stream_util`, `document_util`, `xml_util`. | | `src/odr/internal/magic.*`, `open_strategy.*` | File-type detection + open/dispatch. | +| `src/odr/internal/file_type_table.*` | **The** per-`FileType` table: extensions, MIME types, category, document type, `FileTypeCapabilities`. Every public lookup in `odr.hpp` is a thin forward into it — extend the table, not the lookups. | | `src/odr/internal/html/` | Generic HTML renderer. | | `src/odr/internal/cfb/`, `zip/` | Container formats (CFB, ZIP). | | `src/odr/internal/odf/` | OpenDocument (odt/ods/odp/odg); see [`odf/AGENTS.md`](src/odr/internal/odf/AGENTS.md). | @@ -138,8 +139,12 @@ cmake --build cmake-build-relwithdebinfo --target translate # CLI: file → HTM ## Adding / extending a document format -1. Detection: extend `magic`/`open_strategy` to map bytes → `FileType` - (+ `DecoderEngine`) and construct your `DecodedFile`. +1. Detection: extend `magic`/`open_strategy` to map bytes → `FileType` and + construct your `DecodedFile`, and add a row to + `internal/file_type_table.cpp` (extensions, MIME types, category, document + type, capabilities) — `odr_test` fails if a `FileType` has no row, if an + alias is claimed twice, or if the declared capabilities exceed what the + engines actually do. 2. For documents: subclass `internal::Document`; in its constructor build an `ElementRegistry` and an `ElementAdapter` (pattern above). 3. Implement the per-element adapters you can populate; the **generic HTML diff --git a/CMakeLists.txt b/CMakeLists.txt index a2735b8b..d7bc0c0e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -93,6 +93,7 @@ set(ODR_SOURCE_FILES "src/odr/table_dimension.cpp" "src/odr/table_position.cpp" + "src/odr/internal/file_type_table.cpp" "${CMAKE_CURRENT_BINARY_DIR}/src/odr/internal/git_info.cpp" "src/odr/internal/magic.cpp" "src/odr/internal/open_strategy.cpp" diff --git a/README.md b/README.md index 6c7669e7..b47f32cc 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,35 @@ C++ library to visualize files, especially documents, in HTML. - [cfb](https://github.com/opendocument-app/OpenDocument.core/issues/110) (Microsoft Compound File Binary File Format) - ttf / otf (font specimen pages) +Every accepted file extension and MIME type — including the template and +macro-enabled aliases (`docm`, `dotx`, `xltx`, `ppsx`, …) — is enumerable at +runtime via `file_extensions_by_file_type` / `mimetypes_by_file_type`, so a +consumer never has to maintain its own copy of these tables. + ## Detected but not decoded -These are recognised by `list_file_types` / `mimetype` so a caller can report -them, but there is no decoder — opening one throws: +These are classified by extension and MIME type, and `rtf`/`wpd` are also +recognised from their bytes, so a caller can report them — but there is no +decoder, and opening one throws: - rtf - wpd (WordPerfect) +- md (Markdown) +- xlsb (Excel binary workbook — an OOXML package whose workbook parts are + binary rather than spreadsheetml) + +## Asking what is supported + +`capabilities_by_file_type(FileType)` answers, per format, whether the library +can detect, open, decrypt, render, edit, save or encrypt it — the decisions a +caller has to make *before* it holds a file, e.g. which MIME types to advertise +to a system file picker. It is a declared upper bound; `DecodedFile::capabilities()` +and `Document::is_editable` / `is_savable` give the precise answer for a +concrete file. + +Editing and saving are currently limited to odt, odp, odg (`edit` + `save`), +ods (`save` only) and docx (`edit` + `save`); saving with a password is not +supported for any format. ## Unsupported files diff --git a/jni/CMakeLists.txt b/jni/CMakeLists.txt index dea2c69e..99c5d0cb 100644 --- a/jni/CMakeLists.txt +++ b/jni/CMakeLists.txt @@ -87,6 +87,7 @@ add_jar(odr_java "java/app/opendocument/core/FileLocation.java" "java/app/opendocument/core/FileMeta.java" "java/app/opendocument/core/FileType.java" + "java/app/opendocument/core/FileTypeCapabilities.java" "java/app/opendocument/core/FileWalker.java" "java/app/opendocument/core/Filesystem.java" "java/app/opendocument/core/FontFile.java" diff --git a/jni/java/app/opendocument/core/DecodedFile.java b/jni/java/app/opendocument/core/DecodedFile.java index e31725f4..ab4a7570 100644 --- a/jni/java/app/opendocument/core/DecodedFile.java +++ b/jni/java/app/opendocument/core/DecodedFile.java @@ -55,6 +55,15 @@ public DecodedFile decrypt(String password) { return new DecodedFile(decryptNative(handle(), password)); } + /** + * What can be done with this file. Refines {@link Odr#capabilitiesByFileType}; {@code edit}, + * {@code save} and {@code encrypt} stay as declared for the format — ask {@link Document} for + * the precise answer. + */ + public FileTypeCapabilities capabilities() { + return capabilitiesNative(handle()); + } + public boolean isDecodable() { return isDecodableNative(handle()); } @@ -129,6 +138,8 @@ public FontFile asFontFile() { private native long decryptNative(long handle, String password); + private native FileTypeCapabilities capabilitiesNative(long handle); + private native boolean isDecodableNative(long handle); private native boolean isTextFileNative(long handle); diff --git a/jni/java/app/opendocument/core/FileType.java b/jni/java/app/opendocument/core/FileType.java index 61e58e1c..eab8ef0c 100644 --- a/jni/java/app/opendocument/core/FileType.java +++ b/jni/java/app/opendocument/core/FileType.java @@ -5,7 +5,7 @@ public enum FileType { UNKNOWN, OPENDOCUMENT_TEXT, OPENDOCUMENT_PRESENTATION, OPENDOCUMENT_SPREADSHEET, OPENDOCUMENT_GRAPHICS, OFFICE_OPEN_XML_DOCUMENT, OFFICE_OPEN_XML_PRESENTATION, OFFICE_OPEN_XML_WORKBOOK, - OFFICE_OPEN_XML_ENCRYPTED, LEGACY_WORD_DOCUMENT, + OFFICE_OPEN_XML_ENCRYPTED, EXCEL_BINARY_WORKBOOK, LEGACY_WORD_DOCUMENT, LEGACY_POWERPOINT_PRESENTATION, LEGACY_EXCEL_WORKSHEETS, WORD_PERFECT, RICH_TEXT_FORMAT, PORTABLE_DOCUMENT_FORMAT, TEXT_FILE, COMMA_SEPARATED_VALUES, JAVASCRIPT_OBJECT_NOTATION, MARKDOWN, ZIP, diff --git a/jni/java/app/opendocument/core/FileTypeCapabilities.java b/jni/java/app/opendocument/core/FileTypeCapabilities.java new file mode 100644 index 00000000..8bb1369c --- /dev/null +++ b/jni/java/app/opendocument/core/FileTypeCapabilities.java @@ -0,0 +1,50 @@ +package app.opendocument.core; + +/** + * What this library can do with a file format. Mirrors {@code + * odr::FileTypeCapabilities}. + * + *

Declared, format-level support — an upper bound. A concrete file may still fail + * (corrupt, encrypted, an unsupported sub-variant); ask {@link DecodedFile} or {@link Document} for + * the precise answer. The point of the static query is the decisions a caller has to make + * before it holds a file, e.g. which MIME types to advertise to the system file picker. + */ +public final class FileTypeCapabilities { + /** Recognised from its bytes alone, without a file name or MIME type. */ + public final boolean detectByContent; + + /** A decoder exists; {@link Odr#open(String)} can decode it. */ + public final boolean open; + + /** Encrypted instances can be decrypted. */ + public final boolean decrypt; + + /** {@link Html#translate} produces output. */ + public final boolean translateHtml; + + /** {@link Document#isEditable} can be {@code true}. */ + public final boolean edit; + + /** {@link Document#save} is supported. */ + public final boolean save; + + /** {@link Document#save} with a password is supported. */ + public final boolean encrypt; + + FileTypeCapabilities( + boolean detectByContent, + boolean open, + boolean decrypt, + boolean translateHtml, + boolean edit, + boolean save, + boolean encrypt) { + this.detectByContent = detectByContent; + this.open = open; + this.decrypt = decrypt; + this.translateHtml = translateHtml; + this.edit = edit; + this.save = save; + this.encrypt = encrypt; + } +} diff --git a/jni/java/app/opendocument/core/Odr.java b/jni/java/app/opendocument/core/Odr.java index e4a91420..6f17b713 100644 --- a/jni/java/app/opendocument/core/Odr.java +++ b/jni/java/app/opendocument/core/Odr.java @@ -1,6 +1,7 @@ package app.opendocument.core; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; /** Library-level functions. Mirrors the free functions in {@code odr/odr.hpp}. */ @@ -25,10 +26,29 @@ public final class Odr { /** Whether the native library was built with the HTTP server. */ public static native boolean hasHttpServer(); + /** Every file type this library knows about. */ + public static List allFileTypes() { + List result = new ArrayList<>(); + for (int code : allFileTypesNative()) { + result.add(FileType.fromNative(code)); + } + return result; + } + public static FileType fileTypeByFileExtension(String extension) { return FileType.fromNative(fileTypeByFileExtensionNative(extension)); } + /** Every file extension accepted for the file type; the canonical one first. */ + public static List fileExtensionsByFileType(FileType type) { + return Arrays.asList(fileExtensionsByFileTypeNative(type.toNative())); + } + + /** Canonical file extension for the file type, without a leading dot. */ + public static String fileExtensionByFileType(FileType type) { + return fileExtensionByFileTypeNative(type.toNative()); + } + public static FileCategory fileCategoryByFileType(FileType type) { return FileCategory.fromNative(fileCategoryByFileTypeNative(type.toNative())); } @@ -53,10 +73,21 @@ public static FileType fileTypeByMimetype(String mimetype) { return FileType.fromNative(fileTypeByMimetypeNative(mimetype)); } + /** Canonical MIME type for the file type. */ public static String mimetypeByFileType(FileType type) { return mimetypeByFileTypeNative(type.toNative()); } + /** Every MIME type accepted for the file type; the canonical one first. */ + public static List mimetypesByFileType(FileType type) { + return Arrays.asList(mimetypesByFileTypeNative(type.toNative())); + } + + /** What this library can do with the file type. Format-level, i.e. an upper bound. */ + public static FileTypeCapabilities capabilitiesByFileType(FileType type) { + return capabilitiesByFileTypeNative(type.toNative()); + } + /** Determines the possible file types of a file. */ public static List listFileTypes(String path) { List result = new ArrayList<>(); @@ -93,8 +124,14 @@ public static DecodedFile open(String path, DecodePreference preference) { preference.fileTypePriorityNative())); } + private static native int[] allFileTypesNative(); + private static native int fileTypeByFileExtensionNative(String extension); + private static native String[] fileExtensionsByFileTypeNative(int type); + + private static native String fileExtensionByFileTypeNative(int type); + private static native int fileCategoryByFileTypeNative(int type); private static native int documentTypeByFileTypeNative(int type); @@ -109,6 +146,10 @@ public static DecodedFile open(String path, DecodePreference preference) { private static native String mimetypeByFileTypeNative(int type); + private static native String[] mimetypesByFileTypeNative(int type); + + private static native FileTypeCapabilities capabilitiesByFileTypeNative(int type); + private static native int[] listFileTypesNative(String path); private static native long openNative(String path); diff --git a/jni/src/jni_convert.hpp b/jni/src/jni_convert.hpp index 4fd48edc..485370bc 100644 --- a/jni/src/jni_convert.hpp +++ b/jni/src/jni_convert.hpp @@ -39,6 +39,8 @@ jobject make_table_dimensions(JNIEnv *env, const odr::TableDimensions &dimensions); jobject make_table_position(JNIEnv *env, const odr::TablePosition &position); jobject make_file_meta(JNIEnv *env, const odr::FileMeta &meta); +jobject make_file_type_capabilities(JNIEnv *env, + const odr::FileTypeCapabilities &); jobject html_config_to_java(JNIEnv *env, const odr::HtmlConfig &config); odr::HtmlConfig html_config_from_java(JNIEnv *env, jobject config); diff --git a/jni/src/jni_core.cpp b/jni/src/jni_core.cpp index ecfa5c43..c5d1e284 100644 --- a/jni/src/jni_core.cpp +++ b/jni/src/jni_core.cpp @@ -1,3 +1,4 @@ +#include "jni_convert.hpp" #include "odr_jni.hpp" #include @@ -6,13 +7,16 @@ #include #include +#include #include +#include #include namespace { using odr_jni::from_handle; using odr_jni::guarded; +using odr_jni::make_file_type_capabilities; using odr_jni::make_handle; using odr_jni::to_jstring; using odr_jni::to_string; @@ -27,6 +31,26 @@ jintArray to_jint_array(JNIEnv *env, const std::vector &values) { return result; } +jobjectArray to_jstring_array(JNIEnv *env, + const std::span values) { + jclass cls = env->FindClass("java/lang/String"); + if (cls == nullptr) { + return nullptr; + } + jobjectArray result = + env->NewObjectArray(static_cast(values.size()), cls, nullptr); + env->DeleteLocalRef(cls); + if (result == nullptr) { + return nullptr; + } + for (std::size_t i = 0; i < values.size(); ++i) { + jstring value = to_jstring(env, values[i]); + env->SetObjectArrayElement(result, static_cast(i), value); + env->DeleteLocalRef(value); + } + return result; +} + } // namespace // app.opendocument.core.Odr @@ -56,6 +80,17 @@ Java_app_opendocument_core_Odr_identify(JNIEnv *env, jclass) { return guarded(env, [&] { return to_jstring(env, odr::identify()); }); } +extern "C" JNIEXPORT jintArray JNICALL +Java_app_opendocument_core_Odr_allFileTypesNative(JNIEnv *env, jclass) { + return guarded(env, [&] { + std::vector codes; + for (const odr::FileType type : odr::all_file_types()) { + codes.push_back(static_cast(type)); + } + return to_jint_array(env, codes); + }); +} + extern "C" JNIEXPORT jint JNICALL Java_app_opendocument_core_Odr_fileTypeByFileExtensionNative( JNIEnv *env, jclass, jstring extension) { @@ -65,6 +100,26 @@ Java_app_opendocument_core_Odr_fileTypeByFileExtensionNative( }); } +extern "C" JNIEXPORT jobjectArray JNICALL +Java_app_opendocument_core_Odr_fileExtensionsByFileTypeNative(JNIEnv *env, + jclass, + jint type) { + return guarded(env, [&] { + return to_jstring_array(env, odr::file_extensions_by_file_type( + static_cast(type))); + }); +} + +extern "C" JNIEXPORT jstring JNICALL +Java_app_opendocument_core_Odr_fileExtensionByFileTypeNative(JNIEnv *env, + jclass, + jint type) { + return guarded(env, [&] { + return to_jstring(env, odr::file_extension_by_file_type( + static_cast(type))); + }); +} + extern "C" JNIEXPORT jint JNICALL Java_app_opendocument_core_Odr_fileCategoryByFileTypeNative(JNIEnv *env, jclass, jint type) { @@ -128,6 +183,24 @@ Java_app_opendocument_core_Odr_mimetypeByFileTypeNative(JNIEnv *env, jclass, }); } +extern "C" JNIEXPORT jobjectArray JNICALL +Java_app_opendocument_core_Odr_mimetypesByFileTypeNative(JNIEnv *env, jclass, + jint type) { + return guarded(env, [&] { + return to_jstring_array( + env, odr::mimetypes_by_file_type(static_cast(type))); + }); +} + +extern "C" JNIEXPORT jobject JNICALL +Java_app_opendocument_core_Odr_capabilitiesByFileTypeNative(JNIEnv *env, jclass, + jint type) { + return guarded(env, [&] { + return make_file_type_capabilities( + env, odr::capabilities_by_file_type(static_cast(type))); + }); +} + extern "C" JNIEXPORT jintArray JNICALL Java_app_opendocument_core_Odr_listFileTypesNative(JNIEnv *env, jclass, jstring path) { diff --git a/jni/src/jni_file.cpp b/jni/src/jni_file.cpp index 9fc9b89c..db89078b 100644 --- a/jni/src/jni_file.cpp +++ b/jni/src/jni_file.cpp @@ -173,6 +173,15 @@ Java_app_opendocument_core_DecodedFile_isDecodableNative(JNIEnv *env, jobject, }); } +extern "C" JNIEXPORT jobject JNICALL +Java_app_opendocument_core_DecodedFile_capabilitiesNative(JNIEnv *env, jobject, + jlong handle) { + return guarded(env, [&] { + return odr_jni::make_file_type_capabilities(env, + decoded(handle).capabilities()); + }); +} + extern "C" JNIEXPORT jboolean JNICALL Java_app_opendocument_core_DecodedFile_isTextFileNative(JNIEnv *env, jobject, jlong handle) { diff --git a/jni/src/jni_style.cpp b/jni/src/jni_style.cpp index 9dce56a5..3107e325 100644 --- a/jni/src/jni_style.cpp +++ b/jni/src/jni_style.cpp @@ -289,6 +289,20 @@ jobject make_file_meta(JNIEnv *env, const odr::FileMeta &meta) { make_string_opt(env, meta.modification_date)); } +jobject +make_file_type_capabilities(JNIEnv *env, + const odr::FileTypeCapabilities &capabilities) { + return new_object(env, "app/opendocument/core/FileTypeCapabilities", + "(ZZZZZZZ)V", + static_cast(capabilities.detect_by_content), + static_cast(capabilities.open), + static_cast(capabilities.decrypt), + static_cast(capabilities.translate_html), + static_cast(capabilities.edit), + static_cast(capabilities.save), + static_cast(capabilities.encrypt)); +} + jobject html_config_to_java(JNIEnv *env, const odr::HtmlConfig &config) { jclass cls = env->FindClass("app/opendocument/core/HtmlConfig"); if (cls == nullptr) { diff --git a/jni/tests/app/opendocument/core/MetaTest.java b/jni/tests/app/opendocument/core/MetaTest.java index f8ea8640..1697b25e 100644 --- a/jni/tests/app/opendocument/core/MetaTest.java +++ b/jni/tests/app/opendocument/core/MetaTest.java @@ -5,6 +5,9 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import org.junit.jupiter.api.Test; class MetaTest { @@ -38,6 +41,49 @@ void mimetypeRoundTrip() { assertEquals(FileType.OPENDOCUMENT_TEXT, Odr.fileTypeByMimetype(mimetype)); } + @Test + void aliasesRoundTrip() { + List fileTypes = Odr.allFileTypes(); + assertTrue(fileTypes.contains(FileType.OPENDOCUMENT_TEXT)); + + Set seen = new HashSet<>(); + for (FileType fileType : fileTypes) { + for (String extension : Odr.fileExtensionsByFileType(fileType)) { + assertTrue(seen.add("ext:" + extension), extension); + assertEquals(fileType, Odr.fileTypeByFileExtension(extension)); + } + for (String mimetype : Odr.mimetypesByFileType(fileType)) { + assertTrue(seen.add("mime:" + mimetype), mimetype); + assertEquals(fileType, Odr.fileTypeByMimetype(mimetype)); + } + } + + assertEquals( + FileType.OFFICE_OPEN_XML_DOCUMENT, Odr.fileTypeByFileExtension("docm")); + assertEquals("odt", Odr.fileExtensionByFileType(FileType.OPENDOCUMENT_TEXT)); + + // its own type: an OOXML package we deliberately cannot open + assertEquals(FileType.EXCEL_BINARY_WORKBOOK, Odr.fileTypeByFileExtension("xlsb")); + assertFalse(Odr.capabilitiesByFileType(FileType.EXCEL_BINARY_WORKBOOK).open); + } + + @Test + void capabilitiesByFileType() { + FileTypeCapabilities odt = Odr.capabilitiesByFileType(FileType.OPENDOCUMENT_TEXT); + assertTrue(odt.open); + assertTrue(odt.translateHtml); + assertTrue(odt.edit); + + // detected and named, but there is no decoder behind it + FileTypeCapabilities wpd = Odr.capabilitiesByFileType(FileType.WORD_PERFECT); + assertTrue(wpd.detectByContent); + assertFalse(wpd.open); + assertFalse(wpd.translateHtml); + + // spreadsheet editing is force-disabled + assertFalse(Odr.capabilitiesByFileType(FileType.OPENDOCUMENT_SPREADSHEET).edit); + } + @Test void tablePosition() { assertEquals(0, TablePosition.toColumnNum("A")); diff --git a/python/src/bind_core.cpp b/python/src/bind_core.cpp index 16e4de34..e65b1a9f 100644 --- a/python/src/bind_core.cpp +++ b/python/src/bind_core.cpp @@ -9,6 +9,7 @@ #include #include +#include namespace py = pybind11; @@ -64,12 +65,27 @@ void odr_python::bind_core(py::module_ &m) { } void odr_python::bind_functions(py::module_ &m) { + m.def("all_file_types", &odr::all_file_types, + "Every file type this library knows about."); m.def( "file_type_by_file_extension", [](const std::string &extension) { return odr::file_type_by_file_extension(extension); }, py::arg("extension")); + m.def( + "file_extensions_by_file_type", + [](const odr::FileType type) { + const auto extensions = odr::file_extensions_by_file_type(type); + return std::vector(extensions.begin(), extensions.end()); + }, + py::arg("type"), "Every file extension accepted for the file type."); + m.def( + "file_extension_by_file_type", + [](const odr::FileType type) { + return std::string(odr::file_extension_by_file_type(type)); + }, + py::arg("type")); m.def("file_category_by_file_type", &odr::file_category_by_file_type, py::arg("type")); m.def("document_type_by_file_type", &odr::document_type_by_file_type, @@ -91,6 +107,15 @@ void odr_python::bind_functions(py::module_ &m) { return std::string(odr::mimetype_by_file_type(type)); }, py::arg("type")); + m.def( + "mimetypes_by_file_type", + [](const odr::FileType type) { + const auto mimetypes = odr::mimetypes_by_file_type(type); + return std::vector(mimetypes.begin(), mimetypes.end()); + }, + py::arg("type"), "Every MIME type accepted for the file type."); + m.def("capabilities_by_file_type", &odr::capabilities_by_file_type, + py::arg("type"), "What this library can do with the file type."); m.def( "list_file_types", diff --git a/python/src/bind_file.cpp b/python/src/bind_file.cpp index 51102e98..c9003af7 100644 --- a/python/src/bind_file.cpp +++ b/python/src/bind_file.cpp @@ -30,6 +30,7 @@ void odr_python::bind_file(py::module_ &m) { odr::FileType::office_open_xml_workbook) .value("office_open_xml_encrypted", odr::FileType::office_open_xml_encrypted) + .value("excel_binary_workbook", odr::FileType::excel_binary_workbook) .value("legacy_word_document", odr::FileType::legacy_word_document) .value("legacy_powerpoint_presentation", odr::FileType::legacy_powerpoint_presentation) @@ -105,6 +106,18 @@ void odr_python::bind_file(py::module_ &m) { .def_readwrite("creation_date", &odr::FileMeta::creation_date) .def_readwrite("modification_date", &odr::FileMeta::modification_date); + py::class_(m, "FileTypeCapabilities") + .def(py::init<>()) + .def_readwrite("detect_by_content", + &odr::FileTypeCapabilities::detect_by_content) + .def_readwrite("open", &odr::FileTypeCapabilities::open) + .def_readwrite("decrypt", &odr::FileTypeCapabilities::decrypt) + .def_readwrite("translate_html", + &odr::FileTypeCapabilities::translate_html) + .def_readwrite("edit", &odr::FileTypeCapabilities::edit) + .def_readwrite("save", &odr::FileTypeCapabilities::save) + .def_readwrite("encrypt", &odr::FileTypeCapabilities::encrypt); + py::class_(m, "File") .def(py::init<>()) .def(py::init(), py::arg("path")) @@ -139,6 +152,7 @@ void odr_python::bind_file(py::module_ &m) { .def("encryption_state", &odr::DecodedFile::encryption_state) .def("decrypt", &odr::DecodedFile::decrypt, py::arg("password")) .def("is_decodable", &odr::DecodedFile::is_decodable) + .def("capabilities", &odr::DecodedFile::capabilities) .def("is_text_file", &odr::DecodedFile::is_text_file) .def("is_image_file", &odr::DecodedFile::is_image_file) .def("is_archive_file", &odr::DecodedFile::is_archive_file) diff --git a/python/tests/test_meta.py b/python/tests/test_meta.py index faae16ed..963f0edb 100644 --- a/python/tests/test_meta.py +++ b/python/tests/test_meta.py @@ -70,6 +70,64 @@ def test_mimetype_roundtrip(): ) +def test_all_file_types_and_aliases_round_trip(): + file_types = pyodr.all_file_types() + assert pyodr.FileType.opendocument_text in file_types + + extensions = set() + mimetypes = set() + for file_type in file_types: + for extension in pyodr.file_extensions_by_file_type(file_type): + assert extension not in extensions + extensions.add(extension) + assert pyodr.file_type_by_file_extension(extension) == file_type + for mimetype in pyodr.mimetypes_by_file_type(file_type): + assert mimetype not in mimetypes + mimetypes.add(mimetype) + assert pyodr.file_type_by_mimetype(mimetype) == file_type + + # the aliases the issue asked for + assert pyodr.file_type_by_file_extension("docm") == ( + pyodr.FileType.office_open_xml_document + ) + # its own type: an OOXML package we deliberately cannot open + assert pyodr.file_type_by_file_extension("xlsb") == ( + pyodr.FileType.excel_binary_workbook + ) + assert not pyodr.capabilities_by_file_type( + pyodr.FileType.excel_binary_workbook + ).open + assert pyodr.file_type_by_mimetype("application/x-vnd.oasis.opendocument.text") == ( + pyodr.FileType.opendocument_text + ) + + +def test_capabilities_by_file_type(): + odt = pyodr.capabilities_by_file_type(pyodr.FileType.opendocument_text) + assert odt.open + assert odt.translate_html + assert odt.edit + + # detected and named, but there is no decoder behind it + wpd = pyodr.capabilities_by_file_type(pyodr.FileType.word_perfect) + assert wpd.detect_by_content + assert not wpd.open + assert not wpd.translate_html + + # spreadsheet editing is force-disabled + assert not pyodr.capabilities_by_file_type( + pyodr.FileType.opendocument_spreadsheet + ).edit + + +def test_decoded_file_capabilities(odt_path): + capabilities = pyodr.open(str(odt_path)).capabilities() + assert capabilities.open + assert capabilities.translate_html + # not encrypted, so there is nothing to decrypt + assert not capabilities.decrypt + + def test_global_params(): assert isinstance(pyodr.GlobalParams.odr_core_data_path(), str) assert isinstance(pyodr.GlobalParams.libmagic_database_path(), str) diff --git a/src/odr/file.cpp b/src/odr/file.cpp index 57e467a2..96e78e7d 100644 --- a/src/odr/file.cpp +++ b/src/odr/file.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -117,6 +118,24 @@ bool DecodedFile::is_decodable() const noexcept { return m_impl->is_decodable(); } +FileTypeCapabilities DecodedFile::capabilities() const { + FileTypeCapabilities result = capabilities_by_file_type(file_type()); + + // we are holding the decoded file, so this one is settled + result.open = true; + // only an actually encrypted file can be decrypted + result.decrypt = + result.decrypt && encryption_state() == EncryptionState::encrypted; + // an encrypted file has to be decrypted before it renders + result.translate_html = + result.translate_html && encryption_state() != EncryptionState::encrypted; + + // `edit`/`save`/`encrypt` stay as declared — resolving them would mean + // decoding the document; ask `Document` for the precise answer + + return result; +} + bool DecodedFile::is_text_file() const { return std::dynamic_pointer_cast(m_impl) != nullptr; diff --git a/src/odr/file.hpp b/src/odr/file.hpp index 98465f62..9a983b30 100644 --- a/src/odr/file.hpp +++ b/src/odr/file.hpp @@ -45,6 +45,9 @@ enum class FileType { office_open_xml_presentation, office_open_xml_workbook, office_open_xml_encrypted, + // Classification only — `.xlsb` stores the workbook in binary parts, not in + // the spreadsheetml XML the OOXML engine reads, so there is no decoder. + excel_binary_workbook, // https://en.wikipedia.org/wiki/List_of_Microsoft_Office_filename_extensions legacy_word_document, @@ -104,6 +107,23 @@ enum class FileLocation { disk, }; +/// @brief What this library can do with a file format. +/// +/// Declared, format-level support — an *upper bound*. A concrete file may still +/// fail (corrupt, encrypted, an unsupported sub-variant); ask @ref DecodedFile +/// or @ref Document for the precise answer. The point of the static query is +/// the decisions a caller has to make *before* it holds a file, e.g. which +/// MIME types to advertise to the platform's file picker. +struct FileTypeCapabilities final { + bool detect_by_content{}; ///< recognised from its bytes alone + bool open{}; ///< a decoder exists; @ref odr::open can decode it + bool decrypt{}; ///< encrypted instances can be decrypted + bool translate_html{}; ///< @ref html::translate produces output + bool edit{}; ///< @ref Document::is_editable can be `true` + bool save{}; ///< @ref Document::save is supported + bool encrypt{}; ///< @ref Document::save with a password is supported +}; + /// @brief Preference for decoding files. struct DecodePreference final { std::optional as_file_type; @@ -208,6 +228,14 @@ class DecodedFile { [[nodiscard]] bool is_decodable() const noexcept; + /// @brief What can be done with this file. + /// + /// Refines @ref capabilities_by_file_type with what is known about this + /// file. `edit`/`save`/`encrypt` are passed through from the format-level + /// declaration — resolving them exactly would mean decoding the document; + /// ask @ref Document::is_editable / @ref Document::is_savable for that. + [[nodiscard]] FileTypeCapabilities capabilities() const; + [[nodiscard]] bool is_text_file() const; [[nodiscard]] bool is_image_file() const; [[nodiscard]] bool is_archive_file() const; diff --git a/src/odr/internal/common/image_file.cpp b/src/odr/internal/common/image_file.cpp index d2a65b5d..43bb9566 100644 --- a/src/odr/internal/common/image_file.cpp +++ b/src/odr/internal/common/image_file.cpp @@ -1,6 +1,7 @@ #include #include +#include namespace odr::internal { @@ -16,7 +17,12 @@ FileType ImageFile::file_type() const noexcept { return m_file_type; } FileMeta ImageFile::file_meta() const noexcept { return {}; } -std::string_view ImageFile::mimetype() const noexcept { return ""; } +std::string_view ImageFile::mimetype() const noexcept { + // not `mimetype_by_file_type` — that throws, and this is `noexcept` + const std::span mimetypes = + mimetypes_by_file_type(m_file_type); + return mimetypes.empty() ? "" : mimetypes.front(); +} bool ImageFile::is_decodable() const noexcept { return false; } diff --git a/src/odr/internal/file_type_table.cpp b/src/odr/internal/file_type_table.cpp new file mode 100644 index 00000000..549ea8d5 --- /dev/null +++ b/src/odr/internal/file_type_table.cpp @@ -0,0 +1,458 @@ +#include + +#include +#include + +namespace odr::internal { + +namespace { + +using file_type_table::Row; + +using namespace std::string_view_literals; + +// File extensions and MIME types accepted for each file type. The first entry +// of a list is the canonical one. Aliases have to stay unique across file +// types — the lookups take the first match and `odr_test` asserts that no +// extension or MIME type appears twice. + +constexpr std::array odt_extensions{"odt"sv, "fodt"sv, "ott"sv, "odm"sv, + "otm"sv}; +constexpr std::array odt_mimetypes{ + "application/vnd.oasis.opendocument.text"sv, + "application/x-vnd.oasis.opendocument.text"sv, + "application/vnd.oasis.opendocument.text-template"sv, + "application/vnd.oasis.opendocument.text-master"sv, + "application/vnd.oasis.opendocument.text-master-template"sv, + "application/vnd.oasis.opendocument.text-flat-xml"sv, +}; + +constexpr std::array odp_extensions{"odp"sv, "fodp"sv, "otp"sv}; +constexpr std::array odp_mimetypes{ + "application/vnd.oasis.opendocument.presentation"sv, + "application/x-vnd.oasis.opendocument.presentation"sv, + "application/vnd.oasis.opendocument.presentation-template"sv, + "application/vnd.oasis.opendocument.presentation-flat-xml"sv, +}; + +constexpr std::array ods_extensions{"ods"sv, "fods"sv, "ots"sv}; +constexpr std::array ods_mimetypes{ + "application/vnd.oasis.opendocument.spreadsheet"sv, + "application/x-vnd.oasis.opendocument.spreadsheet"sv, + "application/vnd.oasis.opendocument.spreadsheet-template"sv, + "application/vnd.oasis.opendocument.spreadsheet-flat-xml"sv, +}; + +constexpr std::array odg_extensions{"odg"sv, "fodg"sv, "otg"sv}; +constexpr std::array odg_mimetypes{ + "application/vnd.oasis.opendocument.graphics"sv, + "application/x-vnd.oasis.opendocument.graphics"sv, + "application/vnd.oasis.opendocument.graphics-template"sv, + "application/vnd.oasis.opendocument.graphics-flat-xml"sv, +}; + +constexpr std::array docx_extensions{"docx"sv, "docm"sv, "dotx"sv, "dotm"sv}; +constexpr std::array docx_mimetypes{ + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"sv, + "application/vnd.openxmlformats-officedocument.wordprocessingml.template"sv, + "application/vnd.ms-word.document.macroEnabled.12"sv, + "application/vnd.ms-word.template.macroEnabled.12"sv, +}; + +constexpr std::array pptx_extensions{"pptx"sv, "pptm"sv, "potx"sv, + "potm"sv, "ppsx"sv, "ppsm"sv}; +constexpr std::array pptx_mimetypes{ + "application/" + "vnd.openxmlformats-officedocument.presentationml.presentation"sv, + "application/vnd.openxmlformats-officedocument.presentationml.slideshow"sv, + "application/vnd.openxmlformats-officedocument.presentationml.template"sv, + "application/vnd.ms-powerpoint.presentation.macroEnabled.12"sv, + "application/vnd.ms-powerpoint.slideshow.macroEnabled.12"sv, + "application/vnd.ms-powerpoint.template.macroEnabled.12"sv, +}; + +constexpr std::array xlsx_extensions{"xlsx"sv, "xlsm"sv, "xltx"sv, "xltm"sv}; +constexpr std::array xlsx_mimetypes{ + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"sv, + "application/vnd.openxmlformats-officedocument.spreadsheetml.template"sv, + "application/vnd.ms-excel.sheet.macroEnabled.12"sv, + "application/vnd.ms-excel.template.macroEnabled.12"sv, +}; + +// `.xlsb` ships in an OOXML package but stores the workbook in binary parts +// instead of spreadsheetml, so it gets its own type rather than riding along +// with `xlsx` — the capability row is what tells a caller we cannot open it. +constexpr std::array xlsb_extensions{"xlsb"sv}; +constexpr std::array xlsb_mimetypes{ + "application/vnd.ms-excel.sheet.binary.macroEnabled.12"sv}; + +// An encrypted OOXML package is carried by an ordinary `docx`/`pptx`/`xlsx` +// file, so it has no extension of its own. +constexpr std::array ooxml_encrypted_mimetypes{"application/vnd.ms-office"sv}; + +constexpr std::array doc_extensions{"doc"sv, "dot"sv}; +constexpr std::array doc_mimetypes{"application/msword"sv, + "application/vnd.ms-word"sv}; + +constexpr std::array ppt_extensions{"ppt"sv, "pot"sv, "pps"sv}; +constexpr std::array ppt_mimetypes{"application/vnd.ms-powerpoint"sv, + "application/mspowerpoint"sv}; + +constexpr std::array xls_extensions{"xls"sv, "xlt"sv, "xlm"sv}; +constexpr std::array xls_mimetypes{"application/vnd.ms-excel"sv, + "application/msexcel"sv}; + +constexpr std::array wpd_extensions{"wpd"sv}; +constexpr std::array wpd_mimetypes{"application/vnd.wordperfect"sv, + "application/wordperfect"sv, + "application/x-wordperfect"sv}; + +constexpr std::array rtf_extensions{"rtf"sv}; +constexpr std::array rtf_mimetypes{"application/rtf"sv, "text/rtf"sv, + "application/x-rtf"sv}; + +constexpr std::array pdf_extensions{"pdf"sv}; +constexpr std::array pdf_mimetypes{"application/pdf"sv, "application/x-pdf"sv}; + +constexpr std::array txt_extensions{"txt"sv, "text"sv}; +constexpr std::array txt_mimetypes{"text/plain"sv}; + +constexpr std::array csv_extensions{"csv"sv}; +constexpr std::array csv_mimetypes{"text/csv"sv, "application/csv"sv, + "text/comma-separated-values"sv}; + +constexpr std::array json_extensions{"json"sv}; +constexpr std::array json_mimetypes{"application/json"sv, "text/json"sv}; + +constexpr std::array markdown_extensions{"md"sv, "markdown"sv}; +constexpr std::array markdown_mimetypes{"text/markdown"sv, "text/x-markdown"sv}; + +constexpr std::array zip_extensions{"zip"sv}; +constexpr std::array zip_mimetypes{ + "application/zip"sv, "application/x-zip-compressed"sv, "multipart/x-zip"sv}; + +constexpr std::array cfb_extensions{"cfb"sv}; +constexpr std::array cfb_mimetypes{"application/x-cfb"sv, + "application/x-ole-storage"sv}; + +constexpr std::array png_extensions{"png"sv}; +constexpr std::array png_mimetypes{"image/png"sv}; + +constexpr std::array gif_extensions{"gif"sv}; +constexpr std::array gif_mimetypes{"image/gif"sv}; + +constexpr std::array jpg_extensions{"jpg"sv, "jpeg"sv, "jpe"sv, + "jif"sv, "jfif"sv, "jfi"sv}; +constexpr std::array jpg_mimetypes{"image/jpeg"sv, "image/jpg"sv}; + +constexpr std::array bmp_extensions{"bmp"sv, "dib"sv}; +constexpr std::array bmp_mimetypes{"image/bmp"sv, "image/x-ms-bmp"sv, + "image/x-bmp"sv}; + +constexpr std::array svm_extensions{"svm"sv}; +constexpr std::array svm_mimetypes{"application/x-starview-metafile"sv}; + +constexpr std::array ttf_extensions{"ttf"sv, "ttc"sv}; +constexpr std::array ttf_mimetypes{"font/ttf"sv, "application/x-font-ttf"sv, + "application/x-font-truetype"sv, + "font/collection"sv}; + +constexpr std::array otf_extensions{"otf"sv}; +constexpr std::array otf_mimetypes{"font/otf"sv, "application/x-font-otf"sv, + "application/x-font-opentype"sv, + "application/vnd.ms-opentype"sv}; + +// The single source of truth behind every public format lookup. `odr_test` +// asserts that it covers each `FileType` exactly once and that the capability +// bits agree with what the engines actually do. +// +// `decrypt` on the OOXML document types refers to a password-protected +// package, which is detected as `office_open_xml_encrypted` and decrypts into +// the type named here. ODF files decrypt in place and keep their type. +constexpr std::array table{ + Row{FileType::unknown, + "unknown"sv, + {}, + {}, + FileCategory::unknown, + DocumentType::unknown, + {}}, + + Row{FileType::opendocument_text, + "odt"sv, + odt_extensions, + odt_mimetypes, + FileCategory::document, + DocumentType::text, + {.detect_by_content = true, + .open = true, + .decrypt = true, + .translate_html = true, + .edit = true, + .save = true}}, + Row{FileType::opendocument_presentation, + "odp"sv, + odp_extensions, + odp_mimetypes, + FileCategory::document, + DocumentType::presentation, + {.detect_by_content = true, + .open = true, + .decrypt = true, + .translate_html = true, + .edit = true, + .save = true}}, + // ODF spreadsheet editing is force-disabled, see `odf::Document`. + Row{FileType::opendocument_spreadsheet, + "ods"sv, + ods_extensions, + ods_mimetypes, + FileCategory::document, + DocumentType::spreadsheet, + {.detect_by_content = true, + .open = true, + .decrypt = true, + .translate_html = true, + .save = true}}, + Row{FileType::opendocument_graphics, + "odg"sv, + odg_extensions, + odg_mimetypes, + FileCategory::document, + DocumentType::drawing, + {.detect_by_content = true, + .open = true, + .decrypt = true, + .translate_html = true, + .edit = true, + .save = true}}, + + Row{FileType::office_open_xml_document, + "docx"sv, + docx_extensions, + docx_mimetypes, + FileCategory::document, + DocumentType::text, + {.detect_by_content = true, + .open = true, + .decrypt = true, + .translate_html = true, + .edit = true, + .save = true}}, + Row{FileType::office_open_xml_presentation, + "pptx"sv, + pptx_extensions, + pptx_mimetypes, + FileCategory::document, + DocumentType::presentation, + {.detect_by_content = true, + .open = true, + .decrypt = true, + .translate_html = true}}, + Row{FileType::office_open_xml_workbook, + "xlsx"sv, + xlsx_extensions, + xlsx_mimetypes, + FileCategory::document, + DocumentType::spreadsheet, + {.detect_by_content = true, + .open = true, + .decrypt = true, + .translate_html = true}}, + Row{FileType::office_open_xml_encrypted, + "ooxml_encrypted"sv, + {}, + ooxml_encrypted_mimetypes, + FileCategory::document, + DocumentType::unknown, + {.detect_by_content = true, .open = true, .decrypt = true}}, + // classified so a caller can name and route the file; no decoder + Row{FileType::excel_binary_workbook, + "xlsb"sv, + xlsb_extensions, + xlsb_mimetypes, + FileCategory::document, + DocumentType::spreadsheet, + {}}, + + Row{FileType::legacy_word_document, + "doc"sv, + doc_extensions, + doc_mimetypes, + FileCategory::document, + DocumentType::text, + {.detect_by_content = true, .open = true, .translate_html = true}}, + Row{FileType::legacy_powerpoint_presentation, + "ppt"sv, + ppt_extensions, + ppt_mimetypes, + FileCategory::document, + DocumentType::presentation, + {.detect_by_content = true, .open = true, .translate_html = true}}, + Row{FileType::legacy_excel_worksheets, + "xls"sv, + xls_extensions, + xls_mimetypes, + FileCategory::document, + DocumentType::spreadsheet, + {.detect_by_content = true, .open = true, .translate_html = true}}, + + // Recognised by magic so a caller can name the type, but there is no + // decoder behind either of these. + Row{FileType::word_perfect, + "wpd"sv, + wpd_extensions, + wpd_mimetypes, + FileCategory::document, + DocumentType::unknown, + {.detect_by_content = true}}, + Row{FileType::rich_text_format, + "rtf"sv, + rtf_extensions, + rtf_mimetypes, + FileCategory::document, + DocumentType::unknown, + {.detect_by_content = true}}, + + Row{FileType::portable_document_format, + "pdf"sv, + pdf_extensions, + pdf_mimetypes, + FileCategory::document, + DocumentType::text, + {.detect_by_content = true, + .open = true, + .decrypt = true, + .translate_html = true}}, + + Row{FileType::text_file, + "txt"sv, + txt_extensions, + txt_mimetypes, + FileCategory::text, + DocumentType::unknown, + {.detect_by_content = true, .open = true, .translate_html = true}}, + Row{FileType::comma_separated_values, + "csv"sv, + csv_extensions, + csv_mimetypes, + FileCategory::text, + DocumentType::unknown, + {.detect_by_content = true, .open = true, .translate_html = true}}, + Row{FileType::javascript_object_notation, + "json"sv, + json_extensions, + json_mimetypes, + FileCategory::text, + DocumentType::unknown, + {.detect_by_content = true, .open = true, .translate_html = true}}, + // Classified for callers that route files by type; there is no decoder. + Row{FileType::markdown, + "md"sv, + markdown_extensions, + markdown_mimetypes, + FileCategory::text, + DocumentType::unknown, + {}}, + + Row{FileType::zip, + "zip"sv, + zip_extensions, + zip_mimetypes, + FileCategory::archive, + DocumentType::unknown, + {.detect_by_content = true, .open = true, .translate_html = true}}, + Row{FileType::compound_file_binary_format, + "cfb"sv, + cfb_extensions, + cfb_mimetypes, + FileCategory::archive, + DocumentType::unknown, + {.detect_by_content = true, .open = true, .translate_html = true}}, + + Row{FileType::portable_network_graphics, + "png"sv, + png_extensions, + png_mimetypes, + FileCategory::image, + DocumentType::unknown, + {.detect_by_content = true, .open = true, .translate_html = true}}, + Row{FileType::graphics_interchange_format, + "gif"sv, + gif_extensions, + gif_mimetypes, + FileCategory::image, + DocumentType::unknown, + {.detect_by_content = true, .open = true, .translate_html = true}}, + Row{FileType::jpeg, + "jpg"sv, + jpg_extensions, + jpg_mimetypes, + FileCategory::image, + DocumentType::unknown, + {.detect_by_content = true, .open = true, .translate_html = true}}, + Row{FileType::bitmap_image_file, + "bmp"sv, + bmp_extensions, + bmp_mimetypes, + FileCategory::image, + DocumentType::unknown, + {.detect_by_content = true, .open = true, .translate_html = true}}, + Row{FileType::starview_metafile, + "svm"sv, + svm_extensions, + svm_mimetypes, + FileCategory::image, + DocumentType::unknown, + {.detect_by_content = true, .open = true, .translate_html = true}}, + + Row{FileType::truetype_font, + "ttf"sv, + ttf_extensions, + ttf_mimetypes, + FileCategory::font, + DocumentType::unknown, + {.detect_by_content = true, .open = true, .translate_html = true}}, + Row{FileType::opentype_font, + "otf"sv, + otf_extensions, + otf_mimetypes, + FileCategory::font, + DocumentType::unknown, + {.detect_by_content = true, .open = true, .translate_html = true}}, +}; + +/// Finds the row whose list, selected by @p list, contains @p needle. +template +const Row *find_by_alias(const std::string_view needle, + Projection list) noexcept { + const auto it = std::ranges::find_if(table, [&](const Row &row) { + return std::ranges::find(list(row), needle) != std::ranges::end(list(row)); + }); + return it == std::ranges::end(table) ? nullptr : &*it; +} + +} // namespace + +std::span file_type_table::rows() noexcept { + return table; +} + +const file_type_table::Row * +file_type_table::find(const FileType type) noexcept { + const auto it = std::ranges::find(table, type, &Row::type); + return it == std::ranges::end(table) ? nullptr : &*it; +} + +const file_type_table::Row * +file_type_table::find_by_extension(const std::string_view extension) noexcept { + return find_by_alias(extension, + [](const Row &row) { return row.extensions; }); +} + +const file_type_table::Row * +file_type_table::find_by_mimetype(const std::string_view mimetype) noexcept { + return find_by_alias(mimetype, [](const Row &row) { return row.mimetypes; }); +} + +} // namespace odr::internal diff --git a/src/odr/internal/file_type_table.hpp b/src/odr/internal/file_type_table.hpp new file mode 100644 index 00000000..9e641ab1 --- /dev/null +++ b/src/odr/internal/file_type_table.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include +#include + +namespace odr::internal::file_type_table { + +/// Everything the library statically knows about one file type. +struct Row final { + FileType type{FileType::unknown}; + std::string_view name; ///< as `file_type_to_string` + std::span extensions; ///< canonical one first + std::span mimetypes; ///< canonical one first + FileCategory category{FileCategory::unknown}; + DocumentType document_type{DocumentType::unknown}; + FileTypeCapabilities capabilities; +}; + +/// The whole table, one row per @ref FileType, in declaration order. +[[nodiscard]] std::span rows() noexcept; + +/// The row for @p type, or `nullptr` if there is none. +[[nodiscard]] const Row *find(FileType type) noexcept; + +/// The row listing @p extension among its extensions, or `nullptr`. +[[nodiscard]] const Row *find_by_extension(std::string_view extension) noexcept; + +/// The row listing @p mimetype among its MIME types, or `nullptr`. +[[nodiscard]] const Row *find_by_mimetype(std::string_view mimetype) noexcept; + +} // namespace odr::internal::file_type_table diff --git a/src/odr/odr.cpp b/src/odr/odr.cpp index 6a4bcb18..dbb250af 100644 --- a/src/odr/odr.cpp +++ b/src/odr/odr.cpp @@ -3,9 +3,20 @@ #include #include +#include #include #include +#include +#include +#include +#include + +namespace { +using odr::internal::file_type_table::Row; +namespace table = odr::internal::file_type_table; +} // namespace + std::string odr::version() { return internal::project_info::version(); } std::string odr::commit_hash() { return internal::git_info::commit_hash(); } @@ -20,211 +31,49 @@ std::string odr::identify() noexcept { (is_dirty() ? " [dirty]" : "") + (is_debug() ? " [debug]" : ""); } +std::vector odr::all_file_types() { + std::vector result; + result.reserve(table::rows().size()); + std::ranges::transform(table::rows(), std::back_inserter(result), &Row::type); + return result; +} + odr::FileType odr::file_type_by_file_extension(const std::string &extension) noexcept { - if (extension == "zip") { - return FileType::zip; - } - if (extension == "cfb") { - return FileType::compound_file_binary_format; - } - if (extension == "odt" || extension == "fodt" || extension == "ott" || - extension == "odm") { - return FileType::opendocument_text; - } - if (extension == "odp" || extension == "fodp" || extension == "otp") { - return FileType::opendocument_presentation; - } - if (extension == "ods" || extension == "fods" || extension == "ots") { - return FileType::opendocument_spreadsheet; - } - if (extension == "odg" || extension == "fodg" || extension == "otg") { - return FileType::opendocument_graphics; - } - if (extension == "docx") { - return FileType::office_open_xml_document; - } - if (extension == "pptx") { - return FileType::office_open_xml_presentation; - } - if (extension == "xlsx") { - return FileType::office_open_xml_workbook; - } - if (extension == "doc") { - return FileType::legacy_word_document; - } - if (extension == "ppt") { - return FileType::legacy_powerpoint_presentation; - } - if (extension == "xls") { - return FileType::legacy_excel_worksheets; - } - if (extension == "wpd") { - return FileType::word_perfect; - } - if (extension == "rtf") { - return FileType::rich_text_format; - } - if (extension == "pdf") { - return FileType::portable_document_format; - } - if (extension == "png") { - return FileType::portable_network_graphics; - } - if (extension == "gif") { - return FileType::graphics_interchange_format; - } - if (extension == "jpg" || extension == "jpeg" || extension == "jpe" || - extension == "jif" || extension == "jfif" || extension == "jfi") { - return FileType::jpeg; - } - if (extension == "bmp" || extension == "dib") { - return FileType::bitmap_image_file; - } - if (extension == "svm") { - return FileType::starview_metafile; - } - if (extension == "txt") { - return FileType::text_file; - } - if (extension == "csv") { - return FileType::comma_separated_values; - } - if (extension == "json") { - return FileType::javascript_object_notation; - } - if (extension == "ttf") { - return FileType::truetype_font; - } - if (extension == "otf") { - return FileType::opentype_font; + const Row *row = table::find_by_extension(extension); + return row == nullptr ? FileType::unknown : row->type; +} + +std::span +odr::file_extensions_by_file_type(const FileType type) noexcept { + const Row *row = table::find(type); + return row == nullptr ? std::span{} : row->extensions; +} + +std::string_view odr::file_extension_by_file_type(const FileType type) { + const std::span extensions = + file_extensions_by_file_type(type); + if (extensions.empty()) { + throw UnsupportedFileType(type); } - return FileType::unknown; + return extensions.front(); } odr::FileCategory odr::file_category_by_file_type(const FileType type) noexcept { - switch (type) { - case FileType::zip: - case FileType::compound_file_binary_format: - return FileCategory::archive; - case FileType::opendocument_text: - case FileType::opendocument_presentation: - case FileType::opendocument_spreadsheet: - case FileType::opendocument_graphics: - case FileType::office_open_xml_document: - case FileType::office_open_xml_presentation: - case FileType::office_open_xml_workbook: - case FileType::legacy_word_document: - case FileType::legacy_powerpoint_presentation: - case FileType::legacy_excel_worksheets: - case FileType::word_perfect: - case FileType::rich_text_format: - return FileCategory::document; - case FileType::portable_network_graphics: - case FileType::graphics_interchange_format: - case FileType::jpeg: - case FileType::bitmap_image_file: - case FileType::starview_metafile: - return FileCategory::image; - case FileType::text_file: - case FileType::comma_separated_values: - case FileType::javascript_object_notation: - case FileType::markdown: - return FileCategory::text; - case FileType::truetype_font: - case FileType::opentype_font: - return FileCategory::font; - default: - return FileCategory::unknown; - } + const Row *row = table::find(type); + return row == nullptr ? FileCategory::unknown : row->category; } odr::DocumentType odr::document_type_by_file_type(const FileType type) noexcept { - switch (type) { - case FileType::opendocument_text: - return DocumentType::text; - case FileType::opendocument_presentation: - return DocumentType::presentation; - case FileType::opendocument_spreadsheet: - return DocumentType::spreadsheet; - case FileType::opendocument_graphics: - return DocumentType::drawing; - case FileType::office_open_xml_document: - return DocumentType::text; - case FileType::office_open_xml_presentation: - return DocumentType::presentation; - case FileType::office_open_xml_workbook: - return DocumentType::spreadsheet; - case FileType::legacy_word_document: - return DocumentType::text; - case FileType::legacy_powerpoint_presentation: - return DocumentType::presentation; - case FileType::legacy_excel_worksheets: - return DocumentType::spreadsheet; - default: - return DocumentType::unknown; - } + const Row *row = table::find(type); + return row == nullptr ? DocumentType::unknown : row->document_type; } std::string odr::file_type_to_string(const FileType type) { - switch (type) { - case FileType::unknown: - return "unknown"; - case FileType::zip: - return "zip"; - case FileType::compound_file_binary_format: - return "cfb"; - case FileType::opendocument_text: - return "odt"; - case FileType::opendocument_presentation: - return "odp"; - case FileType::opendocument_spreadsheet: - return "ods"; - case FileType::opendocument_graphics: - return "odg"; - case FileType::office_open_xml_document: - return "docx"; - case FileType::office_open_xml_presentation: - return "pptx"; - case FileType::office_open_xml_workbook: - return "xlsx"; - case FileType::legacy_word_document: - return "doc"; - case FileType::legacy_powerpoint_presentation: - return "ppt"; - case FileType::legacy_excel_worksheets: - return "xls"; - case FileType::word_perfect: - return "wpd"; - case FileType::rich_text_format: - return "rtf"; - case FileType::portable_document_format: - return "pdf"; - case FileType::portable_network_graphics: - return "png"; - case FileType::graphics_interchange_format: - return "gif"; - case FileType::jpeg: - return "jpg"; - case FileType::bitmap_image_file: - return "bmp"; - case FileType::starview_metafile: - return "svm"; - case FileType::truetype_font: - return "ttf"; - case FileType::opentype_font: - return "otf"; - case FileType::text_file: - return "txt"; - case FileType::comma_separated_values: - return "csv"; - case FileType::javascript_object_notation: - return "json"; - default: - return "unnamed"; - } + const Row *row = table::find(type); + return row == nullptr ? "unnamed" : std::string(row->name); } std::string odr::file_category_to_string(const FileCategory type) { @@ -265,154 +114,29 @@ std::string odr::document_type_to_string(const DocumentType type) { odr::FileType odr::file_type_by_mimetype(const std::string_view mimetype) noexcept { - if (mimetype == "application/vnd.oasis.opendocument.text") { - return FileType::opendocument_text; - } - if (mimetype == "application/vnd.oasis.opendocument.presentation") { - return FileType::opendocument_presentation; - } - if (mimetype == "application/vnd.oasis.opendocument.spreadsheet") { - return FileType::opendocument_spreadsheet; - } - if (mimetype == "application/vnd.oasis.opendocument.graphics") { - return FileType::opendocument_graphics; - } - if (mimetype == - "application/" - "vnd.openxmlformats-officedocument.wordprocessingml.document") { - return FileType::office_open_xml_document; - } - if (mimetype == - "application/" - "vnd.openxmlformats-officedocument.presentationml.presentation") { - return FileType::office_open_xml_presentation; - } - if (mimetype == - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") { - return FileType::office_open_xml_workbook; - } - if (mimetype == "application/msword") { - return FileType::legacy_word_document; - } - if (mimetype == "application/vnd.ms-powerpoint") { - return FileType::legacy_powerpoint_presentation; - } - if (mimetype == "application/vnd.ms-excel") { - return FileType::legacy_excel_worksheets; - } - if (mimetype == "application/zip" || - mimetype == "application/x-zip-compressed") { - return FileType::zip; - } - if (mimetype == "application/pdf") { - return FileType::portable_document_format; - } - if (mimetype == "text/plain") { - return FileType::text_file; - } - if (mimetype == "text/csv") { - return FileType::comma_separated_values; - } - if (mimetype == "application/json") { - return FileType::javascript_object_notation; - } - if (mimetype == "text/markdown") { - return FileType::markdown; - } - if (mimetype == "image/png") { - return FileType::portable_network_graphics; - } - if (mimetype == "image/gif") { - return FileType::graphics_interchange_format; - } - if (mimetype == "image/jpeg") { - return FileType::jpeg; - } - if (mimetype == "image/bmp") { - return FileType::bitmap_image_file; - } - if (mimetype == "font/ttf" || mimetype == "application/x-font-ttf" || - mimetype == "application/x-font-truetype") { - return FileType::truetype_font; - } - if (mimetype == "font/otf" || mimetype == "application/x-font-otf" || - mimetype == "application/x-font-opentype" || - mimetype == "application/vnd.ms-opentype") { - return FileType::opentype_font; - } - return FileType::unknown; + const Row *row = table::find_by_mimetype(mimetype); + return row == nullptr ? FileType::unknown : row->type; } std::string_view odr::mimetype_by_file_type(const FileType type) { - if (type == FileType::opendocument_text) { - return "application/vnd.oasis.opendocument.text"; - } - if (type == FileType::opendocument_presentation) { - return "application/vnd.oasis.opendocument.presentation"; - } - if (type == FileType::opendocument_spreadsheet) { - return "application/vnd.oasis.opendocument.spreadsheet"; - } - if (type == FileType::opendocument_graphics) { - return "application/vnd.oasis.opendocument.graphics"; + const std::span mimetypes = + mimetypes_by_file_type(type); + if (mimetypes.empty()) { + throw UnsupportedFileType(type); } - if (type == FileType::office_open_xml_document) { - return "application/" - "vnd.openxmlformats-officedocument.wordprocessingml.document"; - } - if (type == FileType::office_open_xml_presentation) { - return "application/" - "vnd.openxmlformats-officedocument.presentationml.presentation"; - } - if (type == FileType::office_open_xml_workbook) { - return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; - } - if (type == FileType::legacy_word_document) { - return "application/msword"; - } - if (type == FileType::legacy_powerpoint_presentation) { - return "application/vnd.ms-powerpoint"; - } - if (type == FileType::legacy_excel_worksheets) { - return "application/vnd.ms-excel"; - } - if (type == FileType::zip) { - return "application/zip"; - } - if (type == FileType::portable_document_format) { - return "application/pdf"; - } - if (type == FileType::text_file) { - return "text/plain"; - } - if (type == FileType::comma_separated_values) { - return "text/csv"; - } - if (type == FileType::javascript_object_notation) { - return "application/json"; - } - if (type == FileType::markdown) { - return "text/markdown"; - } - if (type == FileType::portable_network_graphics) { - return "image/png"; - } - if (type == FileType::graphics_interchange_format) { - return "image/gif"; - } - if (type == FileType::jpeg) { - return "image/jpeg"; - } - if (type == FileType::bitmap_image_file) { - return "image/bmp"; - } - if (type == FileType::truetype_font) { - return "font/ttf"; - } - if (type == FileType::opentype_font) { - return "font/otf"; - } - throw UnsupportedFileType(type); + return mimetypes.front(); +} + +std::span +odr::mimetypes_by_file_type(const FileType type) noexcept { + const Row *row = table::find(type); + return row == nullptr ? std::span{} : row->mimetypes; +} + +odr::FileTypeCapabilities +odr::capabilities_by_file_type(const FileType type) noexcept { + const Row *row = table::find(type); + return row == nullptr ? FileTypeCapabilities{} : row->capabilities; } std::vector odr::list_file_types(const std::string &path, diff --git a/src/odr/odr.hpp b/src/odr/odr.hpp index acd97013..67b4b129 100644 --- a/src/odr/odr.hpp +++ b/src/odr/odr.hpp @@ -1,16 +1,14 @@ #pragma once +#include #include +#include #include +#include #include namespace odr { -enum class FileType; -enum class FileCategory; -struct DecodePreference; -class DecodedFile; -enum class DocumentType; /// @brief Get the version of the library. /// @return The version of the library. @@ -28,11 +26,30 @@ enum class DocumentType; /// @return The identification string of the library. [[nodiscard]] std::string identify() noexcept; +/// @brief Get every file type this library knows about. +/// @return All file types, in declaration order, including +/// @ref FileType::unknown. +[[nodiscard]] std::vector all_file_types(); + /// @brief Get the file type by the file extension. /// @param extension The file extension. /// @return The file type. [[nodiscard]] FileType file_type_by_file_extension(const std::string &extension) noexcept; +/// @brief Get every file extension accepted for the file type. +/// +/// The first entry is the canonical one. Some file types have no extension of +/// their own (e.g. @ref FileType::office_open_xml_encrypted, which is carried +/// by an ordinary `docx`/`pptx`/`xlsx` file), in which case the result is +/// empty. +/// @param type The file type. +/// @return The file extensions, without a leading dot. +[[nodiscard]] std::span +file_extensions_by_file_type(FileType type) noexcept; +/// @brief Get the canonical file extension by the file type. +/// @param type The file type. +/// @return The file extension, without a leading dot. +[[nodiscard]] std::string_view file_extension_by_file_type(FileType type); /// @brief Get the file category by the file type. /// @param type The file type. /// @return The file category. @@ -58,10 +75,25 @@ file_type_by_file_extension(const std::string &extension) noexcept; /// @return The file type. [[nodiscard]] FileType file_type_by_mimetype(std::string_view mimetype) noexcept; -/// @brief Get MIME type by the file type. +/// @brief Get the canonical MIME type by the file type. /// @param type The file type. /// @return The MIME type. [[nodiscard]] std::string_view mimetype_by_file_type(FileType type); +/// @brief Get every MIME type accepted for the file type. +/// +/// The first entry is the canonical one. +/// @param type The file type. +/// @return The MIME types. +[[nodiscard]] std::span +mimetypes_by_file_type(FileType type) noexcept; + +/// @brief Get what this library can do with the file type. +/// +/// Format-level support, i.e. an upper bound — see @ref FileTypeCapabilities. +/// @param type The file type. +/// @return The capabilities. +[[nodiscard]] FileTypeCapabilities +capabilities_by_file_type(FileType type) noexcept; /// @brief Determine the file types by the file path. /// @param path The file path. diff --git a/test/data/reference-output/odr-private b/test/data/reference-output/odr-private index 7ed25fa7..ff796105 160000 --- a/test/data/reference-output/odr-private +++ b/test/data/reference-output/odr-private @@ -1 +1 @@ -Subproject commit 7ed25fa753206f782af4b6495a2a4c4ff03478eb +Subproject commit ff7961051a535d518bb23cb62cc8a77d89c90389 diff --git a/test/data/reference-output/odr-public b/test/data/reference-output/odr-public index 6581dcd1..e8380975 160000 --- a/test/data/reference-output/odr-public +++ b/test/data/reference-output/odr-public @@ -1 +1 @@ -Subproject commit 6581dcd1ecf121e030b4ce0221e8f0a119d1f604 +Subproject commit e838097581d832a8071bafa20981cc7f8632b45e diff --git a/test/src/html_output_test.cpp b/test/src/html_output_test.cpp index bb5c56ba..3958eca4 100644 --- a/test/src/html_output_test.cpp +++ b/test/src/html_output_test.cpp @@ -37,6 +37,15 @@ FileType expected_file_type_post_decryption(const TestFile &test_file) { return test_file.type; } +/// PDF document meta is all-or-nothing: a structure we cannot parse leaves +/// `document_type` unset rather than half-filled (see `pdf/AGENTS.md`), so the +/// per-file answer may fall short of what the format declares. +bool document_type_is_comparable(const TestFile &test_file, + const FileMeta &file_meta) { + return test_file.type != FileType::portable_document_format || + file_meta.document_type != DocumentType::unknown; +} + struct TestParams { TestFile test_file; std::string path; @@ -61,10 +70,11 @@ TEST_P(HtmlOutputTests, html_meta) { ODR_INFO(logger, "Testing file: " << test_file.short_path << " output to: " << output_path); - // these files cannot be opened + // formats we cannot decode at all (wpd, rtf, md, …) plus the odd file we + // classify but do not handle if (util::string::ends_with(test_file.short_path, ".sxw") || - test_file.type == FileType::word_perfect || - test_file.type == FileType::starview_metafile) { + test_file.type == FileType::starview_metafile || + !capabilities_by_file_type(test_file.type).open) { GTEST_SKIP(); } @@ -75,7 +85,8 @@ TEST_P(HtmlOutputTests, html_meta) { FileMeta file_meta = file.file_meta(); EXPECT_EQ(file_meta.type, expected_file_type_pre_decryption(test_file)); - if (file_category == FileCategory::document) { + if (file_category == FileCategory::document && + document_type_is_comparable(test_file, file_meta)) { EXPECT_EQ(file_meta.document_type, document_type_by_file_type( expected_file_type_pre_decryption(test_file))); @@ -121,7 +132,8 @@ TEST_P(HtmlOutputTests, html_meta) { file_meta = file.file_meta(); EXPECT_EQ(file_meta.type, expected_file_type_post_decryption(test_file)); - if (file_category == FileCategory::document) { + if (file_category == FileCategory::document && + document_type_is_comparable(test_file, file_meta)) { EXPECT_EQ(file_meta.document_type, document_type_by_file_type( expected_file_type_post_decryption(test_file))); diff --git a/test/src/odr_test.cpp b/test/src/odr_test.cpp index 507dc4e1..ede7c43b 100644 --- a/test/src/odr_test.cpp +++ b/test/src/odr_test.cpp @@ -1,16 +1,38 @@ +#include #include +#include #include -#include - #include +#include +#include +#include +#include +#include +#include + #include using namespace odr; using namespace odr::internal; using namespace odr::test; +namespace { + +/// Every `FileType`, derived from the enum itself rather than from the table — +/// otherwise a type missing from the table would be missing from the test too. +std::vector every_file_type() { + std::vector result; + for (auto i = static_cast(FileType::unknown); + i <= static_cast(FileType::opentype_font); ++i) { + result.push_back(static_cast(i)); + } + return result; +} + +} // namespace + TEST(odr, version) { EXPECT_TRUE(odr::version().empty()); } TEST(odr, commit) { EXPECT_FALSE(odr::commit_hash().empty()); } @@ -37,10 +59,169 @@ TEST(odr, types_wpd) { EXPECT_EQ(types.size(), 1); EXPECT_EQ(types[0], FileType::word_perfect); - if (project_info::has_libmagic()) { - const auto mime = mimetype(path, logger); - EXPECT_EQ(mime, "application/vnd.wordperfect"); - } else { - EXPECT_THROW(std::ignore = mimetype(path, logger), UnsupportedFileType); + // wpd has a MIME type in the table now, so both paths agree + EXPECT_EQ(mimetype(path, logger), "application/vnd.wordperfect"); +} + +TEST(FileTypeTable, covers_every_file_type_exactly_once) { + const std::vector expected = every_file_type(); + const std::vector actual = all_file_types(); + + EXPECT_EQ(actual, expected); + + for (const FileType type : expected) { + EXPECT_NE(file_type_to_string(type), "unnamed") + << "file type " << static_cast(type) << " has no table row"; + } +} + +TEST(FileTypeTable, aliases_are_unique_across_file_types) { + std::set extensions; + std::set mimetypes; + + for (const FileType type : every_file_type()) { + for (const std::string_view extension : + file_extensions_by_file_type(type)) { + EXPECT_TRUE(extensions.insert(extension).second) + << "extension " << extension << " is claimed by two file types"; + } + for (const std::string_view mimetype : mimetypes_by_file_type(type)) { + EXPECT_TRUE(mimetypes.insert(mimetype).second) + << "mimetype " << mimetype << " is claimed by two file types"; + } + } +} + +TEST(FileTypeTable, every_alias_maps_back_to_its_file_type) { + for (const FileType type : every_file_type()) { + for (const std::string_view extension : + file_extensions_by_file_type(type)) { + EXPECT_EQ(file_type_by_file_extension(std::string(extension)), type) + << "extension " << extension; + } + for (const std::string_view mimetype : mimetypes_by_file_type(type)) { + EXPECT_EQ(file_type_by_mimetype(mimetype), type) + << "mimetype " << mimetype; + } + } +} + +TEST(FileTypeTable, canonical_alias_is_the_first_one) { + for (const FileType type : every_file_type()) { + const auto extensions = file_extensions_by_file_type(type); + if (extensions.empty()) { + EXPECT_THROW(std::ignore = file_extension_by_file_type(type), + UnsupportedFileType); + } else { + EXPECT_EQ(file_extension_by_file_type(type), extensions.front()); + } + + const auto mimetypes = mimetypes_by_file_type(type); + if (mimetypes.empty()) { + EXPECT_THROW(std::ignore = mimetype_by_file_type(type), + UnsupportedFileType); + } else { + EXPECT_EQ(mimetype_by_file_type(type), mimetypes.front()); + } + } +} + +/// `FileType::unknown` is the only type we refuse to name a MIME type for. +TEST(FileTypeTable, only_unknown_has_no_mimetype) { + for (const FileType type : every_file_type()) { + if (type == FileType::unknown) { + EXPECT_THROW(std::ignore = mimetype_by_file_type(type), + UnsupportedFileType); + } else { + EXPECT_FALSE(mimetype_by_file_type(type).empty()) + << file_type_to_string(type); + } + } +} + +TEST(FileTypeTable, capabilities_build_on_each_other) { + for (const FileType type : every_file_type()) { + const FileTypeCapabilities capabilities = capabilities_by_file_type(type); + + if (!capabilities.open) { + EXPECT_FALSE(capabilities.translate_html) << file_type_to_string(type); + EXPECT_FALSE(capabilities.edit) << file_type_to_string(type); + EXPECT_FALSE(capabilities.save) << file_type_to_string(type); + } + if (!capabilities.save) { + EXPECT_FALSE(capabilities.encrypt) << file_type_to_string(type); + } + if (capabilities.edit) { + EXPECT_TRUE(capabilities.save) << file_type_to_string(type); + } + } +} + +/// The anti-drift check behind `capabilities_by_file_type`: what the engines +/// actually do must never exceed what the table declares. Capabilities are +/// per-format constants today, so a couple of files per type is enough. +TEST(FileTypeCapabilities, declaration_matches_the_engines) { + static constexpr std::size_t files_per_file_type = 2; + + const auto &logger = Logger::null(); + + for (const FileType type : every_file_type()) { + const FileTypeCapabilities declared = capabilities_by_file_type(type); + const std::vector test_files = TestData::test_files(type); + + const std::size_t count = std::min(files_per_file_type, test_files.size()); + for (std::size_t i = 0; i < count; ++i) { + const TestFile &test_file = test_files[i]; + + if (!declared.open) { + EXPECT_ANY_THROW(std::ignore = + open(test_file.absolute_path, type, logger)) + << test_file.short_path; + continue; + } + + std::optional file; + try { + file = open(test_file.absolute_path, type, logger); + } catch (...) { + // declared support is an upper bound — a single file may still fail + continue; + } + + // whatever detection sees, the table has to admit to + const std::vector detected = + list_file_types(test_file.absolute_path, logger); + if (std::ranges::find(detected, type) != std::ranges::end(detected)) { + EXPECT_TRUE(declared.detect_by_content) << test_file.short_path; + } + + // an encrypted OOXML package decodes as its own file type + const FileTypeCapabilities declared_actual = + capabilities_by_file_type(file->file_type()); + const FileTypeCapabilities actual = file->capabilities(); + EXPECT_TRUE(declared_actual.open) << test_file.short_path; + EXPECT_TRUE(!actual.decrypt || declared_actual.decrypt) + << test_file.short_path; + EXPECT_TRUE(!actual.translate_html || declared_actual.translate_html) + << test_file.short_path; + + if (!file->is_document_file() || file->password_encrypted()) { + continue; + } + + std::optional document; + try { + document = file->as_document_file().document(); + } catch (...) { + continue; + } + + EXPECT_EQ(document->is_editable(), declared_actual.edit) + << test_file.short_path; + EXPECT_EQ(document->is_savable(false), declared_actual.save) + << test_file.short_path; + EXPECT_EQ(document->is_savable(true), declared_actual.encrypt) + << test_file.short_path; + } } }