diff --git a/AGENTS.md b/AGENTS.md index a8270b69f..d7b271006 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,7 @@ bytes ─▶ magic/open_strategy ─▶ DecodedFile ─▶ Document ─▶ Eleme | `src/odr/internal/oldms/` | **Legacy MS binary** (.doc/.ppt/.xls). | | `src/odr/internal/pdf/` | PDF (own parser). | | `src/odr/internal/xml/` | XML, rendered as a source view; see [`xml/AGENTS.md`](src/odr/internal/xml/AGENTS.md). | +| `src/odr/internal/svg/` | SVG, detected by reading it as xml; see [`svg/AGENTS.md`](src/odr/internal/svg/AGENTS.md). | | `src/odr/internal/{csv,json,text,svm}/` | Smaller formats. | | `cli/src/` | CLI tools: `translate`, `back_translate`, `meta`, `server`. | | `python/` | Python bindings (`pyodr`, pybind11); see [`python/AGENTS.md`](python/AGENTS.md). | @@ -177,6 +178,15 @@ Dispatch `release.yml` against main, publish the draft that appears — drifts from the header fails at **compile** time instead of becoming an obscure linker error. (The `util` helpers use the `struct string { static … }` idiom for exactly this.) Keep translation-unit-local helpers in an **anonymous namespace**. +- **The input file never authors the output markup**: we interpret a file and + emit our own html — text through `escape_text`, images as an `` we + construct. Nothing is passed through as live markup, which is why an svg goes + out as a data url rather than inlined ([`svg/AGENTS.md`](src/odr/internal/svg/AGENTS.md)) + and why the rendered page needs no sanitiser. What is *not* consistent today + is link targets: `html/pdf_file.cpp` filters a PDF `/URI` action down to an + allowlist of navigable schemes, while a document hyperlink + (`html/document_element.cpp`) is only `escape_attribute`d, so a `javascript:` + href in an odt reaches the page. Pick one policy before adding a third. - **Public API**: value semantics; immutable handles; iterators only for immutable traversal (`docs/design/README.md`). - **Byte parsing**: read POD structs via `util::byte_stream::read`; assumes host diff --git a/CHANGELOG.md b/CHANGELOG.md index c0f152038..eb95cb348 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ The release run heads these entries with the version and opens a fresh - An xml file opens as xml and reads as a foldable, highlighted source view rather than as one very long line, in the encoding its declaration names. +- An svg is recognised by reading it rather than by what it is called, so bytes + that are not one no longer open as an image that cannot be shown. ## v6.4.0 - 2026-08-09 diff --git a/CMakeLists.txt b/CMakeLists.txt index 6d1b30001..2c8e480d6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -235,7 +235,7 @@ 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/svg/svg_file.cpp" "src/odr/internal/svm/svm_file.cpp" "src/odr/internal/svm/svm_format.cpp" diff --git a/src/odr/exceptions.cpp b/src/odr/exceptions.cpp index 065ca8e05..aaa8d361c 100644 --- a/src/odr/exceptions.cpp +++ b/src/odr/exceptions.cpp @@ -70,6 +70,8 @@ NoLegacyMicrosoftFile::NoLegacyMicrosoftFile() NoXmlFile::NoXmlFile() : Exception("not an xml file") {} +NoSvgFile::NoSvgFile() : Exception("not an svg file") {} + UnsupportedCryptoAlgorithm::UnsupportedCryptoAlgorithm() : Exception("unsupported crypto algorithm") {} diff --git a/src/odr/exceptions.hpp b/src/odr/exceptions.hpp index dac37e653..c9d55fc17 100644 --- a/src/odr/exceptions.hpp +++ b/src/odr/exceptions.hpp @@ -147,6 +147,11 @@ struct NoXmlFile final : Exception { NoXmlFile(); }; +/// @brief No SVG file exception +struct NoSvgFile final : Exception { + NoSvgFile(); +}; + /// @brief Unsupported crypto algorithm exception struct UnsupportedCryptoAlgorithm final : Exception { UnsupportedCryptoAlgorithm(); diff --git a/src/odr/file.hpp b/src/odr/file.hpp index 8343a95e7..61ed44c74 100644 --- a/src/odr/file.hpp +++ b/src/odr/file.hpp @@ -128,7 +128,8 @@ enum class FileType { 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. + // the same reason as the block above - nothing here is decoded either, + // except svg, which is xml. // https://en.wikipedia.org/wiki/SVG scalable_vector_graphics, // https://en.wikipedia.org/wiki/ICO_(file_format) diff --git a/src/odr/internal/encoding/text_encoding_table.cpp b/src/odr/internal/encoding/text_encoding_table.cpp index 26ec513fc..833438403 100644 --- a/src/odr/internal/encoding/text_encoding_table.cpp +++ b/src/odr/internal/encoding/text_encoding_table.cpp @@ -1,8 +1,9 @@ #include +#include + #include #include -#include #include namespace odr::internal::encoding { @@ -127,8 +128,7 @@ std::string normalize(const std::string_view name) { if (c == '-' || c == '_' || c == ' ') { continue; } - result.push_back( - static_cast(std::tolower(static_cast(c)))); + result.push_back(util::string::to_lower(c)); } return result; } diff --git a/src/odr/internal/file_type_table.cpp b/src/odr/internal/file_type_table.cpp index c5063b54c..093ecd948 100644 --- a/src/odr/internal/file_type_table.cpp +++ b/src/odr/internal/file_type_table.cpp @@ -606,9 +606,9 @@ constexpr std::array table{ DocumentType::unknown, {.detect_by_content = true, .open = true, .translate_html = true}}, - // Named but not decoded, like the images above. `translate_html` means the - // image page is written and the data url labelled, not that every browser - // paints it. + // Named but not decoded, like the images above — except svg, which is xml + // and is parsed. For the rest `translate_html` means the image page is + // written and the data url labelled, not that every browser paints it. Row{FileType::scalable_vector_graphics, "svg"sv, svg_extensions, diff --git a/src/odr/internal/html/media_file.cpp b/src/odr/internal/html/media_file.cpp index 9cdd26919..f83dab673 100644 --- a/src/odr/internal/html/media_file.cpp +++ b/src/odr/internal/html/media_file.cpp @@ -10,9 +10,9 @@ #include #include #include +#include #include -#include #include #include #include @@ -38,9 +38,7 @@ std::string source_extension(const DecodedFile &media_file) { 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))); - }); + extension = util::string::to_lower(extension); if (std::ranges::find(extensions, extension) != extensions.end()) { return extension; } diff --git a/src/odr/internal/html/pdf_file.cpp b/src/odr/internal/html/pdf_file.cpp index 5eb75cdd9..ce7e21320 100644 --- a/src/odr/internal/html/pdf_file.cpp +++ b/src/odr/internal/html/pdf_file.cpp @@ -186,12 +186,11 @@ bool is_safe_uri(std::string_view uri) { for (const char ch : uri) { const auto c = static_cast(ch); if (ch == ':') { - std::ranges::transform(scheme, scheme.begin(), [](const char s) { - return static_cast(std::tolower(static_cast(s))); - }); static constexpr std::array allowed = { "http", "https", "mailto", "ftp", "ftps", "tel"}; - return std::ranges::find(allowed, scheme) != allowed.end(); + return std::ranges::any_of(allowed, [&scheme](const std::string_view s) { + return util::string::equals_ignore_case(scheme, s); + }); } if (ch == '/' || ch == '?' || ch == '#') { return true; // path/query/fragment reached first -> relative reference diff --git a/src/odr/internal/open_strategy.cpp b/src/odr/internal/open_strategy.cpp index ccf821428..68a4e2606 100644 --- a/src/odr/internal/open_strategy.cpp +++ b/src/odr/internal/open_strategy.cpp @@ -18,12 +18,13 @@ #include #include #include -#include +#include #include #include #include #include +#include namespace odr::internal { @@ -121,6 +122,18 @@ open_file_as(const std::shared_ptr &file, const FileType as, throw NoSvmFile(); } + if (as == FileType::scalable_vector_graphics) { + ODR_VERBOSE(logger, "open as svg"); + try { + auto text = std::make_shared(file); + return std::make_unique( + std::make_shared(text)); + } catch (...) { + ODR_VERBOSE(logger, "failed to open as svg"); + } + throw NoSvgFile(); + } + // no decoder below: the bytes go to the browser as they are, so only the // category has to be right const FileCategory category = file_category_by_file_type(as); @@ -305,14 +318,12 @@ open_strategy::list_file_types(const std::shared_ptr &file, // xml, so both are reported try { ODR_VERBOSE(logger, "try open as xml"); - result.push_back(xml::XmlFile(text).file_type()); - - 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"); + auto xml_file = std::make_shared(text); + result.push_back(xml_file->file_type()); + + if (svg::is_svg_file(*xml_file)) { + ODR_VERBOSE(logger, "open as svg"); + result.push_back(svg::SvgFile(xml_file).file_type()); } } catch (...) { ODR_VERBOSE(logger, "failed to open as xml"); @@ -427,20 +438,22 @@ open_strategy::open_file(const std::shared_ptr &file, ODR_VERBOSE(logger, "failed to open as json"); } - // svg first - it is the more specific reading of the same bytes - and - // xml last, before the line list - 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"); - } - + // svg is read off the parse xml already did: it is the more specific + // reading of the same bytes, and xml is the last resort before the line + // list try { ODR_VERBOSE(logger, "try open as xml"); - return std::make_unique(text); + auto xml_file = std::make_unique(text); + + if (!svg::is_svg_file(*xml_file)) { + ODR_VERBOSE(logger, "not an svg"); + // handed on as it is, so the parse is not repeated + return xml_file; + } + + ODR_VERBOSE(logger, "open as svg"); + return std::make_unique( + std::shared_ptr(std::move(xml_file))); } catch (...) { ODR_VERBOSE(logger, "failed to open as xml"); } diff --git a/src/odr/internal/pdf/pdf_afm.cpp b/src/odr/internal/pdf/pdf_afm.cpp index 3d6fd02b3..8178ea498 100644 --- a/src/odr/internal/pdf/pdf_afm.cpp +++ b/src/odr/internal/pdf/pdf_afm.cpp @@ -1,10 +1,10 @@ #include #include +#include #include #include -#include namespace odr::internal::pdf { @@ -35,11 +35,7 @@ std::string normalize_name(std::string_view base_font) { base_font.remove_prefix(7); } } - std::string result(base_font); - for (char &c : result) { - c = static_cast(std::tolower(static_cast(c))); - } - return result; + return util::string::to_lower(base_font); } bool contains(const std::string &haystack, const std::string_view needle) { diff --git a/src/odr/internal/svg/AGENTS.md b/src/odr/internal/svg/AGENTS.md new file mode 100644 index 000000000..85319fd4c --- /dev/null +++ b/src/odr/internal/svg/AGENTS.md @@ -0,0 +1,76 @@ +# AGENTS.md — `internal/svg` + +Read the root [`AGENTS.md`](../../../../AGENTS.md) first. This file covers what +svg does differently, and why. + +## An svg is xml + +`SvgFile` is an `abstract::ImageFile` over a `std::shared_ptr`. +The [xml module](../xml/AGENTS.md) parses, rejects what is not well formed, and +resolves the encoding from the declaration. What is left is one question — is +the root element `svg`? — answered by `is_svg_file` against +`XmlFile::root_name()`, which the xml parse already recorded, so detection costs +one parse and not two. + +pugixml does not process namespaces, so the root name arrives with whatever +prefix the document bound (``) and the prefix comes off by hand. + +`FileType::scalable_vector_graphics` is therefore no longer a label the generic +`common::ImageFile` will put on any bytes: `open` as an svg throws `NoSvgFile` +unless it is one. + +`is_svg_file` is a predicate rather than a throwing check because +`open_strategy` asks the question without wanting the file: an xml that is not +an svg is handed on as the `XmlFile` already built, so the parse is not +repeated. + +Every layer stays reachable from the one above — `xml_file()`, `text_file()`, +`file()`, plus `document()` and `text()` forwarded from the xml layer. A +downstream reader needs neither a second parse nor a second decode. Note that +`text_file()->text()` decodes with the encoding *detected over the bytes* while +`text()` uses the one the *declaration* names; for an xml document the latter is +the right answer. + +## It renders as an image, like every other image + +The markup goes into the page as `` — +`html/image_file.cpp`, the same path as png and jpeg, and the same path an svg +*inside* a document takes through `translate_image_src`. Nothing in this module +renders. + +That is a deliberate choice and worth keeping on record, because the obvious +improvement — inline the markup so the drawing scales to the viewport and its +text is selectable — costs more than it looks: + +- **Inside an `` a browser renders svg in secure static mode.** Scripts do + not run, external references are not fetched, animation is frozen. That is a + browser guarantee, free, and it holds for a file that came from wherever the + user got it. +- **Inlined, the markup is live**, and it is the only path in the library where + the input file authors the output DOM. Everywhere else we interpret the file + and emit our own markup — text goes through `escape_text`, images become an + `` we construct. Inlining means `