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;
+ }
}
}