diff --git a/CMakeLists.txt b/CMakeLists.txt index bf9161b6..e7bb32f4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -226,6 +226,8 @@ set(ODR_SOURCE_FILES "src/odr/internal/font/sfnt_transform.cpp" "src/odr/internal/font/font_file.cpp" + "src/odr/internal/svg/svg_util.cpp" + "src/odr/internal/svm/svm_file.cpp" "src/odr/internal/svm/svm_format.cpp" "src/odr/internal/svm/svm_to_svg.cpp" diff --git a/apple/include/OdrCoreObjC/ODRFile.h b/apple/include/OdrCoreObjC/ODRFile.h index e0a8430e..1d70afd6 100644 --- a/apple/include/OdrCoreObjC/ODRFile.h +++ b/apple/include/OdrCoreObjC/ODRFile.h @@ -73,6 +73,16 @@ typedef NS_ENUM(NSInteger, ODRFileType) { ODRFileTypeThirdGenerationPartnershipVideo, ODRFileTypeMatroskaVideo, ODRFileTypeAudioVideoInterleave, + + ODRFileTypeScalableVectorGraphics, + ODRFileTypeWindowsIcon, + ODRFileTypeJpegXl, + ODRFileTypeJpeg2000, + ODRFileTypePhotoshopDocument, + ODRFileTypeWindowsMetafile, + ODRFileTypeEnhancedMetafile, + + ODRFileTypeXml, } NS_SWIFT_NAME(FileType); typedef NS_ENUM(NSInteger, ODRFileCategory) { diff --git a/apple/src/ODRFile.mm b/apple/src/ODRFile.mm index abbddfc9..35fd0dd2 100644 --- a/apple/src/ODRFile.mm +++ b/apple/src/ODRFile.mm @@ -81,6 +81,15 @@ ODR_SAME_ENUM(ODRFileTypeMatroskaVideo, odr::FileType::matroska_video); ODR_SAME_ENUM(ODRFileTypeAudioVideoInterleave, odr::FileType::audio_video_interleave); +ODR_SAME_ENUM(ODRFileTypeScalableVectorGraphics, + odr::FileType::scalable_vector_graphics); +ODR_SAME_ENUM(ODRFileTypeWindowsIcon, odr::FileType::windows_icon); +ODR_SAME_ENUM(ODRFileTypeJpegXl, odr::FileType::jpeg_xl); +ODR_SAME_ENUM(ODRFileTypeJpeg2000, odr::FileType::jpeg_2000); +ODR_SAME_ENUM(ODRFileTypePhotoshopDocument, odr::FileType::photoshop_document); +ODR_SAME_ENUM(ODRFileTypeWindowsMetafile, odr::FileType::windows_metafile); +ODR_SAME_ENUM(ODRFileTypeEnhancedMetafile, odr::FileType::enhanced_metafile); +ODR_SAME_ENUM(ODRFileTypeXml, odr::FileType::xml); ODR_SAME_ENUM(ODRFileCategoryUnknown, odr::FileCategory::unknown); ODR_SAME_ENUM(ODRFileCategoryText, odr::FileCategory::text); diff --git a/jni/java/app/opendocument/core/FileType.java b/jni/java/app/opendocument/core/FileType.java index 4e1c77af..80d13963 100644 --- a/jni/java/app/opendocument/core/FileType.java +++ b/jni/java/app/opendocument/core/FileType.java @@ -15,7 +15,8 @@ public enum FileType { HIGH_EFFICIENCY_IMAGE_FORMAT, AV1_IMAGE_FILE_FORMAT, MPEG_AUDIO, MPEG4_AUDIO, OGG_AUDIO, WAVEFORM_AUDIO, FREE_LOSSLESS_AUDIO_CODEC, MPEG4_VIDEO, QUICKTIME_VIDEO, THIRD_GENERATION_PARTNERSHIP_VIDEO, MATROSKA_VIDEO, - AUDIO_VIDEO_INTERLEAVE; + AUDIO_VIDEO_INTERLEAVE, SCALABLE_VECTOR_GRAPHICS, WINDOWS_ICON, JPEG_XL, + JPEG_2000, PHOTOSHOP_DOCUMENT, WINDOWS_METAFILE, ENHANCED_METAFILE, XML; static FileType fromNative(int code) { return code < 0 ? null : values()[code]; diff --git a/python/src/bind_file.cpp b/python/src/bind_file.cpp index 826da09c..0fb1230c 100644 --- a/python/src/bind_file.cpp +++ b/python/src/bind_file.cpp @@ -73,7 +73,16 @@ void odr_python::bind_file(py::module_ &m) { .value("third_generation_partnership_video", odr::FileType::third_generation_partnership_video) .value("matroska_video", odr::FileType::matroska_video) - .value("audio_video_interleave", odr::FileType::audio_video_interleave); + .value("audio_video_interleave", odr::FileType::audio_video_interleave) + .value("scalable_vector_graphics", + odr::FileType::scalable_vector_graphics) + .value("windows_icon", odr::FileType::windows_icon) + .value("jpeg_xl", odr::FileType::jpeg_xl) + .value("jpeg_2000", odr::FileType::jpeg_2000) + .value("photoshop_document", odr::FileType::photoshop_document) + .value("windows_metafile", odr::FileType::windows_metafile) + .value("enhanced_metafile", odr::FileType::enhanced_metafile) + .value("xml", odr::FileType::xml); py::enum_(m, "FileCategory") .value("unknown", odr::FileCategory::unknown) diff --git a/src/odr/file.hpp b/src/odr/file.hpp index 5ad85d6d..295eebc8 100644 --- a/src/odr/file.hpp +++ b/src/odr/file.hpp @@ -126,6 +126,29 @@ enum class FileType { matroska_video, // https://en.wikipedia.org/wiki/Audio_Video_Interleave audio_video_interleave, + + // More images that arrive alongside documents, named the same way and for + // the same reason as the block above - nothing here is decoded either. + // https://en.wikipedia.org/wiki/SVG + scalable_vector_graphics, + // https://en.wikipedia.org/wiki/ICO_(file_format) + windows_icon, + // https://en.wikipedia.org/wiki/JPEG_XL + jpeg_xl, + // https://en.wikipedia.org/wiki/JPEG_2000 + jpeg_2000, + // https://en.wikipedia.org/wiki/Adobe_Photoshop#File_format + photoshop_document, + // https://en.wikipedia.org/wiki/Windows_Metafile + windows_metafile, + // https://en.wikipedia.org/wiki/Windows_Metafile#Enhanced_Metafile + enhanced_metafile, + + // Classification only for now - detection reports it under the formats built + // on it, e.g. an svg comes back as `[text_file, xml, scalable_vector_ + // graphics]`, but there is no decoder of its own behind it yet. + // https://en.wikipedia.org/wiki/XML + xml, }; /// @brief Collection of file categories. diff --git a/src/odr/internal/common/image_file.cpp b/src/odr/internal/common/image_file.cpp index 43bb9566..adbfd0e8 100644 --- a/src/odr/internal/common/image_file.cpp +++ b/src/odr/internal/common/image_file.cpp @@ -15,7 +15,12 @@ std::shared_ptr ImageFile::file() const noexcept { FileType ImageFile::file_type() const noexcept { return m_file_type; } -FileMeta ImageFile::file_meta() const noexcept { return {}; } +FileMeta ImageFile::file_meta() const noexcept { + FileMeta result; + result.type = file_type(); + result.mimetype = mimetype(); + return result; +} std::string_view ImageFile::mimetype() const noexcept { // not `mimetype_by_file_type` — that throws, and this is `noexcept` diff --git a/src/odr/internal/file_type_table.cpp b/src/odr/internal/file_type_table.cpp index c0e3cae9..483b7933 100644 --- a/src/odr/internal/file_type_table.cpp +++ b/src/odr/internal/file_type_table.cpp @@ -176,6 +176,37 @@ constexpr std::array heif_mimetypes{"image/heic"sv, "image/heif"sv, constexpr std::array avif_extensions{"avif"sv, "avifs"sv}; constexpr std::array avif_mimetypes{"image/avif"sv, "image/avif-sequence"sv}; +constexpr std::array xml_extensions{"xml"sv}; +constexpr std::array xml_mimetypes{"application/xml"sv, "text/xml"sv}; + +constexpr std::array svg_extensions{"svg"sv}; +constexpr std::array svg_mimetypes{"image/svg+xml"sv}; + +// `.cur` is the same container with a different resource type, so it rides +// along here rather than becoming a type of its own +constexpr std::array ico_extensions{"ico"sv, "cur"sv}; +constexpr std::array ico_mimetypes{"image/vnd.microsoft.icon"sv, + "image/x-icon"sv}; + +constexpr std::array jxl_extensions{"jxl"sv}; +constexpr std::array jxl_mimetypes{"image/jxl"sv}; + +constexpr std::array jp2_extensions{"jp2"sv, "jpx"sv, "jpf"sv, + "j2k"sv, "jpc"sv, "j2c"sv}; +constexpr std::array jp2_mimetypes{"image/jp2"sv, "image/jpx"sv}; + +// `.psb` is the large document variant and carries the same signature +constexpr std::array psd_extensions{"psd"sv, "psb"sv}; +constexpr std::array psd_mimetypes{"image/vnd.adobe.photoshop"sv, + "application/x-photoshop"sv}; + +constexpr std::array wmf_extensions{"wmf"sv}; +constexpr std::array wmf_mimetypes{"image/wmf"sv, "image/x-wmf"sv, + "application/x-msmetafile"sv}; + +constexpr std::array emf_extensions{"emf"sv}; +constexpr std::array emf_mimetypes{"image/emf"sv, "image/x-emf"sv}; + constexpr std::array mp3_extensions{"mp3"sv}; constexpr std::array mp3_mimetypes{"audio/mpeg"sv, "audio/mp3"sv, "audio/x-mpeg"sv}; @@ -579,6 +610,69 @@ constexpr std::array table{ FileCategory::video, DocumentType::unknown, {.detect_by_content = true, .open = true, .translate_html = true}}, + + // Named but not decoded, like the images above. `translate_html` says the + // image page is written and the data url is labelled with the type below, + // not that every browser paints it - that is already true of tiff and heif. + Row{FileType::scalable_vector_graphics, + "svg"sv, + svg_extensions, + svg_mimetypes, + FileCategory::image, + DocumentType::unknown, + {.detect_by_content = true, .open = true, .translate_html = true}}, + Row{FileType::windows_icon, + "ico"sv, + ico_extensions, + ico_mimetypes, + FileCategory::image, + DocumentType::unknown, + {.detect_by_content = true, .open = true, .translate_html = true}}, + Row{FileType::jpeg_xl, + "jxl"sv, + jxl_extensions, + jxl_mimetypes, + FileCategory::image, + DocumentType::unknown, + {.detect_by_content = true, .open = true, .translate_html = true}}, + Row{FileType::jpeg_2000, + "jp2"sv, + jp2_extensions, + jp2_mimetypes, + FileCategory::image, + DocumentType::unknown, + {.detect_by_content = true, .open = true, .translate_html = true}}, + Row{FileType::photoshop_document, + "psd"sv, + psd_extensions, + psd_mimetypes, + FileCategory::image, + DocumentType::unknown, + {.detect_by_content = true, .open = true, .translate_html = true}}, + Row{FileType::windows_metafile, + "wmf"sv, + wmf_extensions, + wmf_mimetypes, + FileCategory::image, + DocumentType::unknown, + {.detect_by_content = true, .open = true, .translate_html = true}}, + Row{FileType::enhanced_metafile, + "emf"sv, + emf_extensions, + emf_mimetypes, + FileCategory::image, + DocumentType::unknown, + {.detect_by_content = true, .open = true, .translate_html = true}}, + + // Detection reports it, nothing opens it yet - a plain xml file still + // decodes as text. + Row{FileType::xml, + "xml"sv, + xml_extensions, + xml_mimetypes, + FileCategory::text, + DocumentType::unknown, + {.detect_by_content = true}}, }; /// Finds the row whose list, selected by @p list, contains @p needle. diff --git a/src/odr/internal/html/image_file.cpp b/src/odr/internal/html/image_file.cpp index f8f4475f..433a3ab6 100644 --- a/src/odr/internal/html/image_file.cpp +++ b/src/odr/internal/html/image_file.cpp @@ -37,16 +37,14 @@ void write_image_src(const ImageFile &image_file, std::ostream &out, // TODO use stream out << file_to_url(svg_out.str(), "image/svg+xml"); } catch (...) { - // else we guess that it is a usual image + // else it is a usual image and goes out as it came in // TODO use stream out << file_to_url(*image_file.stream(), mime_type); } } -/// The image page knows exactly which format it is holding, so it says so -/// rather than taking `translate_image_src`'s `image/jpg` - webp, heif and -/// avif arrive here now, and a browser that honours the data URL's type would -/// be left with nothing. +/// An image file knows exactly which format it is holding, so the data url +/// says so. The table's first mime type is the canonical one. std::string image_mime_type(const ImageFile &image_file) { const std::span mimetypes = mimetypes_by_file_type(image_file.file_type()); @@ -147,7 +145,8 @@ void html::translate_image_src(const File &file, std::ostream &out, try { translate_image_src(DecodedFile(file).as_image_file(), out, config); } catch (...) { - // TODO hacky - `image/jpg` works for all common image types in chrome + // nothing named it, so the label is a guess - browsers sniff `` and + // `image/jpg` is what they have been handed here for years // TODO use stream out << file_to_url(*file.stream(), "image/jpg"); } @@ -155,11 +154,7 @@ void html::translate_image_src(const File &file, std::ostream &out, void html::translate_image_src(const ImageFile &image_file, std::ostream &out, const HtmlConfig & /*config*/) { - // TODO hacky - `image/jpg` works for all common image types in chrome. - // An image inside a document keeps it: browsers sniff ``, and naming - // the real type here would rewrite every reference output we have. The - // standalone image page does name it - see `image_mime_type`. - write_image_src(image_file, out, "image/jpg"); + write_image_src(image_file, out, image_mime_type(image_file)); } HtmlService html::create_image_service(const ImageFile &image_file, diff --git a/src/odr/internal/magic.cpp b/src/odr/internal/magic.cpp index 4a288939..aed926ec 100644 --- a/src/odr/internal/magic.cpp +++ b/src/odr/internal/magic.cpp @@ -9,15 +9,24 @@ #include #include -#include #include #include +#include #include namespace odr::internal { namespace { +/// At most @p size bytes, cut back to what was actually read - a file shorter +/// than the longest signature must never be matched against what sat behind it. +std::string read_head(std::istream &in, const std::size_t size) { + std::string result(size, '\0'); + in.read(result.data(), static_cast(size)); + result.resize(static_cast(in.gcount())); + return result; +} + bool match_magic(const std::string &head, const std::string &pattern) { const auto bytes = util::string::split(pattern, " "); if (bytes.size() > head.size()) { @@ -161,20 +170,44 @@ FileType magic::file_type(const std::string &magic) { return FileType::mpeg_audio; } + if (match_magic(magic, "00 00 01 00") || // icon + match_magic(magic, "00 00 02 00")) { // cursor + return FileType::windows_icon; + } + if (match_magic(magic, "00 00 00 0C 6A 50 20 20 0D 0A 87 0A") || // 'jP ' box + match_magic(magic, "FF 4F FF 51")) { // bare codestream + return FileType::jpeg_2000; + } + if (match_magic(magic, "00 00 00 0C 4A 58 4C 20 0D 0A 87 0A")) { // 'JXL ' box + return FileType::jpeg_xl; + } + if (match_magic(magic, "38 42 50 53")) { // '8BPS' + return FileType::photoshop_document; + } + if (match_magic(magic, "D7 CD C6 9A") || // aldus placeable header + match_magic(magic, "01 00 09 00 00 03") || // memory metafile header + match_magic(magic, "02 00 09 00 00 03")) { // disk metafile header + return FileType::windows_metafile; + } + // the leading record type alone is far too weak, so the header signature at + // offset 40 has to agree + if (match_magic(magic, "01 00 00 00") && tag_at(magic, 40) == " EMF") { + return FileType::enhanced_metafile; + } + // two bytes and nothing more to check, so it goes after everything else + if (match_magic(magic, "FF 0A")) { // bare codestream + return FileType::jpeg_xl; + } + return FileType::unknown; } FileType magic::file_type(std::istream &in) { - static constexpr std::size_t max_head_size = 12; - - // value initialized, and cut back to what was actually read: a file shorter - // than the longest signature would otherwise be matched against whatever the - // stack held behind it - std::array head{}; - in.read(head.data(), head.size()); + // every signature is a prefix but one: an enhanced metafile names itself at + // offset 40, so the head has to reach 44 + static constexpr std::size_t head_size = 64; - return file_type( - std::string(head.data(), static_cast(in.gcount()))); + return file_type(read_head(in, head_size)); } FileType magic::file_type(const abstract::File &file) { diff --git a/src/odr/internal/open_strategy.cpp b/src/odr/internal/open_strategy.cpp index 4feb8f4d..5b4e4c06 100644 --- a/src/odr/internal/open_strategy.cpp +++ b/src/odr/internal/open_strategy.cpp @@ -18,7 +18,9 @@ #include #include #include +#include #include +#include #include #include @@ -282,6 +284,24 @@ open_strategy::list_file_types(const std::shared_ptr &file, } catch (...) { ODR_VERBOSE(logger, "failed to open as json"); } + + // an svg has no signature to find it by - it is xml, and only the root + // element tells the two apart, so both are reported + try { + ODR_VERBOSE(logger, "try open as xml"); + util::xml::check_xml_file(*file->stream()); + result.push_back(FileType::xml); + + try { + ODR_VERBOSE(logger, "try open as svg"); + svg::check_svg_file(*file->stream()); + result.push_back(FileType::scalable_vector_graphics); + } catch (...) { + ODR_VERBOSE(logger, "failed to open as svg"); + } + } catch (...) { + ODR_VERBOSE(logger, "failed to open as xml"); + } } catch (...) { ODR_VERBOSE(logger, "failed to open as text"); } @@ -393,6 +413,17 @@ open_strategy::open_file(const std::shared_ptr &file, ODR_VERBOSE(logger, "failed to open as json"); } + // see `list_file_types` - an svg is only recognised by parsing it, and + // a plain xml file has no decoder of its own, so it stays text + try { + ODR_VERBOSE(logger, "try open as svg"); + svg::check_svg_file(*file->stream()); + return std::make_unique(file, + FileType::scalable_vector_graphics); + } catch (...) { + ODR_VERBOSE(logger, "failed to open as svg"); + } + ODR_VERBOSE(logger, "open as text file"); // TODO looks dirty return std::make_unique(file); diff --git a/src/odr/internal/svg/svg_util.cpp b/src/odr/internal/svg/svg_util.cpp new file mode 100644 index 00000000..8823ec0a --- /dev/null +++ b/src/odr/internal/svg/svg_util.cpp @@ -0,0 +1,28 @@ +#include + +#include + +#include + +#include +#include + +namespace odr::internal { + +void svg::check_svg_file(std::istream &in) { + const pugi::xml_document document = util::xml::parse(in); + + // pugixml does not process namespaces, so the root element carries whatever + // prefix the document bound to the svg namespace + std::string_view name = document.document_element().name(); + if (const std::size_t colon = name.find(':'); + colon != std::string_view::npos) { + name.remove_prefix(colon + 1); + } + + if (name != "svg") { + throw std::runtime_error("no svg file"); + } +} + +} // namespace odr::internal diff --git a/src/odr/internal/svg/svg_util.hpp b/src/odr/internal/svg/svg_util.hpp new file mode 100644 index 00000000..61cac5e9 --- /dev/null +++ b/src/odr/internal/svg/svg_util.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include + +namespace odr::internal::svg { + +/// Throws unless @p in holds an xml document whose root element is `svg`. +void check_svg_file(std::istream &in); + +} // namespace odr::internal::svg diff --git a/src/odr/internal/util/xml_util.cpp b/src/odr/internal/util/xml_util.cpp index 69d3d9e8..59e224bb 100644 --- a/src/odr/internal/util/xml_util.cpp +++ b/src/odr/internal/util/xml_util.cpp @@ -8,6 +8,8 @@ #include +#include + namespace odr::internal::util { pugi::xml_document xml::parse(const std::string &in) { @@ -26,6 +28,8 @@ pugi::xml_document xml::parse(std::istream &in) { return result; } +void xml::check_xml_file(std::istream &in) { std::ignore = parse(in); } + pugi::xml_document xml::parse(const abstract::ReadableFilesystem &filesystem, const AbsPath &path) { pugi::xml_document result; diff --git a/src/odr/internal/util/xml_util.hpp b/src/odr/internal/util/xml_util.hpp index a4dc51ca..490a7727 100644 --- a/src/odr/internal/util/xml_util.hpp +++ b/src/odr/internal/util/xml_util.hpp @@ -22,6 +22,9 @@ pugi::xml_document parse(const std::string &); pugi::xml_document parse(std::istream &); pugi::xml_document parse(const abstract::ReadableFilesystem &, const AbsPath &); +/// Throws unless @p in holds a well formed xml document. +void check_xml_file(std::istream &in); + struct StringToken { enum class Type { none, diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 6667c62a..8907510f 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -37,6 +37,7 @@ add_executable(odr_test "src/table_position_test.cpp" "src/internal/html/common_test.cpp" + "src/internal/html/image_file_test.cpp" "src/internal/html/media_file_test.cpp" "src/internal/magic_test.cpp" diff --git a/test/data.cmake b/test/data.cmake index a80cb873..f8049b66 100644 --- a/test/data.cmake +++ b/test/data.cmake @@ -17,9 +17,9 @@ odr_test_data( odr_test_data( PATH "reference-output/odr-public" URL "https://github.com/opendocument-app/OpenDocument.test.output.git" - REVISION "650c95dfd924d43aa5226e69c208cc094a69f0bc") + REVISION "47b226f82957e6976e692c818a91656c1f40f773") odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "047d755fba88902817c493f7b688530759bec935") + REVISION "439447f37320e4d93d23bc5b2990ea772e19a8eb") diff --git a/test/src/internal/html/image_file_test.cpp b/test/src/internal/html/image_file_test.cpp new file mode 100644 index 00000000..55afa9f8 --- /dev/null +++ b/test/src/internal/html/image_file_test.cpp @@ -0,0 +1,171 @@ +#include +#include +#include +#include + +#include +#include +#include + +#include + +#include +#include +#include +#include + +using namespace odr; + +namespace { + +/// Signature plus payload. Nothing past the signature is ever read - these +/// formats are named, not decoded. +File image_file(const std::string &content) { + return File(std::make_shared(content)); +} + +File svg_file() { + return image_file(R"()" + "\n" + R"()"); +} + +File ico_file() { return image_file(std::string("\x00\x00\x01\x00", 4) + "d"); } + +File png_file() { + return image_file(std::string("\x89PNG\r\n\x1a\n", 8) + "payload"); +} + +std::string cache_path(const std::string &name) { + return (std::filesystem::current_path() / name).string(); +} + +std::string write_path(const HtmlService &service, const std::string &path) { + std::ostringstream out; + service.write(path, out); + return out.str(); +} + +std::string image_src(const File &file) { + std::ostringstream out; + internal::html::translate_image_src(DecodedFile(file).as_image_file(), out, + HtmlConfig()); + return out.str(); +} + +} // namespace + +namespace { + +std::vector detect(const std::string &content) { + return internal::open_strategy::list_file_types( + std::make_shared(content), Logger::null()); +} + +} // namespace + +/// An svg has no signature - it is xml, and only the root element separates it +/// from any other xml. So `magic` does not guess it; the open strategy parses +/// the file and reports every layer it verified, least specific first. +TEST(image_file, svg_is_detected_by_parsing_it) { + EXPECT_EQ(detect(R"()"), + (std::vector{FileType::text_file, FileType::xml, + FileType::scalable_vector_graphics})); + + // a namespace prefix binds the root element just the same + EXPECT_EQ(detect(R"()"), + (std::vector{FileType::text_file, FileType::xml, + FileType::scalable_vector_graphics})); + + // a prologue of any length is the parser's problem, not a scanner's + EXPECT_EQ(detect("\n"), + (std::vector{FileType::text_file, FileType::xml, + FileType::scalable_vector_graphics})); +} + +/// The root element is what separates the two, so xml that is not an svg stops +/// at xml - including the two that a head scanner would most easily confuse. +TEST(image_file, xml_that_is_not_an_svg_stops_at_xml) { + for (const std::string content : + {R"()", + R"()"}) { + EXPECT_EQ(detect(content), + (std::vector{FileType::text_file, FileType::xml})) + << content; + } + + // not xml at all + EXPECT_EQ(detect("just some text"), (std::vector{FileType::text_file})); +} + +TEST(image_file, svg_is_detected_and_opens_as_an_image) { + const DecodedFile file{svg_file()}; + + EXPECT_EQ(file.file_type(), FileType::scalable_vector_graphics); + EXPECT_EQ(file.file_category(), FileCategory::image); + EXPECT_EQ(file.file_meta().mimetype, "image/svg+xml"); + EXPECT_TRUE(file.capabilities().translate_html); + + EXPECT_TRUE(file.is_image_file()); + EXPECT_FALSE(file.is_decodable()); + EXPECT_FALSE(file.is_text_file()); +} + +TEST(image_file, svg_translates_to_an_image_page) { + const DecodedFile file{svg_file()}; + const HtmlService service = + html::translate(file, cache_path("image_svg"), HtmlConfig()); + + ASSERT_EQ(service.list_views().size(), 1); + EXPECT_EQ(service.list_views().front().name(), "image"); + + const std::string html = write_path(service, "image.html"); + EXPECT_NE(html.find(" every_file_type() { std::vector result; for (auto i = static_cast(FileType::unknown); - i <= static_cast(FileType::audio_video_interleave); ++i) { + i <= static_cast(FileType::xml); ++i) { result.push_back(static_cast(i)); } return result;