diff --git a/CMakeLists.txt b/CMakeLists.txt index e0abb814..93e0da4d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -116,6 +116,7 @@ set(ODR_SOURCE_FILES "src/odr/internal/common/file.cpp" "src/odr/internal/common/filesystem.cpp" "src/odr/internal/common/image_file.cpp" + "src/odr/internal/common/media_file.cpp" "src/odr/internal/common/path.cpp" "src/odr/internal/common/random.cpp" "src/odr/internal/common/style.cpp" @@ -138,6 +139,7 @@ set(ODR_SOURCE_FILES "src/odr/internal/html/html_service.cpp" "src/odr/internal/html/html_writer.cpp" "src/odr/internal/html/image_file.cpp" + "src/odr/internal/html/media_file.cpp" "src/odr/internal/html/pdf_file.cpp" "src/odr/internal/html/text_file.cpp" diff --git a/apple/include/OdrCoreObjC/ODRHtml.h b/apple/include/OdrCoreObjC/ODRHtml.h index 1e7aec13..b589eae8 100644 --- a/apple/include/OdrCoreObjC/ODRHtml.h +++ b/apple/include/OdrCoreObjC/ODRHtml.h @@ -16,6 +16,7 @@ typedef NS_ENUM(NSInteger, ODRHtmlResourceType) { ODRHtmlResourceTypeJs, ODRHtmlResourceTypeImage, ODRHtmlResourceTypeFont, + ODRHtmlResourceTypeMedia, } NS_SWIFT_NAME(HtmlResourceType); typedef NS_ENUM(NSInteger, ODRHtmlTableGridlines) { diff --git a/apple/src/ODRHtml.mm b/apple/src/ODRHtml.mm index 0c22ce9d..9000a5b8 100644 --- a/apple/src/ODRHtml.mm +++ b/apple/src/ODRHtml.mm @@ -20,6 +20,7 @@ ODR_SAME_ENUM(ODRHtmlResourceTypeJs, odr::HtmlResourceType::js); ODR_SAME_ENUM(ODRHtmlResourceTypeImage, odr::HtmlResourceType::image); ODR_SAME_ENUM(ODRHtmlResourceTypeFont, odr::HtmlResourceType::font); +ODR_SAME_ENUM(ODRHtmlResourceTypeMedia, odr::HtmlResourceType::media); ODR_SAME_ENUM(ODRHtmlTableGridlinesNone, odr::HtmlTableGridlines::none); ODR_SAME_ENUM(ODRHtmlTableGridlinesSoft, odr::HtmlTableGridlines::soft); diff --git a/jni/java/app/opendocument/core/HtmlResourceType.java b/jni/java/app/opendocument/core/HtmlResourceType.java index 8a5dd3db..fe4e74f3 100644 --- a/jni/java/app/opendocument/core/HtmlResourceType.java +++ b/jni/java/app/opendocument/core/HtmlResourceType.java @@ -2,7 +2,7 @@ /** Mirrors {@code odr::HtmlResourceType}; constant order must match the C++ declaration. */ public enum HtmlResourceType { - HTML_FRAGMENT, CSS, JS, IMAGE, FONT; + HTML_FRAGMENT, CSS, JS, IMAGE, FONT, MEDIA; static HtmlResourceType fromNative(int code) { return code < 0 ? null : values()[code]; diff --git a/python/src/bind_html.cpp b/python/src/bind_html.cpp index 10b913fc..e6f00e0a 100644 --- a/python/src/bind_html.cpp +++ b/python/src/bind_html.cpp @@ -21,7 +21,8 @@ void odr_python::bind_html(py::module_ &m) { .value("css", odr::HtmlResourceType::css) .value("js", odr::HtmlResourceType::js) .value("image", odr::HtmlResourceType::image) - .value("font", odr::HtmlResourceType::font); + .value("font", odr::HtmlResourceType::font) + .value("media", odr::HtmlResourceType::media); py::enum_(m, "HtmlTableGridlines") .value("none", odr::HtmlTableGridlines::none) diff --git a/src/odr/file.hpp b/src/odr/file.hpp index d370ab73..5ad85d6d 100644 --- a/src/odr/file.hpp +++ b/src/odr/file.hpp @@ -90,11 +90,12 @@ enum class FileType { // https://en.wikipedia.org/wiki/OpenType opentype_font, - // Detection only, like `word_perfect` above: the media formats a viewer is - // regularly handed alongside documents. Naming them is the point - a caller - // that knows a file is a video hands it to a player instead of asking us to - // translate it, and without a name the text fallback would call it plain - // text. New entries go at the end: the bindings mirror this enum by ordinal. + // The media formats a viewer is regularly handed alongside documents. + // Nothing here is decoded - opening one wraps its bytes, and translating it + // puts those bytes in an `` or in a player and lets the browser do the + // work. Naming them is what makes that possible; without a name the text + // fallback would call a video plain text. + // New entries go at the end: the bindings mirror this enum by ordinal. // https://en.wikipedia.org/wiki/WebP webp, // https://en.wikipedia.org/wiki/TIFF diff --git a/src/odr/html.cpp b/src/odr/html.cpp index 35db76d5..7e1d252c 100644 --- a/src/odr/html.cpp +++ b/src/odr/html.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -226,6 +227,14 @@ HtmlService html::translate(const DecodedFile &file, if (file.is_font_file()) { return translate(file.as_font_file(), cache_path, config, logger); } + // No wrapper type to go through: nothing is decoded, so the plain + // `DecodedFile` already carries the bytes and the type that names them. + if (const FileCategory category = file.file_category(); + category == FileCategory::audio || category == FileCategory::video) { + std::filesystem::create_directories(cache_path); + return internal::html::create_media_service(file, cache_path, config, + logger); + } throw UnsupportedFileType(file.file_type()); } diff --git a/src/odr/html.hpp b/src/odr/html.hpp index 9ba69a08..f4653875 100644 --- a/src/odr/html.hpp +++ b/src/odr/html.hpp @@ -31,6 +31,9 @@ enum class HtmlResourceType { js, image, font, + // appended rather than sorted in: the bindings mirror this enum by ordinal + /// Audio or video, never embedded — see @ref HtmlConfig::embed_images. + media, }; class HtmlResource final { diff --git a/src/odr/internal/common/media_file.cpp b/src/odr/internal/common/media_file.cpp new file mode 100644 index 00000000..7377ffc9 --- /dev/null +++ b/src/odr/internal/common/media_file.cpp @@ -0,0 +1,37 @@ +#include + +#include + +namespace odr::internal { + +MediaFile::MediaFile(std::shared_ptr file, + const FileType file_type) + : m_file{std::move(file)}, m_file_type{file_type} {} + +std::shared_ptr MediaFile::file() const noexcept { + return m_file; +} + +FileType MediaFile::file_type() const noexcept { return m_file_type; } + +FileCategory MediaFile::file_category() const noexcept { + return file_category_by_file_type(m_file_type); +} + +FileMeta MediaFile::file_meta() const noexcept { + FileMeta result; + result.type = file_type(); + result.mimetype = mimetype(); + return result; +} + +std::string_view MediaFile::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 MediaFile::is_decodable() const noexcept { return false; } + +} // namespace odr::internal diff --git a/src/odr/internal/common/media_file.hpp b/src/odr/internal/common/media_file.hpp new file mode 100644 index 00000000..b4f3fecc --- /dev/null +++ b/src/odr/internal/common/media_file.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include + +namespace odr::internal { + +/// Audio and video: nothing is decoded, the bytes are handed to the browser as +/// they are. One class covers both, the category coming from the file type +/// table rather than from the class. +class MediaFile final : public abstract::DecodedFile { +public: + MediaFile(std::shared_ptr file, FileType file_type); + + [[nodiscard]] std::shared_ptr file() const noexcept override; + + [[nodiscard]] FileType file_type() const noexcept override; + [[nodiscard]] FileCategory file_category() const noexcept override; + [[nodiscard]] FileMeta file_meta() const noexcept override; + [[nodiscard]] std::string_view mimetype() const noexcept override; + + [[nodiscard]] bool is_decodable() const noexcept override; + +private: + std::shared_ptr m_file; + FileType m_file_type; +}; + +} // namespace odr::internal diff --git a/src/odr/internal/file_type_table.cpp b/src/odr/internal/file_type_table.cpp index 1a5716e4..c0e3cae9 100644 --- a/src/odr/internal/file_type_table.cpp +++ b/src/odr/internal/file_type_table.cpp @@ -474,36 +474,39 @@ constexpr std::array table{ DocumentType::unknown, {.detect_by_content = true, .open = true, .translate_html = true}}, - // Named but not decoded, so `detect_by_content` is the only capability - // they carry - see the comment on these in `FileType`. + // Named but not decoded: `open` wraps the bytes without looking at them + // and `translate_html` hands them straight to the browser, in an `` + // for the images below and in a player for the audio and video after them. + // Nothing here reads a pixel or a sample - see the comment on these in + // `FileType`. Row{FileType::webp, "webp"sv, webp_extensions, webp_mimetypes, FileCategory::image, DocumentType::unknown, - {.detect_by_content = true}}, + {.detect_by_content = true, .open = true, .translate_html = true}}, Row{FileType::tagged_image_file_format, "tiff"sv, tiff_extensions, tiff_mimetypes, FileCategory::image, DocumentType::unknown, - {.detect_by_content = true}}, + {.detect_by_content = true, .open = true, .translate_html = true}}, Row{FileType::high_efficiency_image_format, "heif"sv, heif_extensions, heif_mimetypes, FileCategory::image, DocumentType::unknown, - {.detect_by_content = true}}, + {.detect_by_content = true, .open = true, .translate_html = true}}, Row{FileType::av1_image_file_format, "avif"sv, avif_extensions, avif_mimetypes, FileCategory::image, DocumentType::unknown, - {.detect_by_content = true}}, + {.detect_by_content = true, .open = true, .translate_html = true}}, Row{FileType::mpeg_audio, "mp3"sv, @@ -511,35 +514,35 @@ constexpr std::array table{ mp3_mimetypes, FileCategory::audio, DocumentType::unknown, - {.detect_by_content = true}}, + {.detect_by_content = true, .open = true, .translate_html = true}}, Row{FileType::mpeg4_audio, "m4a"sv, m4a_extensions, m4a_mimetypes, FileCategory::audio, DocumentType::unknown, - {.detect_by_content = true}}, + {.detect_by_content = true, .open = true, .translate_html = true}}, Row{FileType::ogg_audio, "ogg"sv, ogg_extensions, ogg_mimetypes, FileCategory::audio, DocumentType::unknown, - {.detect_by_content = true}}, + {.detect_by_content = true, .open = true, .translate_html = true}}, Row{FileType::waveform_audio, "wav"sv, wav_extensions, wav_mimetypes, FileCategory::audio, DocumentType::unknown, - {.detect_by_content = true}}, + {.detect_by_content = true, .open = true, .translate_html = true}}, Row{FileType::free_lossless_audio_codec, "flac"sv, flac_extensions, flac_mimetypes, FileCategory::audio, DocumentType::unknown, - {.detect_by_content = true}}, + {.detect_by_content = true, .open = true, .translate_html = true}}, Row{FileType::mpeg4_video, "mp4"sv, @@ -547,35 +550,35 @@ constexpr std::array table{ mp4_mimetypes, FileCategory::video, DocumentType::unknown, - {.detect_by_content = true}}, + {.detect_by_content = true, .open = true, .translate_html = true}}, Row{FileType::quicktime_video, "mov"sv, mov_extensions, mov_mimetypes, FileCategory::video, DocumentType::unknown, - {.detect_by_content = true}}, + {.detect_by_content = true, .open = true, .translate_html = true}}, Row{FileType::third_generation_partnership_video, "3gp"sv, third_gpp_extensions, third_gpp_mimetypes, FileCategory::video, DocumentType::unknown, - {.detect_by_content = true}}, + {.detect_by_content = true, .open = true, .translate_html = true}}, Row{FileType::matroska_video, "mkv"sv, mkv_extensions, mkv_mimetypes, FileCategory::video, DocumentType::unknown, - {.detect_by_content = true}}, + {.detect_by_content = true, .open = true, .translate_html = true}}, Row{FileType::audio_video_interleave, "avi"sv, avi_extensions, avi_mimetypes, FileCategory::video, DocumentType::unknown, - {.detect_by_content = true}}, + {.detect_by_content = true, .open = true, .translate_html = true}}, }; /// Finds the row whose list, selected by @p list, contains @p needle. diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index 947a9099..d574ff9d 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -40,6 +40,12 @@ constexpr const char *text_css = R"css( [contenteditable]:focus{outline:none} )css"; +constexpr const char *media_css = R"css( +.odr-media{display:flex;align-items:center;justify-content:center;margin:0;min-height:100vh;background:#000} +.odr-media video{max-width:100%;max-height:100vh} +.odr-media audio{width:100%;max-width:40rem;margin:0 1rem} +)css"; + constexpr const char *document_js = R"js( (function () { "use strict"; @@ -584,6 +590,8 @@ void html::write_spreadsheet_style(HtmlWriter &out) { void html::write_text_style(HtmlWriter &out) { write_style(out, text_css); } +void html::write_media_style(HtmlWriter &out) { write_style(out, media_css); } + void html::write_document_script(HtmlWriter &out) { write_script(out, document_js); } diff --git a/src/odr/internal/html/frontend.hpp b/src/odr/internal/html/frontend.hpp index e6427c69..f6a17fa9 100644 --- a/src/odr/internal/html/frontend.hpp +++ b/src/odr/internal/html/frontend.hpp @@ -10,6 +10,7 @@ void write_document_style(HtmlWriter &out); /// Written in addition to the document style. void write_spreadsheet_style(HtmlWriter &out); void write_text_style(HtmlWriter &out); +void write_media_style(HtmlWriter &out); /// The `odr` object a document view exposes to its host: `generateDiff()`, /// `search()`, `searchNext()`, `searchPrevious()`, `resetSearch()`. diff --git a/src/odr/internal/html/image_file.cpp b/src/odr/internal/html/image_file.cpp index cf23b7d1..f8f4475f 100644 --- a/src/odr/internal/html/image_file.cpp +++ b/src/odr/internal/html/image_file.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -11,11 +12,47 @@ #include #include +#include #include +#include namespace odr::internal::html { namespace { +/// A starview metafile is converted to svg; anything else goes out as its own +/// bytes labelled @p mime_type. +void write_image_src(const ImageFile &image_file, std::ostream &out, + const std::string &mime_type) { + // try svm + try { + // TODO `image_file` is already an `SvmFile` + // TODO `impl()` might be a bit dirty + const std::shared_ptr image_file_impl = + image_file.file().impl(); + // TODO memory file might not be necessary; other istreams didn't support + // `tellg` + const svm::SvmFile svm_file(std::make_shared(*image_file_impl)); + std::ostringstream svg_out; + svm::Translator::svg(svm_file, svg_out); + // TODO use stream + out << file_to_url(svg_out.str(), "image/svg+xml"); + } catch (...) { + // else we guess that it is a usual image + // 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. +std::string image_mime_type(const ImageFile &image_file) { + const std::span mimetypes = + mimetypes_by_file_type(image_file.file_type()); + return mimetypes.empty() ? "image/jpg" : std::string(mimetypes.front()); +} + class HtmlServiceImpl final : public HtmlService { public: HtmlServiceImpl(ImageFile image_file, HtmlConfig config, const Logger &logger) @@ -83,7 +120,7 @@ class HtmlServiceImpl final : public HtmlService { out.out() << " alt=\"Error: image not found or unsupported\""; out.out() << " src=\""; - translate_image_src(m_image_file, out.out(), config()); + write_image_src(m_image_file, out.out(), image_mime_type(m_image_file)); out.out() << "\">"; } @@ -118,25 +155,11 @@ 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*/) { - // try svm - try { - // TODO `image_file` is already an `SvmFile` - // TODO `impl()` might be a bit dirty - const std::shared_ptr image_file_impl = - image_file.file().impl(); - // TODO memory file might not be necessary; other istreams didn't support - // `tellg` - const svm::SvmFile svm_file(std::make_shared(*image_file_impl)); - std::ostringstream svg_out; - svm::Translator::svg(svm_file, svg_out); - // TODO use stream - out << file_to_url(svg_out.str(), "image/svg+xml"); - } catch (...) { - // else we guess that it is a usual image - // TODO hacky - `image/jpg` works for all common image types in chrome - // TODO use stream - out << file_to_url(*image_file.stream(), "image/jpg"); - } + // 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"); } HtmlService html::create_image_service(const ImageFile &image_file, diff --git a/src/odr/internal/html/media_file.cpp b/src/odr/internal/html/media_file.cpp new file mode 100644 index 00000000..e350479b --- /dev/null +++ b/src/odr/internal/html/media_file.cpp @@ -0,0 +1,216 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace odr::internal::html { +namespace { + +/// The extension the media goes out under: the file type's canonical one, +/// unless the file came from disk named with another extension the same type +/// claims. `.mkv` and `.webm` are one type here - the two are the same bytes +/// down to the EBML DocType, deeper than a signature reaches - and a browser +/// handed a webm under the matroska name and MIME will not play it. +std::string source_extension(const DecodedFile &media_file) { + const std::span extensions = + file_extensions_by_file_type(media_file.file_type()); + if (extensions.empty()) { + return "bin"; + } + + if (const std::optional path = media_file.file().disk_path(); + path.has_value()) { + std::string extension = std::filesystem::path(*path).extension().string(); + if (!extension.empty()) { + extension.erase(0, 1); // the dot + std::ranges::transform(extension, extension.begin(), [](const char c) { + return static_cast(std::tolower(static_cast(c))); + }); + if (std::ranges::find(extensions, extension) != extensions.end()) { + return extension; + } + } + } + + return std::string(extensions.front()); +} + +/// The MIME type to serve @p extension as: the file type's canonical one, +/// unless @p extension is not the canonical extension either - then the alias +/// whose subtype names it, so a `.webm` goes out as `video/webm` rather than +/// `video/x-matroska`. +std::string mime_type_for(const FileType file_type, + const std::string_view extension) { + const std::span mimetypes = + mimetypes_by_file_type(file_type); + if (mimetypes.empty()) { + return "application/octet-stream"; + } + + const std::span extensions = + file_extensions_by_file_type(file_type); + if (extensions.empty() || extension == extensions.front()) { + return std::string(mimetypes.front()); + } + + for (const std::string_view mimetype : mimetypes) { + std::string_view subtype = mimetype.substr(mimetype.find('/') + 1); + if (subtype.starts_with("x-")) { + subtype.remove_prefix(2); + } + if (subtype == extension) { + return std::string(mimetype); + } + } + + return std::string(mimetypes.front()); +} + +class HtmlServiceImpl final : public HtmlService { +public: + HtmlServiceImpl(DecodedFile media_file, HtmlConfig config, + const Logger &logger) + : HtmlService(std::move(config), logger), + m_media_file{std::move(media_file)}, + m_element{m_media_file.file_category() == FileCategory::video + ? "video" + : "audio"}, + m_view_path{m_element + ".html"}, + m_extension{source_extension(m_media_file)}, + m_source_path{m_element + "." + m_extension}, + m_mime_type{mime_type_for(m_media_file.file_type(), m_extension)} { + m_views.emplace_back( + std::make_shared(*this, m_element, 0, m_view_path)); + } + + void warmup() const override {} + + [[nodiscard]] const HtmlViews &list_views() const override { return m_views; } + + [[nodiscard]] bool exists(const std::string &path) const override { + return path == m_view_path || path == m_source_path; + } + + [[nodiscard]] std::string mimetype(const std::string &path) const override { + if (path == m_view_path) { + return "text/html"; + } + if (path == m_source_path) { + return m_mime_type; + } + + throw FileNotFound("Unknown path: " + path); + } + + void write(const std::string &path, std::ostream &out) const override { + if (path == m_view_path) { + HtmlWriter writer(out, config()); + write_media(writer); + return; + } + if (path == m_source_path) { + m_media_file.file().pipe(out); + return; + } + + throw FileNotFound("Unknown path: " + path); + } + + HtmlResources write_html(const std::string &path, + HtmlWriter &out) const override { + if (path == m_view_path) { + return write_media(out); + } + + throw FileNotFound("Unknown path: " + path); + } + + HtmlResources write_media(HtmlWriter &out) const { + HtmlResources resources; + + // The media stays a resource rather than a data URI: a video is regularly + // larger than everything else we emit put together, and base64 in the + // markup would cost a third on top of it again. + const odr::HtmlResource resource = HtmlResource::create( + HtmlResourceType::media, m_mime_type, m_source_path, m_source_path, + m_media_file.file(), false, false, true); + const HtmlResourceLocation location = + config().resource_locator(resource, config()); + resources.emplace_back(resource, location); + + out.write_begin(); + out.write_header_begin(); + out.write_header_charset("UTF-8"); + out.write_header_target("_blank"); + out.write_header_title("odr"); + write_viewport_meta(out, config(), false); + write_media_style(out); + out.write_header_end(); + + out.write_body_begin(HtmlElementOptions().set_class("odr-media")); + + out.write_element_begin( + m_element, + HtmlElementOptions() + .set_extra(m_element == "video" + ? "controls playsinline preload=\"metadata\"" + : "controls preload=\"metadata\"") + .set_attributes([&](const HtmlAttributeWriterCallback &clb) { + if (location.has_value()) { + clb("src", escape_attribute(*location)); + } else { + clb("src", [&](std::ostream &o) { + o << file_to_url(*m_media_file.file().impl(), m_mime_type); + }); + } + })); + out.out() << "Error: " << m_element << " not supported by this browser"; + out.write_element_end(m_element); + + out.write_body_end(); + out.write_end(); + + return resources; + } + +private: + DecodedFile m_media_file; + + std::string m_element; + std::string m_view_path; + std::string m_extension; + std::string m_source_path; + std::string m_mime_type; + + HtmlViews m_views; +}; + +} // namespace +} // namespace odr::internal::html + +namespace odr::internal { + +HtmlService +html::create_media_service(const DecodedFile &media_file, + [[maybe_unused]] const std::string &cache_path, + HtmlConfig config, const Logger &logger) { + return odr::HtmlService( + std::make_unique(media_file, std::move(config), logger)); +} + +} // namespace odr::internal diff --git a/src/odr/internal/html/media_file.hpp b/src/odr/internal/html/media_file.hpp new file mode 100644 index 00000000..0d5edaba --- /dev/null +++ b/src/odr/internal/html/media_file.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace odr { +class DecodedFile; +struct HtmlConfig; +class HtmlService; +class Logger; +} // namespace odr + +namespace odr::internal::html { + +/// A player page for an audio or video file. There is no wrapper type to take +/// here — nothing is decoded, so the plain @ref DecodedFile carries everything +/// the page needs: the bytes and the file type they are named by. +HtmlService create_media_service(const DecodedFile &media_file, + const std::string &cache_path, + HtmlConfig config, const Logger &logger); + +} // namespace odr::internal::html diff --git a/src/odr/internal/open_strategy.cpp b/src/odr/internal/open_strategy.cpp index 6efe3ddf..4feb8f4d 100644 --- a/src/odr/internal/open_strategy.cpp +++ b/src/odr/internal/open_strategy.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -112,18 +113,6 @@ open_file_as(const std::shared_ptr &file, const FileType as, throw NoPdfFile(); } - if (as == FileType::portable_network_graphics || - as == FileType::graphics_interchange_format || as == FileType::jpeg || - as == FileType::bitmap_image_file) { - ODR_VERBOSE(logger, "open as image"); - try { - return std::make_unique(file, as); - } catch (...) { - ODR_VERBOSE(logger, "failed to open as image"); - } - throw NoImageFile(); - } - if (as == FileType::starview_metafile) { ODR_VERBOSE(logger, "open as svm"); try { @@ -134,6 +123,19 @@ open_file_as(const std::shared_ptr &file, const FileType as, throw NoSvmFile(); } + // Everything below has no decoder: the bytes go to the browser as they are, + // so the category is all that has to be right. Every image but the starview + // metafile above lands here, and so does all audio and video. + const FileCategory category = file_category_by_file_type(as); + if (category == FileCategory::image) { + ODR_VERBOSE(logger, "open as image"); + return std::make_unique(file, as); + } + if (category == FileCategory::audio || category == FileCategory::video) { + ODR_VERBOSE(logger, "open as media"); + return std::make_unique(file, as); + } + if (as == FileType::truetype_font || as == FileType::opentype_font) { ODR_VERBOSE(logger, "open as font"); try { @@ -348,16 +350,22 @@ open_strategy::open_file(const std::shared_ptr &file, ODR_VERBOSE(logger, "open as pdf"); return std::make_unique(file); } - if (file_type == FileType::portable_network_graphics || - file_type == FileType::graphics_interchange_format || - file_type == FileType::jpeg || file_type == FileType::bitmap_image_file) { - ODR_VERBOSE(logger, "open as image"); - return std::make_unique(file, file_type); - } if (file_type == FileType::starview_metafile) { ODR_VERBOSE(logger, "open as svm"); return std::make_unique(file); } + // see `open_file_as` — no decoder, so the category is all that has to be + // right + const FileCategory file_category = file_category_by_file_type(file_type); + if (file_category == FileCategory::image) { + ODR_VERBOSE(logger, "open as image"); + return std::make_unique(file, file_type); + } + if (file_category == FileCategory::audio || + file_category == FileCategory::video) { + ODR_VERBOSE(logger, "open as media"); + return std::make_unique(file, file_type); + } if (file_type == FileType::truetype_font || file_type == FileType::opentype_font) { ODR_VERBOSE(logger, "open as font"); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 8f0a23bb..6667c62a 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/media_file_test.cpp" "src/internal/magic_test.cpp" diff --git a/test/src/internal/html/media_file_test.cpp b/test/src/internal/html/media_file_test.cpp new file mode 100644 index 00000000..f4b66afd --- /dev/null +++ b/test/src/internal/html/media_file_test.cpp @@ -0,0 +1,176 @@ +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include +#include + +using namespace odr; +using namespace odr::internal; + +namespace { + +/// Signature plus payload. Nothing past the signature is ever read — audio and +/// video are named, not decoded — so the payload only has to come back out +/// unchanged. +File media_file(const std::string &signature) { + return File(std::make_shared(signature + "payload")); +} + +File mp3_file() { return media_file("ID3\x04"); } + +const std::string mp4_signature = + std::string("\x00\x00\x00\x18", 4) + "ftypmp42"; + +File mp4_file() { return media_file(mp4_signature); } + +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(); +} + +} // namespace + +TEST(media_file, audio_is_decoded_without_a_wrapper_type) { + const DecodedFile file{mp3_file()}; + + EXPECT_EQ(file.file_type(), FileType::mpeg_audio); + EXPECT_EQ(file.file_category(), FileCategory::audio); + EXPECT_EQ(file.file_meta().mimetype, "audio/mpeg"); + EXPECT_TRUE(file.capabilities().translate_html); + + EXPECT_FALSE(file.is_decodable()); + EXPECT_FALSE(file.is_text_file()); + EXPECT_FALSE(file.is_image_file()); + EXPECT_FALSE(file.is_document_file()); +} + +TEST(media_file, audio_translates_to_a_player) { + const DecodedFile file{mp3_file()}; + const HtmlService service = + html::translate(file, cache_path("media_audio"), HtmlConfig()); + + ASSERT_EQ(service.list_views().size(), 1); + EXPECT_EQ(service.list_views().front().name(), "audio"); + EXPECT_EQ(service.list_views().front().path(), "audio.html"); + + const std::string html = write_path(service, "audio.html"); + EXPECT_NE(html.find("( + std::string("RIFF\x24\x00\x00\x00WEBPdata", 16))); + const DecodedFile decoded{file}; + + EXPECT_EQ(decoded.file_type(), FileType::webp); + EXPECT_TRUE(decoded.is_image_file()); + + const HtmlService service = + html::translate(decoded, cache_path("media_webp"), HtmlConfig()); + ASSERT_EQ(service.list_views().size(), 1); + + // named, not guessed: a browser that honours the data URL's type has to be + // told this is webp and not the `image/jpg` a document's images go out as + const std::string html = write_path(service, "image.html"); + EXPECT_NE(html.find("