diff --git a/apple/include/OdrCoreObjC/ODRHtml.h b/apple/include/OdrCoreObjC/ODRHtml.h index b589eae83..c93b22a3c 100644 --- a/apple/include/OdrCoreObjC/ODRHtml.h +++ b/apple/include/OdrCoreObjC/ODRHtml.h @@ -17,6 +17,7 @@ typedef NS_ENUM(NSInteger, ODRHtmlResourceType) { ODRHtmlResourceTypeImage, ODRHtmlResourceTypeFont, ODRHtmlResourceTypeMedia, + ODRHtmlResourceTypeFile, } NS_SWIFT_NAME(HtmlResourceType); typedef NS_ENUM(NSInteger, ODRHtmlTableGridlines) { diff --git a/apple/src/ODRHtml.mm b/apple/src/ODRHtml.mm index 692aff382..6881fb8dd 100644 --- a/apple/src/ODRHtml.mm +++ b/apple/src/ODRHtml.mm @@ -21,6 +21,7 @@ ODR_SAME_ENUM(ODRHtmlResourceTypeImage, odr::HtmlResourceType::image); ODR_SAME_ENUM(ODRHtmlResourceTypeFont, odr::HtmlResourceType::font); ODR_SAME_ENUM(ODRHtmlResourceTypeMedia, odr::HtmlResourceType::media); +ODR_SAME_ENUM(ODRHtmlResourceTypeFile, odr::HtmlResourceType::file); 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 fe4e74f3e..33579f9b6 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, MEDIA; + HTML_FRAGMENT, CSS, JS, IMAGE, FONT, MEDIA, FILE; 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 e5487e004..375b838ce 100644 --- a/python/src/bind_html.cpp +++ b/python/src/bind_html.cpp @@ -22,7 +22,8 @@ void odr_python::bind_html(py::module_ &m) { .value("js", odr::HtmlResourceType::js) .value("image", odr::HtmlResourceType::image) .value("font", odr::HtmlResourceType::font) - .value("media", odr::HtmlResourceType::media); + .value("media", odr::HtmlResourceType::media) + .value("file", odr::HtmlResourceType::file); py::enum_(m, "HtmlTableGridlines") .value("none", odr::HtmlTableGridlines::none) diff --git a/src/odr/html.hpp b/src/odr/html.hpp index 4598a147d..4a4b06cf8 100644 --- a/src/odr/html.hpp +++ b/src/odr/html.hpp @@ -34,6 +34,8 @@ enum class HtmlResourceType { // appended rather than sorted in: the bindings mirror this enum by ordinal /// Audio or video, never embedded — see @ref HtmlConfig::embed_images. media, + /// An entry of an archive, offered as itself rather than rendered. + file, }; class HtmlResource final { diff --git a/src/odr/internal/html/filesystem.cpp b/src/odr/internal/html/filesystem.cpp index 839b6edf7..48bc5f46a 100644 --- a/src/odr/internal/html/filesystem.cpp +++ b/src/odr/internal/html/filesystem.cpp @@ -3,8 +3,10 @@ #include #include #include +#include #include +#include #include #include #include @@ -13,11 +15,14 @@ #include #include +#include #include namespace odr::internal::html { namespace { +constexpr std::string_view listing_path = "files.html"; + /// Binary multiples, named as such — nothing to guess the base of. std::string human_size(const std::size_t size) { static constexpr std::array units{"B", "KiB", "MiB", "GiB", @@ -36,29 +41,82 @@ std::string human_size(const std::size_t size) { return result.str(); } +/// From the extension, not the bytes: sniffing every entry would read the whole +/// archive. +std::string mime_type_of(const Path &path) { + const FileType type = file_type_by_file_extension(path.extension()); + if (type == FileType::unknown) { + return "application/octet-stream"; + } + return std::string(mimetype_by_file_type(type)); +} + +/// The whole entry inline, for a listing that has to stand alone. +std::optional entry_data_url(const File &file, + const std::string &mime_type) { + const std::unique_ptr stream = file.stream(); + if (stream == nullptr) { + return std::nullopt; + } + return file_to_url(*stream, mime_type); +} + +/// Where an entry is written, relative to the listing. The archive names it, so +/// a path that escapes the output directory or collides with the listing gets +/// none, and stays inline instead. +std::optional entry_location(const Path &path) { + const RelPath relative = path.make_relative(); + if (relative.escaping() || relative.empty() || + relative.string() == listing_path) { + return std::nullopt; + } + return relative; +} + class HtmlServiceImpl final : public HtmlService { public: HtmlServiceImpl(Filesystem filesystem, HtmlConfig config, const Logger &logger) : HtmlService(std::move(config), logger), - m_filesystem{std::move(filesystem)}, - m_resources{locate_filesystem_resources(this->config())} { - m_views.emplace_back( - std::make_shared(*this, "files", 0, "files.html")); + m_filesystem{std::move(filesystem)} { + m_views.emplace_back(std::make_shared(*this, "files", 0, + std::string(listing_path))); } - void warmup() const override {} + /// Walking the archive is what turns up the entries, so the resources a host + /// may ask for are only known once the listing has been written. + void warmup() const override { + std::lock_guard lock(m_mutex); + + if (m_warm) { + return; + } + + NullStream null; + HtmlWriter out(null, config()); + m_resources = write_filesystem(out); + + m_warm = true; + } [[nodiscard]] const HtmlViews &list_views() const override { return m_views; } [[nodiscard]] bool exists(const std::string &path) const override { - return path == "files.html" || resource_at(m_resources, path) != nullptr; + if (path == listing_path) { + return true; + } + + warmup(); + + return resource_at(m_resources, path) != nullptr; } [[nodiscard]] std::string mimetype(const std::string &path) const override { - if (path == "files.html") { + if (path == listing_path) { return "text/html"; } + warmup(); + if (const odr::HtmlResource *resource = resource_at(m_resources, path); resource != nullptr) { return resource->mime_type(); @@ -68,11 +126,13 @@ class HtmlServiceImpl final : public HtmlService { } void write(const std::string &path, std::ostream &out) const override { - if (path == "files.html") { + if (path == listing_path) { HtmlWriter writer(out, config()); write_filesystem(writer); return; } + warmup(); + if (const odr::HtmlResource *resource = resource_at(m_resources, path); resource != nullptr) { resource->write_resource(out); @@ -84,13 +144,37 @@ class HtmlServiceImpl final : public HtmlService { HtmlResources write_html(const std::string &path, HtmlWriter &out) const override { - if (path == "files.html") { + if (path == listing_path) { return write_filesystem(out); } throw FileNotFound("Unknown path: " + path); } + /// `nullopt` when the entry is to be written into the listing instead. + HtmlResourceLocation locate_entry(const WritingState &state, const Path &path, + const File &file) const { + const std::optional entry_path = entry_location(path); + if (!entry_path.has_value()) { + return std::nullopt; + } + + const odr::HtmlResource resource = HtmlResource::create( + HtmlResourceType::file, mime_type_of(path), path.basename(), + entry_path->string(), file, false, false, true); + HtmlResourceLocation location = + config().resource_locator(resource, config()); + // Two resources at one location write over each other; the stylesheet is + // registered first, so an entry landing on it yields. + if (location.has_value() && + resource_at(state.resources(), *location) != nullptr) { + return std::nullopt; + } + + state.resources().emplace_back(resource, location); + return location; + } + HtmlResources write_filesystem(HtmlWriter &out) const { HtmlResources resources; const WritingState state(out, config(), resources); @@ -115,53 +199,61 @@ class HtmlServiceImpl final : public HtmlService { // No header row: labels would be the only words in the page to translate. out.write_element_begin("tbody"); + // No directory rows: every entry names its whole path, and a directory is + // the one row with nothing to open, save or measure. for (; !file_walker.end(); file_walker.next()) { + if (!file_walker.is_file()) { + continue; + } + const Path file_path(file_walker.path()); - const bool is_file = file_walker.is_file(); + const File file = m_filesystem.open(file_path.string()); + const std::string name = file_path.basename(); + const HtmlResourceLocation location = + locate_entry(state, file_path, file); - out.write_element_begin("tr", HtmlElementOptions().set_class( - is_file ? std::nullopt - : std::optional( - "odr-files-directory"))); + out.write_element_begin("tr"); - // A trailing separator says "directory" without a column saying so. + // An embedded entry is a `data:` URL, which no browser navigates to at + // the top level, so there the path is text rather than a link. out.write_element_begin( "td", HtmlElementOptions().set_inline(true).set_class("odr-files-name")); - out.write_raw( - escape_text(is_file ? file_path.string() : file_path.string() + "/")); + if (location.has_value()) { + out.write_element_begin( + "a", HtmlElementOptions().set_inline(true).set_attributes( + HtmlAttributesVector{{"href", escape_attribute(*location)}, + {"title", escape_attribute(name)}})); + out.write_raw(escape_text(file_path.string())); + out.write_element_end("a"); + } else { + out.write_raw(escape_text(file_path.string())); + } out.write_element_end("td"); - const File file = - is_file ? m_filesystem.open(file_path.string()) : File(); - out.write_element_begin( "td", HtmlElementOptions().set_inline(true).set_class("odr-files-size")); - if (is_file) { - out.write_raw(human_size(file.size())); - } + out.write_raw(human_size(file.size())); out.write_element_end("td"); + // The glyph has no name of its own; the file's is what a tooltip and a + // screen reader read. out.write_element_begin( "td", HtmlElementOptions().set_inline(true).set_class("odr-files-action")); - if (is_file) { - if (const std::unique_ptr stream = file.stream(); - stream != nullptr) { - const std::string name = file_path.basename(); - // The glyph has no name of its own; the file's is what a tooltip and - // a screen reader read. - out.write_element_begin( - "a", HtmlElementOptions().set_inline(true).set_attributes( - HtmlAttributesVector{ - {"href", - file_to_url(*stream, "application/octet-stream")}, - {"download", escape_attribute(name)}, - {"title", escape_attribute(name)}})); - out.write_raw("\u2193"); - out.write_element_end("a"); - } + if (const std::optional href = + location.has_value() + ? std::optional(escape_attribute(*location)) + : entry_data_url(file, mime_type_of(file_path)); + href.has_value()) { + out.write_element_begin( + "a", HtmlElementOptions().set_inline(true).set_attributes( + HtmlAttributesVector{{"href", *href}, + {"download", escape_attribute(name)}, + {"title", escape_attribute(name)}})); + out.write_raw("\u2193"); + out.write_element_end("a"); } out.write_element_end("td"); @@ -180,10 +272,12 @@ class HtmlServiceImpl final : public HtmlService { protected: Filesystem m_filesystem; - /// The css this view links; empty of locations when the config embeds it. - HtmlResources m_resources; HtmlViews m_views; + + mutable std::mutex m_mutex; + mutable bool m_warm = false; + mutable HtmlResources m_resources; }; } // namespace diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index 6f8a0aae1..b31bc834d 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -97,9 +97,10 @@ body{background:#fff;color:#1f2328;font:13px/1.5 var(--odr-files-font)} .odr-files td{padding:5px 12px;border-top:1px solid var(--odr-files-line)} .odr-files tbody tr:hover>*{background-image:linear-gradient(rgba(0,0,0,.04),rgba(0,0,0,.04))} .odr-files-name{font-family:var(--odr-files-mono);word-break:break-all} -.odr-files-directory .odr-files-name{color:var(--odr-files-muted)} +.odr-files-name a{color:inherit;text-decoration:none} +.odr-files-name a:hover{color:var(--odr-files-link);text-decoration:underline} .odr-files-size{width:1%;text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums;color:var(--odr-files-muted)} -.odr-files-action{width:1%;white-space:nowrap} +.odr-files-action{width:1%;white-space:nowrap;text-align:right} /* U+2193: the download glyphs are missing from enough system fonts to tofu. */ .odr-files-action a{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:4px;color:var(--odr-files-muted);font-size:15px;line-height:1;text-decoration:none} .odr-files-action a:hover{background:rgba(0,0,0,.07);color:var(--odr-files-link)} @@ -988,11 +989,6 @@ HtmlResources html::locate_text_resources(const HtmlConfig &config) { return locate_all(assets, config); } -HtmlResources html::locate_filesystem_resources(const HtmlConfig &config) { - static constexpr std::array assets{filesystem_css_asset}; - return locate_all(assets, config); -} - HtmlResources html::locate_media_resources(const HtmlConfig &config) { static constexpr std::array assets{media_css_asset}; return locate_all(assets, config); diff --git a/src/odr/internal/html/frontend.hpp b/src/odr/internal/html/frontend.hpp index 35f74a45a..baed98e5e 100644 --- a/src/odr/internal/html/frontend.hpp +++ b/src/odr/internal/html/frontend.hpp @@ -27,7 +27,6 @@ void write_text_script(const WritingState &state); /// a service has to answer for these paths as well as for its views. Every /// entry is located `nullopt` when the config embeds them. HtmlResources locate_text_resources(const HtmlConfig &config); -HtmlResources locate_filesystem_resources(const HtmlConfig &config); HtmlResources locate_media_resources(const HtmlConfig &config); } // namespace odr::internal::html diff --git a/test/data.cmake b/test/data.cmake index 7cde26c59..47c0e8bb5 100644 --- a/test/data.cmake +++ b/test/data.cmake @@ -17,7 +17,7 @@ odr_test_data( odr_test_data( PATH "reference-output/odr-public" URL "https://github.com/opendocument-app/OpenDocument.test.output.git" - REVISION "e8413d37cfcd943f88bc05b325ee3bb91912212d") + REVISION "9e2f4c30b31d38aa4cf4bfae0b6920fd05f8563b") odr_test_data( PATH "reference-output/odr-private" diff --git a/test/src/html_test.cpp b/test/src/html_test.cpp index ae3231177..fdffa7167 100644 --- a/test/src/html_test.cpp +++ b/test/src/html_test.cpp @@ -59,6 +59,35 @@ TEST(html, linked_resources_are_served) { } // The one archive the reference-output suite renders has no directory in it. +// An archive may hold a file named like the stylesheet. Forcing the collision +// through the locator saves needing such an archive in the test data. +TEST(html, archive_entry_yields_to_a_shipped_resource) { + const auto logger = Logger::create_stdio("odr-test", LogLevel::verbose); + + const DecodedFile file(TestData::test_file_path("odr-public/odt/about.odt"), + FileType::zip, logger); + + HtmlConfig config((std::filesystem::current_path() / "collision").string()); + config.embed_shipped_resources = false; + config.resource_locator = [](const HtmlResource &resource, + const HtmlConfig &) -> HtmlResourceLocation { + return resource.is_shipped() ? "content.xml" : resource.path(); + }; + + std::ostringstream out; + const HtmlResources resources = + html::translate(file, config, logger).list_views().at(0).write_html(out); + + std::size_t claimants = 0; + for (const auto &[resource, location] : resources) { + if (location.has_value() && *location == "content.xml") { + ++claimants; + EXPECT_TRUE(resource.is_shipped()); + } + } + EXPECT_EQ(claimants, 1); +} + TEST(html, archive_listing) { const auto logger = Logger::create_stdio("odr-test", LogLevel::verbose); @@ -80,14 +109,25 @@ TEST(html, archive_listing) { EXPECT_NE(page.find(R"()"), std::string::npos); // no header row: the listing carries no words to translate EXPECT_EQ(page.find(""), std::string::npos); - EXPECT_NE(page.find(R"()"), + // directories are not listed; every entry names its own whole path + EXPECT_EQ(page.find("Configurations2/menubar/<"), std::string::npos); + EXPECT_NE(page.find("/Configurations2/accelerator/current.xml"), std::string::npos); - // a directory says so by its trailing separator and carries no size - EXPECT_NE(page.find(R"()"), + + // entries are written beside the listing and linked, rather than base64'd + // into it, so the path opens one and the glyph saves it + EXPECT_EQ(page.find("data:"), std::string::npos); + EXPECT_NE(page.find(R"()"), std::string::npos); EXPECT_NE( - page.find(R"()"), + page.find( + R"()"), std::string::npos); + EXPECT_TRUE(std::filesystem::is_regular_file( + std::filesystem::path(output_path) / "content.xml")); + EXPECT_TRUE(std::filesystem::is_regular_file( + std::filesystem::path(output_path) / "Pictures" / + "10000000000001F4000001FF1D2394A8.jpg")); } TEST(html, views) { diff --git a/wasm/src/wasm_core.cpp b/wasm/src/wasm_core.cpp index 47173fd4b..5b1861587 100644 --- a/wasm/src/wasm_core.cpp +++ b/wasm/src/wasm_core.cpp @@ -97,7 +97,8 @@ emscripten::val enum_tables() { entry("js", HtmlResourceType::js), entry("image", HtmlResourceType::image), entry("font", HtmlResourceType::font), - entry("media", HtmlResourceType::media))); + entry("media", HtmlResourceType::media), + entry("file", HtmlResourceType::file))); result.set("HtmlTableGridlines", table(entry("none", HtmlTableGridlines::none), entry("soft", HtmlTableGridlines::soft),
/mimetype
/Configurations2/menubar/