From 2d2bd54de7879bf3dd48b06175459d1556c11c23 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 9 Aug 2026 11:42:12 +0200 Subject: [PATCH] feat(html): offer an archive's entries as files, not as one page of base64 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every entry of an archive was inlined into the listing as a `data:` URL, whole, whatever the config said — a listing of `about.odt` came to 540 KB for 172 KB of archive, and a large zip produced a page no browser would want. It also left only one thing to do with an entry: save it. `data:` cannot be navigated to at the top level in any current browser, so opening one was never on offer. Entries are `HtmlResource`s now, the same as an image in a document, so the existing locator decides where they go and `bring_offline` writes them out. The listing drops to 5 KB beside the files it names, the http server serves them by path off a warmed-up render, and the entry is a real URL — which is what makes opening it possible at all. The path is the link that opens it, the way a file listing has always worked; the glyph beside it saves it. Two glyphs would have been two things to tell apart, and one of them would have had to mean "open". An embedded entry has no URL to open, so there the path is text and only the glyph remains. Directories are no longer listed. Every entry names its whole path, so a row for the directory above it repeated what the rows below it already said, and it was the one row with nothing to open, save or measure. An entry is named by the archive, and neither its name nor its path is trusted with one: a `..` that would climb out of the output directory, or a `files.html` that would be written over the listing, gets no location and stays inline. `HtmlResourceType` gains `file` — appended, since the bindings mirror it by ordinal — for a resource that is whatever the archive happened to hold. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HcgniS5pgVa4rV1yHxuKm7 --- apple/include/OdrCoreObjC/ODRHtml.h | 1 + apple/src/ODRHtml.mm | 1 + .../opendocument/core/HtmlResourceType.java | 2 +- python/src/bind_html.cpp | 3 +- src/odr/html.hpp | 2 + src/odr/internal/html/filesystem.cpp | 176 ++++++++++++++---- src/odr/internal/html/frontend.cpp | 10 +- src/odr/internal/html/frontend.hpp | 1 - test/data.cmake | 2 +- test/src/html_test.cpp | 48 ++++- wasm/src/wasm_core.cpp | 3 +- 11 files changed, 192 insertions(+), 57 deletions(-) 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/