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 `