Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down Expand Up @@ -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 `<img>` 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
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions src/odr/exceptions.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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") {}

Expand Down
5 changes: 5 additions & 0 deletions src/odr/exceptions.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion src/odr/file.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions src/odr/internal/encoding/text_encoding_table.cpp
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
#include <odr/internal/encoding/text_encoding_table.hpp>

#include <odr/internal/util/string_util.hpp>

#include <algorithm>
#include <array>
#include <cctype>
#include <string>

namespace odr::internal::encoding {
Expand Down Expand Up @@ -127,8 +128,7 @@ std::string normalize(const std::string_view name) {
if (c == '-' || c == '_' || c == ' ') {
continue;
}
result.push_back(
static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
result.push_back(util::string::to_lower(c));
}
return result;
}
Expand Down
6 changes: 3 additions & 3 deletions src/odr/internal/file_type_table.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 2 additions & 4 deletions src/odr/internal/html/media_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
#include <odr/internal/html/frontend.hpp>
#include <odr/internal/html/html_service.hpp>
#include <odr/internal/html/html_writer.hpp>
#include <odr/internal/util/string_util.hpp>

#include <algorithm>
#include <cctype>
#include <filesystem>
#include <optional>
#include <span>
Expand All @@ -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<char>(std::tolower(static_cast<unsigned char>(c)));
});
extension = util::string::to_lower(extension);
if (std::ranges::find(extensions, extension) != extensions.end()) {
return extension;
}
Expand Down
7 changes: 3 additions & 4 deletions src/odr/internal/html/pdf_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -186,12 +186,11 @@ bool is_safe_uri(std::string_view uri) {
for (const char ch : uri) {
const auto c = static_cast<unsigned char>(ch);
if (ch == ':') {
std::ranges::transform(scheme, scheme.begin(), [](const char s) {
return static_cast<char>(std::tolower(static_cast<unsigned char>(s)));
});
static constexpr std::array<std::string_view, 6> 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
Expand Down
55 changes: 34 additions & 21 deletions src/odr/internal/open_strategy.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@
#include <odr/internal/oldms/oldms_file.hpp>
#include <odr/internal/ooxml/ooxml_file.hpp>
#include <odr/internal/pdf/pdf_file.hpp>
#include <odr/internal/svg/svg_util.hpp>
#include <odr/internal/svg/svg_file.hpp>
#include <odr/internal/svm/svm_file.hpp>
#include <odr/internal/xml/xml_file.hpp>
#include <odr/internal/zip/zip_file.hpp>

#include <algorithm>
#include <memory>

namespace odr::internal {

Expand Down Expand Up @@ -121,6 +122,18 @@ open_file_as(const std::shared_ptr<abstract::File> &file, const FileType as,
throw NoSvmFile();
}

if (as == FileType::scalable_vector_graphics) {
ODR_VERBOSE(logger, "open as svg");
try {
auto text = std::make_shared<text::TextFile>(file);
return std::make_unique<svg::SvgFile>(
std::make_shared<xml::XmlFile>(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);
Expand Down Expand Up @@ -305,14 +318,12 @@ open_strategy::list_file_types(const std::shared_ptr<abstract::File> &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<xml::XmlFile>(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");
Expand Down Expand Up @@ -427,20 +438,22 @@ open_strategy::open_file(const std::shared_ptr<abstract::File> &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<ImageFile>(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<xml::XmlFile>(text);
auto xml_file = std::make_unique<xml::XmlFile>(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<svg::SvgFile>(
std::shared_ptr<xml::XmlFile>(std::move(xml_file)));
} catch (...) {
ODR_VERBOSE(logger, "failed to open as xml");
}
Expand Down
8 changes: 2 additions & 6 deletions src/odr/internal/pdf/pdf_afm.cpp
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
#include <odr/internal/pdf/pdf_afm.hpp>

#include <odr/internal/pdf/pdf_afm_data.hpp>
#include <odr/internal/util/string_util.hpp>

#include <algorithm>
#include <array>
#include <cctype>

namespace odr::internal::pdf {

Expand Down Expand Up @@ -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<char>(std::tolower(static_cast<unsigned char>(c)));
}
return result;
return util::string::to_lower(base_font);
}

bool contains(const std::string &haystack, const std::string_view needle) {
Expand Down
76 changes: 76 additions & 0 deletions src/odr/internal/svg/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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<xml::XmlFile>`.
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 (`<s:svg>`) 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 `<img src="data:image/svg+xml;base64,…">` β€”
`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 `<img>` 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
`<img>` we construct. Inlining means `<script>` inside the svg *is* a script
tag, `onload=` fires, `<image href="https://…">` fetches, `<foreignObject>`
carries arbitrary html. Our output is displayed in a WebView with a bridge to
native (`docs/design/editing.md`).
- So inlining requires **re-implementing secure static mode by hand** β€” a scrub
of script and embedding elements, event handlers, references that leave the
document, SMIL aimed at any of those, and css that reaches outside β€” plus a
`script-src 'none'` policy on the page, plus stripping the prefix a document
bound to the svg namespace (the html parser enters foreign content on `svg`,
not on `s:svg`). It was written once and removed again; `git log` for
`svg_util.cpp` has it, tests included.
- And a scrub **interferes with the file**: valid, harmless things go β€” an
`<image href="chart.png">`, a webfont, half of SMIL. Someone who opens an
animated svg gets a still.

If scalable, selectable svg is wanted later, isolation beats modification:
serve the file as its own resource in a sandboxed iframe, where the browser
contains it and the file stays intact.

## The xml layer is not free

`XmlFile` holds the parsed tree for as long as the file is open, and pugixml's
dom is roughly twice the source. An svg costs that even though nothing reads
the tree after the root-name check β€” the price of detecting by reading rather
than by extension.
Loading
Loading