From b6ef73a4986aeb2dffcb8bc4520898a7932edd77 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Fri, 4 Sep 2026 09:33:30 +0200 Subject: [PATCH 1/3] feat(file): carry the name a file already has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `abstract::File` knew where a file was but not what it was called. The name was only reachable through `disk_path`, which a memory file does not have — so `file_type_by_name` could offer markdown for a `.md` on disk and nothing for the same bytes uploaded from a browser. `File::name()` asks the file itself: the file name for one on disk, the entry name for one inside a zip or cfb, and what `File::from_memory(data, name)` was given for one in memory, defaulting to none. Reading a file into memory takes the name with the bytes, so a `MemoryFile` built from a `DiskFile` still knows it is `about.odt`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wana5y5HtzoDq5yMkKvAMS --- CHANGELOG.md | 7 +++++ src/odr/file.cpp | 7 +++-- src/odr/file.hpp | 17 +++++++++--- src/odr/internal/abstract/file.hpp | 3 +++ src/odr/internal/cfb/cfb_util.cpp | 2 ++ src/odr/internal/common/file.cpp | 10 +++++-- src/odr/internal/common/file.hpp | 8 +++++- src/odr/internal/markdown/AGENTS.md | 6 ++--- src/odr/internal/markdown/PLAN.md | 4 +-- src/odr/internal/open_strategy.cpp | 6 ++--- src/odr/internal/zip/zip_util.cpp | 11 +++++--- test/src/file_test.cpp | 42 +++++++++++++++++++++++++++++ test/src/odr_test.cpp | 16 +++++++++++ 13 files changed, 120 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a41b5df19..105552d21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- New `File::name()`: what a file is called, without any directory — the file + name for one on disk, the entry name for one inside an archive, and what + `File::from_memory(data, name)` was given for one in memory, where the new + second argument defaults to no name. A named file in memory now gets the + same name-derived candidate a path does, so bytes called `notes.md` decode + as markdown. + ## v6.13.0 - 2026-09-03 - A `.md` opened by path decodes as markdown rather than as plain text, and diff --git a/src/odr/file.cpp b/src/odr/file.cpp index 6d2f28668..087a85ecd 100644 --- a/src/odr/file.cpp +++ b/src/odr/file.cpp @@ -36,8 +36,9 @@ File File::from_disk(const std::string &path) { return File(std::make_shared(path)); } -File File::from_memory(std::string data) { - return File(std::make_shared(std::move(data))); +File File::from_memory(std::string data, std::string name) { + return File( + std::make_shared(std::move(data), std::move(name))); } File::File() = default; @@ -59,6 +60,8 @@ FileLocation File::location() const noexcept { std::size_t File::size() const { return deref(m_impl).size(); } +std::string File::name() const { return deref(m_impl).name(); } + std::optional File::disk_path() const { if (const std::optional path = deref(m_impl).disk_path()) { return path->string(); diff --git a/src/odr/file.hpp b/src/odr/file.hpp index fd8ba11d2..4aa8c4494 100644 --- a/src/odr/file.hpp +++ b/src/odr/file.hpp @@ -313,11 +313,15 @@ class File final { public: /// @brief A file read from @p path on disk. [[nodiscard]] static File from_disk(const std::string &path); - /// @brief A file held in memory; @p data is its bytes, moved in. + /// @brief A file held in memory; @p data is its bytes, moved in, and + /// @p name what it is called, where the caller knows. /// /// The only way to hand the library a file that has no path — a download, a - /// browser upload, a decrypted payload. - [[nodiscard]] static File from_memory(std::string data); + /// browser upload, a decrypted payload. Such a file has no name of its own, + /// so pass the one it arrived under: @ref DecodedFile reads a type off it + /// that no content probe can find. + [[nodiscard]] static File from_memory(std::string data, + std::string name = {}); /// Constructs the null file — every accessor but @ref location throws @ref /// NullPointerError on it, so assign a real one before use. @@ -330,6 +334,13 @@ class File final { [[nodiscard]] FileLocation location() const noexcept; [[nodiscard]] std::size_t size() const; + /// @brief The file's own name, without any directory. + /// + /// The file name for one on disk, the entry name for one inside an archive, + /// and what @ref from_memory was given for one in memory — empty where + /// nobody said. + [[nodiscard]] std::string name() const; + [[nodiscard]] std::optional disk_path() const; [[nodiscard]] std::optional memory_data() const; diff --git a/src/odr/internal/abstract/file.hpp b/src/odr/internal/abstract/file.hpp index 2fc99e65d..c9876eb8a 100644 --- a/src/odr/internal/abstract/file.hpp +++ b/src/odr/internal/abstract/file.hpp @@ -25,6 +25,9 @@ class File { [[nodiscard]] virtual FileLocation location() const noexcept = 0; [[nodiscard]] virtual std::size_t size() const = 0; + /// The file's own name, without any directory — empty where there is none. + [[nodiscard]] virtual std::string name() const = 0; + [[nodiscard]] virtual std::optional disk_path() const = 0; /// The file's bytes if it is held in memory, else nullopt. [[nodiscard]] virtual std::optional memory_data() const = 0; diff --git a/src/odr/internal/cfb/cfb_util.cpp b/src/odr/internal/cfb/cfb_util.cpp index 00f6a2a87..c33d5667e 100644 --- a/src/odr/internal/cfb/cfb_util.cpp +++ b/src/odr/internal/cfb/cfb_util.cpp @@ -115,6 +115,8 @@ class FileInCfb final : public abstract::File { } [[nodiscard]] std::size_t size() const override { return m_entry.size; } + [[nodiscard]] std::string name() const override { return m_entry.get_name(); } + [[nodiscard]] std::optional disk_path() const override { return std::nullopt; } diff --git a/src/odr/internal/common/file.cpp b/src/odr/internal/common/file.cpp index 8168e05dd..f00f4bc57 100644 --- a/src/odr/internal/common/file.cpp +++ b/src/odr/internal/common/file.cpp @@ -26,6 +26,8 @@ std::size_t DiskFile::size() const { return std::filesystem::file_size(m_path.string()); } +std::string DiskFile::name() const { return m_path.basename(); } + std::optional DiskFile::disk_path() const { return m_path; } std::optional DiskFile::memory_data() const { @@ -36,9 +38,11 @@ std::unique_ptr DiskFile::stream() const { return std::make_unique(util::file::open(m_path.string())); } -MemoryFile::MemoryFile(std::string data) : m_data{std::move(data)} {} +MemoryFile::MemoryFile(std::string data, std::string name) + : m_data{std::move(data)}, m_name{std::move(name)} {} -MemoryFile::MemoryFile(const File &file) : m_data(file.size(), ' ') { +MemoryFile::MemoryFile(const File &file) + : m_data(file.size(), ' '), m_name{file.name()} { const auto istream = file.stream(); const auto size = static_cast(file.size()); istream->read(m_data.data(), size); @@ -53,6 +57,8 @@ FileLocation MemoryFile::location() const noexcept { std::size_t MemoryFile::size() const { return m_data.size(); } +std::string MemoryFile::name() const { return m_name; } + std::optional MemoryFile::disk_path() const { return std::nullopt; } std::optional MemoryFile::memory_data() const { diff --git a/src/odr/internal/common/file.hpp b/src/odr/internal/common/file.hpp index 86f5d493e..e3b9a14f1 100644 --- a/src/odr/internal/common/file.hpp +++ b/src/odr/internal/common/file.hpp @@ -22,6 +22,8 @@ class DiskFile : public abstract::File { [[nodiscard]] FileLocation location() const noexcept final; [[nodiscard]] std::size_t size() const final; + [[nodiscard]] std::string name() const final; + [[nodiscard]] std::optional disk_path() const final; [[nodiscard]] std::optional memory_data() const final; @@ -33,12 +35,15 @@ class DiskFile : public abstract::File { class MemoryFile final : public abstract::File { public: - explicit MemoryFile(std::string data); + explicit MemoryFile(std::string data, std::string name = {}); + /// Takes @p file's bytes into memory, name and all. explicit MemoryFile(const File &file); [[nodiscard]] FileLocation location() const noexcept override; [[nodiscard]] std::size_t size() const override; + [[nodiscard]] std::string name() const override; + [[nodiscard]] std::optional disk_path() const override; [[nodiscard]] std::optional memory_data() const override; @@ -48,6 +53,7 @@ class MemoryFile final : public abstract::File { private: std::string m_data; + std::string m_name; }; } // namespace odr::internal diff --git a/src/odr/internal/markdown/AGENTS.md b/src/odr/internal/markdown/AGENTS.md index 0275f835b..75069efa5 100644 --- a/src/odr/internal/markdown/AGENTS.md +++ b/src/odr/internal/markdown/AGENTS.md @@ -24,10 +24,10 @@ file with a `#` comment or an `*` bullet in it. Sniffing would steal `text_file` matches and be confidently wrong. So the file name does it instead: `open_strategy::file_type_by_name` reads the -extension off `File::disk_path` and offers markdown once the bytes have already +extension off `File::name` and offers markdown once the bytes have already decoded as text, ahead of the csv/json/xml probes. A name only ever *adds* a -candidate — a `.md` holding a zip is still a zip — and a file with no name on -disk has no hint, so `File::from_memory` still needs +candidate — a `.md` holding a zip is still a zip — and a file nobody named has +no hint, so bytes handed to `File::from_memory` without a name still need `DecodedFile(file, FileType::markdown)`. `NoMarkdownFile` exists only for the `as_markdown_file()` cast: every other diff --git a/src/odr/internal/markdown/PLAN.md b/src/odr/internal/markdown/PLAN.md index e6412aae4..8f460cf65 100644 --- a/src/odr/internal/markdown/PLAN.md +++ b/src/odr/internal/markdown/PLAN.md @@ -84,8 +84,8 @@ is every plain text file with a `#` comment or an `*` bullet in it. Sniffing would steal `text_file` matches and be confidently wrong, so `detect_by_content` stays **false** and markdown never joins the speculative chain. The extension offers it instead, once the bytes have decoded as text — see -[`AGENTS.md`](AGENTS.md) and #760. `File::from_memory` has no name, so it still -needs `DecodedFile(file, FileType::markdown)`. +[`AGENTS.md`](AGENTS.md) and #760. Bytes handed to `File::from_memory` without +a name still need `DecodedFile(file, FileType::markdown)`. **There is no `NoMarkdownFile`.** Every other format's exception exists because detection rejects. Nothing rejects here: md4c is total — any UTF-8 byte diff --git a/src/odr/internal/open_strategy.cpp b/src/odr/internal/open_strategy.cpp index 8a91b98a2..95ea1eb4b 100644 --- a/src/odr/internal/open_strategy.cpp +++ b/src/odr/internal/open_strategy.cpp @@ -56,11 +56,11 @@ template auto priority_comparator(const std::vector &priority) { /// (`detect_by_content == false`), or `unknown`. A name only ever adds a /// candidate the bytes already allow — it never claims them. FileType file_type_by_name(const abstract::File &file) { - const std::optional path = file.disk_path(); - if (!path.has_value()) { + const std::string name = file.name(); + if (name.empty()) { return FileType::unknown; } - const FileType type = file_type_by_file_extension(path->extension()); + const FileType type = file_type_by_file_extension(RelPath(name).extension()); return capabilities_by_file_type(type).detect_by_content ? FileType::unknown : type; } diff --git a/src/odr/internal/zip/zip_util.cpp b/src/odr/internal/zip/zip_util.cpp index 2eb524278..793df44aa 100644 --- a/src/odr/internal/zip/zip_util.cpp +++ b/src/odr/internal/zip/zip_util.cpp @@ -71,8 +71,9 @@ class FileInZipIstream final : public std::istream { class FileInZip final : public abstract::File { public: - FileInZip(std::shared_ptr archive, const std::uint32_t index) - : m_archive{std::move(archive)}, m_index{index} { + FileInZip(std::shared_ptr archive, const std::uint32_t index, + std::string name) + : m_archive{std::move(archive)}, m_index{index}, m_name{std::move(name)} { if (m_archive == nullptr) { throw NullPointerError("FileInZip: archive is nullptr"); } @@ -87,6 +88,8 @@ class FileInZip final : public abstract::File { return stat.m_uncomp_size; } + [[nodiscard]] std::string name() const override { return m_name; } + [[nodiscard]] std::optional disk_path() const override { return std::nullopt; } @@ -112,6 +115,7 @@ class FileInZip final : public abstract::File { private: std::shared_ptr m_archive; std::uint32_t m_index; + std::string m_name; }; } // namespace @@ -148,7 +152,8 @@ std::shared_ptr Archive::Entry::file() const { if (!is_file()) { return nullptr; } - return std::make_shared(m_archive->shared_from_this(), m_index); + return std::make_shared(m_archive->shared_from_this(), m_index, + path().basename()); } ReadSource::ReadSource(std::shared_ptr file) diff --git a/test/src/file_test.cpp b/test/src/file_test.cpp index c36e465d3..3a40d1f63 100644 --- a/test/src/file_test.cpp +++ b/test/src/file_test.cpp @@ -1,6 +1,8 @@ +#include #include #include #include +#include #include #include @@ -94,6 +96,46 @@ TEST(File, a_flat_document_and_a_package_answer_a_wrong_type_alike) { UnknownFileType); } +TEST(File, name_is_the_file_name_on_disk) { + EXPECT_EQ( + File::from_disk(TestData::test_file_path("odr-public/odt/about.odt")) + .name(), + "about.odt"); +} + +/// Bytes arrive without one, so the caller says what they were called - or +/// nobody does. +TEST(File, from_memory_is_unnamed_unless_told) { + EXPECT_EQ(File::from_memory("hello").name(), ""); + EXPECT_EQ(File::from_memory("hello", "greeting.txt").name(), "greeting.txt"); +} + +/// Reading a file into memory drops its path but not what it is called. +TEST(File, a_file_read_into_memory_keeps_its_name) { + const internal::DiskFile on_disk( + TestData::test_file_path("odr-public/odt/about.odt")); + + EXPECT_EQ(File(std::make_shared(on_disk)).name(), + "about.odt"); +} + +/// A file inside an archive is named by its entry, not by the archive. +TEST(File, an_archive_entry_is_named_by_its_entry) { + internal::zip::ZipArchive zip; + zip.insert_file(std::end(zip), internal::RelPath("docProps/preview.emf"), + std::make_shared("not really an emf")); + + std::stringstream out; + zip.save(out); + + const Filesystem filesystem = DecodedFile(File::from_memory(out.str())) + .as_archive_file() + .archive() + .as_filesystem(); + + EXPECT_EQ(filesystem.open("/docProps/preview.emf").name(), "preview.emf"); +} + TEST(File, from_memory_holds_its_bytes) { const File file = File::from_memory("hello"); diff --git a/test/src/odr_test.cpp b/test/src/odr_test.cpp index 726590dba..7d716ef15 100644 --- a/test/src/odr_test.cpp +++ b/test/src/odr_test.cpp @@ -96,6 +96,22 @@ TEST(odr, a_misnamed_file_is_what_its_bytes_are) { EXPECT_EQ(from_memory.file_type(), FileType::text_file); } +/// A caller who holds bytes rather than a path can still say what they were +/// called, and that name offers the same candidate a path would have. +TEST(odr, a_named_file_in_memory_is_offered_its_type) { + const auto logger = Logger::create_stdio("odr-test", LogLevel::verbose); + + const File file = File::from_memory("# heading\n", "notes.md"); + + const auto types = list_file_types(file, logger); + ASSERT_FALSE(types.empty()); + EXPECT_EQ(types.front(), FileType::text_file); + EXPECT_EQ(types.back(), FileType::markdown); + + EXPECT_EQ(DecodedFile(file, logger).file_type(), FileType::markdown); + EXPECT_EQ(mimetype(file, logger), "text/markdown"); +} + TEST(FileTypeTable, covers_every_file_type_exactly_once) { const std::vector expected = every_file_type(); const std::vector actual = all_file_types(); From 6bbe56fdf8387895da39a3edb48323c7ea6f3ca4 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Fri, 4 Sep 2026 09:51:52 +0200 Subject: [PATCH 2/3] feat(bindings): let every binding name a file too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The name a file carries is only useful where a caller can read it, and where a caller handing over bytes can set it. - python: `File.name()`, and `File.from_memory(data, name="")`. - jni: `File.name()`. - apple: `File.name`. - wasm: `open(bytes, {name})` and `detect(bytes, name)` take the browser `File.name` the bytes were dropped in under, and `Document.fileName` hands it back. The js wrapper defaults it, so no existing call changes. JNI and Apple never took bytes in the first place — their `File` is a path — so there is nothing to name there beyond what the path and the archive entry already say. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wana5y5HtzoDq5yMkKvAMS --- CHANGELOG.md | 6 ++++ apple/include/OdrCoreObjC/ODRFile.h | 3 ++ apple/src/ODRFile.mm | 4 +++ apple/tests/OdrCoreTests.swift | 15 +++++++++ jni/java/app/opendocument/core/File.java | 10 ++++++ jni/src/jni_file.cpp | 7 ++++ jni/tests/app/opendocument/core/FileTest.java | 20 +++++++++++ python/src/bind_file.cpp | 11 +++++-- python/tests/test_file.py | 24 ++++++++++++++ wasm/README.md | 6 +++- wasm/example/index.html | 1 + wasm/js/index.d.ts | 7 +++- wasm/js/index.js | 21 ++++++++---- wasm/src/wasm_file.cpp | 33 ++++++++++++++----- wasm/tests/smoke.test.mjs | 29 ++++++++++++++++ 15 files changed, 177 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 105552d21..dd45a4fa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ The release run heads these entries with the version and opens a fresh same name-derived candidate a path does, so bytes called `notes.md` decode as markdown. + Mirrored in every binding: `File.name()` in JNI and `pyodr`, `File.name` in + the Apple bindings, and `pyodr.File.from_memory(data, name)` for naming + bytes. The wasm package takes the name alongside the bytes — + `odr.open(bytes, { name: file.name })` and `odr.detect(bytes, name)` — and + hands it back as `Document.fileName`. + ## v6.13.0 - 2026-09-03 - A `.md` opened by path decodes as markdown rather than as plain text, and diff --git a/apple/include/OdrCoreObjC/ODRFile.h b/apple/include/OdrCoreObjC/ODRFile.h index 5516194c6..2f1e6eae3 100644 --- a/apple/include/OdrCoreObjC/ODRFile.h +++ b/apple/include/OdrCoreObjC/ODRFile.h @@ -190,6 +190,9 @@ NS_SWIFT_NAME(File) @property(nonatomic, readonly) ODRFileLocation location; @property(nonatomic, readonly) NSUInteger size; +/// What the file is called, without any directory - the file name for one on +/// disk, the entry name for one inside an archive. Empty where nobody named it. +@property(nonatomic, readonly, copy) NSString *name; /// The path, when the file is on disk. @property(nonatomic, readonly, nullable, copy) NSString *diskPath; diff --git a/apple/src/ODRFile.mm b/apple/src/ODRFile.mm index 2b52aaca4..134beb9d0 100644 --- a/apple/src/ODRFile.mm +++ b/apple/src/ODRFile.mm @@ -247,6 +247,10 @@ - (NSUInteger)size { NSUInteger{0}); } +- (NSString *)name { + return guarded_value([&] { return to_nsstring(_handle->name()); }, @""); +} + - (nullable NSString *)diskPath { return guarded_value( [&]() -> NSString * { diff --git a/apple/tests/OdrCoreTests.swift b/apple/tests/OdrCoreTests.swift index 0d8d947e9..da7530494 100644 --- a/apple/tests/OdrCoreTests.swift +++ b/apple/tests/OdrCoreTests.swift @@ -94,6 +94,21 @@ final class DecodeTests: XCTestCase { } } +final class FileNameTests: XCTestCase { + func testFileOnDiskIsNamedByItsPath() throws { + let path = try write("hello", as: "note.txt") + XCTAssertEqual(try File(path: path).name, "note.txt") + } + + /// A file inside a package is named by its entry, not by the package. + func testArchiveEntryIsNamedByItsEntry() throws { + let archive = try DecodedFile.decode(path: try Fixture.odt(), as: .zip) + .asArchiveFile().archive() + let entry = try archive.filesystem.open(path: "/content.xml") + XCTAssertEqual(entry.name, "content.xml") + } +} + final class ThumbnailTests: XCTestCase { func testDocumentFileCarriesItsThumbnail() throws { let file = try DecodedFile.decode(path: try Fixture.odt()).asDocumentFile() diff --git a/jni/java/app/opendocument/core/File.java b/jni/java/app/opendocument/core/File.java index 303db0e06..bfc651713 100644 --- a/jni/java/app/opendocument/core/File.java +++ b/jni/java/app/opendocument/core/File.java @@ -22,6 +22,14 @@ public long size() { return sizeNative(handle()); } + /** + * What the file is called, without any directory - the file name for one on disk, the entry name + * for one inside an archive. Empty where nobody named it. + */ + public String name() { + return nameNative(handle()); + } + /** Path on disk; {@code null} for in-memory files. */ public String diskPath() { return diskPathNative(handle()); @@ -51,6 +59,8 @@ long decode() { private native long sizeNative(long handle); + private native String nameNative(long handle); + private native String diskPathNative(long handle); private native byte[] readNative(long handle); diff --git a/jni/src/jni_file.cpp b/jni/src/jni_file.cpp index 1f5ca6b3c..35687622c 100644 --- a/jni/src/jni_file.cpp +++ b/jni/src/jni_file.cpp @@ -54,6 +54,13 @@ Java_app_opendocument_core_File_sizeNative(JNIEnv *env, jobject, jlong handle) { }); } +extern "C" JNIEXPORT jstring JNICALL +Java_app_opendocument_core_File_nameNative(JNIEnv *env, jobject, jlong handle) { + return guarded(env, [&] { + return to_jstring(env, from_handle(handle)->name()); + }); +} + extern "C" JNIEXPORT jstring JNICALL Java_app_opendocument_core_File_diskPathNative(JNIEnv *env, jobject, jlong handle) { diff --git a/jni/tests/app/opendocument/core/FileTest.java b/jni/tests/app/opendocument/core/FileTest.java index 878ce9723..e6eecbde0 100644 --- a/jni/tests/app/opendocument/core/FileTest.java +++ b/jni/tests/app/opendocument/core/FileTest.java @@ -71,6 +71,26 @@ void fileReadMatchesSize() throws IOException { } } + @Test + void fileName() throws IOException { + Path odt = TestFiles.odtFile(tempDir); + try (File file = new File(odt.toString())) { + assertEquals(TestFiles.ODT_RESOURCE, file.name()); + } + } + + /** A file inside a package is named by its entry, not by the package. */ + @Test + void archiveEntryName() throws IOException { + Path odt = TestFiles.odtFile(tempDir); + try (DecodedFile file = Odr.open(odt.toString(), FileType.ZIP)) { + Filesystem filesystem = file.asArchiveFile().archive().asFilesystem(); + try (File entry = filesystem.open("/content.xml")) { + assertEquals("content.xml", entry.name()); + } + } + } + @Test void decodeAnOpenFile() throws IOException { Path odt = TestFiles.odtFile(tempDir); diff --git a/python/src/bind_file.cpp b/python/src/bind_file.cpp index 57f7985bc..8b9a49d16 100644 --- a/python/src/bind_file.cpp +++ b/python/src/bind_file.cpp @@ -162,14 +162,19 @@ void odr_python::bind_file(py::module_ &m) { "A file read from `path` on disk.") .def_static( "from_memory", - [](const py::bytes &data) { - return odr::File::from_memory(std::string(data)); + [](const py::bytes &data, std::string name) { + return odr::File::from_memory(std::string(data), std::move(name)); }, - py::arg("data"), "A file held in memory; `data` is its bytes.") + py::arg("data"), py::arg("name") = std::string(), + "A file held in memory; `data` is its bytes and `name` what it is " + "called, where the caller knows.") .def("__bool__", [](const odr::File &file) { return file.impl() != nullptr; }) .def("location", &odr::File::location) .def("size", &odr::File::size) + .def("name", &odr::File::name, + "What the file is called, without any directory; empty where " + "nobody said.") .def("disk_path", &odr::File::disk_path) .def( "read", diff --git a/python/tests/test_file.py b/python/tests/test_file.py index 4527af632..36c369135 100644 --- a/python/tests/test_file.py +++ b/python/tests/test_file.py @@ -36,6 +36,30 @@ def test_file_from_memory_keeps_bytes_verbatim(): assert pyodr.File.from_memory(data).read() == data +def test_file_name(txt_path): + assert pyodr.File.from_disk(str(txt_path)).name() == "note.txt" + # bytes arrive unnamed unless the caller says otherwise + assert pyodr.File.from_memory(b"hello").name() == "" + + named = pyodr.File.from_memory(b"hello", "greeting.txt") + assert named.name() == "greeting.txt" + + +def test_file_name_of_an_archive_entry(odt_path): + file = pyodr.open(str(odt_path), pyodr.FileType.zip) + filesystem = file.as_archive_file().archive().as_filesystem() + assert filesystem.open("/META-INF/manifest.xml").name() == "manifest.xml" + + +def test_named_bytes_decode_as_markdown(): + # markdown has no signature, so only the name can offer it + named = pyodr.File.from_memory(b"# heading\n", "notes.md") + assert pyodr.DecodedFile(named).file_type() == pyodr.FileType.markdown + + unnamed = pyodr.File.from_memory(b"# heading\n") + assert pyodr.DecodedFile(unnamed).file_type() == pyodr.FileType.text_file + + def test_open_missing_file(tmp_path): with pytest.raises(FileNotFoundError): pyodr.open(str(tmp_path / "missing.txt")) diff --git a/wasm/README.md b/wasm/README.md index a6d7a4b31..0ee4ecdbf 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -34,7 +34,11 @@ that runs against them. Unzip it where your pages are served from and import import { Odr } from '@opendocument/odr-core'; const odr = await Odr.load(); -const doc = odr.open(new Uint8Array(await file.arrayBuffer())); +// `file.name` is worth passing: bytes carry no name, and for a format with no +// signature - markdown - it is the only thing that can offer the type. +const doc = odr.open(new Uint8Array(await file.arrayBuffer()), { + name: file.name, +}); try { const { html } = doc.render(0); iframe.src = URL.createObjectURL(new Blob([html], { type: 'text/html' })); diff --git a/wasm/example/index.html b/wasm/example/index.html index 29335b547..6c8c46eb3 100644 --- a/wasm/example/index.html +++ b/wasm/example/index.html @@ -78,6 +78,7 @@ doc?.close(); try { doc = odr.open(new Uint8Array(await file.arrayBuffer()), { + name: file.name, editable: true, }); } catch (e) { diff --git a/wasm/js/index.d.ts b/wasm/js/index.d.ts index 3104d1543..60dd30809 100644 --- a/wasm/js/index.d.ts +++ b/wasm/js/index.d.ts @@ -124,6 +124,9 @@ export interface HtmlConfig { export interface OpenOptions extends HtmlConfig { /** Force an interpretation instead of detecting one. */ fileType?: number; + /** What the upload was called. Bytes carry no name, and for a format with no + * signature — markdown — it is the only thing that can offer the type. */ + name?: string; } /** `name` is the C++ exception type: `WrongPassword`, `UnsupportedFileType`, … */ @@ -137,6 +140,8 @@ export declare class Document { * is in private fields and clones away to an empty object. */ readonly handle: number; readonly fileType: number; + /** What the bytes were called, as passed to `open`; empty where nothing was. */ + readonly fileName: string; meta(): Record; capabilities(): Capabilities; @@ -177,7 +182,7 @@ export declare class Odr { /** Every known type, enough to populate an `` or a PWA * manifest's file handlers without opening anything. */ fileTypes(): FileTypeInfo[]; - detect(bytes: Uint8Array): Detection; + detect(bytes: Uint8Array, name?: string): Detection; open(bytes: Uint8Array, options?: OpenOptions): Document; /** Applies to documents opened after the call. Null silences it again. */ setLogger(sink: ((level: number, message: string) => void) | null, level?: number): void; diff --git a/wasm/js/index.js b/wasm/js/index.js index bfca3432e..e3ee8c652 100644 --- a/wasm/js/index.js +++ b/wasm/js/index.js @@ -41,6 +41,11 @@ export class Document { return unwrap(this.#core.fileType(this.#handle)); } + // What the bytes were called, as passed to `open`; empty where nothing was. + get fileName() { + return unwrap(this.#core.fileName(this.#handle)); + } + meta() { return JSON.parse(unwrap(this.#core.meta(this.#handle))); } @@ -126,16 +131,20 @@ export class Odr { return this.#core.fileTypes(); } - detect(bytes) { - return unwrap(this.#core.detect(bytes)); + // `name` is what the upload was called. Bytes carry no name, and for a + // format with no signature — markdown — it is the only thing that can offer + // the type. + detect(bytes, name = '') { + return unwrap(this.#core.detect(bytes, name)); } - // `fileType` forces an interpretation instead of detecting one. - open(bytes, { fileType, ...config } = {}) { + // `fileType` forces an interpretation instead of detecting one; `name` is as + // in `detect`. + open(bytes, { fileType, name = '', ...config } = {}) { const handle = fileType === undefined - ? unwrap(this.#core.open(bytes, config)) - : unwrap(this.#core.openAs(bytes, fileType, config)); + ? unwrap(this.#core.open(bytes, name, config)) + : unwrap(this.#core.openAs(bytes, name, fileType, config)); return new Document(this.#core, handle); } diff --git a/wasm/src/wasm_file.cpp b/wasm/src/wasm_file.cpp index e89c19133..b1f151ddc 100644 --- a/wasm/src/wasm_file.cpp +++ b/wasm/src/wasm_file.cpp @@ -17,7 +17,13 @@ namespace { /// An embind `std::string` *parameter* takes a `Uint8Array` and copies the /// bytes verbatim, so this is binary-safe — unlike a `std::string` *return*, /// which goes through `UTF8ToString`. -File from_bytes(const std::string &bytes) { return File::from_memory(bytes); } +/// +/// @p name is what the caller's upload was called, empty where it knows no +/// name. Bytes carry no name of their own, and it is the only thing that can +/// offer a signature-less type — see `File::name`. +File from_bytes(const std::string &bytes, std::string name) { + return File::from_memory(bytes, std::move(name)); +} emscripten::val opened(DecodedFile file, const emscripten::val &config) { Session s{.file = std::move(file), @@ -29,9 +35,9 @@ emscripten::val opened(DecodedFile file, const emscripten::val &config) { return ok(emscripten::val(add_session(std::move(s)))); } -emscripten::val detect(const std::string &bytes) { +emscripten::val detect(const std::string &bytes, std::string name) { return guarded([&] { - const File file = from_bytes(bytes); + const File file = from_bytes(bytes, std::move(name)); const Logger &logger = default_logger(); emscripten::val types = emscripten::val::array(); @@ -46,17 +52,20 @@ emscripten::val detect(const std::string &bytes) { }); } -emscripten::val open(const std::string &bytes, const emscripten::val &config) { +emscripten::val open(const std::string &bytes, std::string name, + const emscripten::val &config) { return guarded([&] { - return opened(DecodedFile(from_bytes(bytes), default_logger()), config); + return opened( + DecodedFile(from_bytes(bytes, std::move(name)), default_logger()), + config); }); } -emscripten::val open_as(const std::string &bytes, const int as, - const emscripten::val &config) { +emscripten::val open_as(const std::string &bytes, std::string name, + const int as, const emscripten::val &config) { return guarded([&] { - return opened(DecodedFile(from_bytes(bytes), static_cast(as), - default_logger()), + return opened(DecodedFile(from_bytes(bytes, std::move(name)), + static_cast(as), default_logger()), config); }); } @@ -96,6 +105,11 @@ emscripten::val decrypt(const Handle handle, const std::string &password) { }); } +emscripten::val file_name(const Handle handle) { + return guarded( + [&] { return ok(emscripten::val(session(handle).file.file().name())); }); +} + emscripten::val file_type(const Handle handle) { return guarded([&] { return ok( @@ -128,6 +142,7 @@ EMSCRIPTEN_BINDINGS(odr_file) { &odr::wasm::is_password_encrypted); emscripten::function("decrypt", &odr::wasm::decrypt); emscripten::function("fileType", &odr::wasm::file_type); + emscripten::function("fileName", &odr::wasm::file_name); emscripten::function("close", &odr::wasm::close); emscripten::function("closeAll", &odr::wasm::close_all); } diff --git a/wasm/tests/smoke.test.mjs b/wasm/tests/smoke.test.mjs index a61075613..0eb323497 100644 --- a/wasm/tests/smoke.test.mjs +++ b/wasm/tests/smoke.test.mjs @@ -43,6 +43,35 @@ describe('smoke', () => { } }); + // Markdown has no signature, so only the name the upload arrived under can + // offer it - the browser's `File.name`, which bytes alone do not carry. + it('takes the name of an upload and reads a type off it', () => { + const markdown = new TextEncoder().encode('# heading\n'); + + assert.equal(odr.detect(markdown).fileTypes.at(-1), odr.enums.FileType.txt); + assert.equal( + odr.detect(markdown, 'notes.md').fileTypes.at(-1), + odr.enums.FileType.md, + ); + + const doc = odr.open(markdown, { name: 'notes.md' }); + try { + assert.equal(doc.fileType, odr.enums.FileType.md); + assert.equal(doc.fileName, 'notes.md'); + } finally { + doc.close(); + } + }); + + it('leaves an unnamed upload unnamed', () => { + const doc = odr.open(new TextEncoder().encode('plain text')); + try { + assert.equal(doc.fileName, ''); + } finally { + doc.close(); + } + }); + it('throws a typed error for a wrong password', () => { const doc = odr.open(fixture('encrypted.docx')); try { From 296bcd8acdcf605b9d74b91b0d8f3283bdaa751c Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 5 Sep 2026 09:20:24 +0200 Subject: [PATCH 3/3] docs(file): shorten the name comments and changelog entry Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ShzTYFSnbXc66ERBiTxhDi --- CHANGELOG.md | 18 ++++++------------ apple/include/OdrCoreObjC/ODRFile.h | 3 +-- jni/java/app/opendocument/core/File.java | 5 +---- python/src/bind_file.cpp | 7 +++---- src/odr/file.hpp | 14 ++++---------- src/odr/internal/abstract/file.hpp | 2 +- wasm/README.md | 3 +-- wasm/js/index.d.ts | 6 +++--- wasm/js/index.js | 6 ++---- wasm/src/wasm_file.cpp | 4 ---- 10 files changed, 22 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd45a4fa5..7981b7ea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,18 +16,12 @@ The release run heads these entries with the version and opens a fresh ## Unreleased -- New `File::name()`: what a file is called, without any directory — the file - name for one on disk, the entry name for one inside an archive, and what - `File::from_memory(data, name)` was given for one in memory, where the new - second argument defaults to no name. A named file in memory now gets the - same name-derived candidate a path does, so bytes called `notes.md` decode - as markdown. - - Mirrored in every binding: `File.name()` in JNI and `pyodr`, `File.name` in - the Apple bindings, and `pyodr.File.from_memory(data, name)` for naming - bytes. The wasm package takes the name alongside the bytes — - `odr.open(bytes, { name: file.name })` and `odr.detect(bytes, name)` — and - hands it back as `Document.fileName`. +- New `File::name()`: the file name on disk, the entry name inside an archive, + or the name `File::from_memory(data, name)` was given. A named in-memory file + gets the same name-derived type candidate a path does, so `notes.md` bytes + decode as markdown. Mirrored in every binding; wasm takes it as + `odr.open(bytes, { name })` and `odr.detect(bytes, name)` and exposes + `Document.fileName`. ## v6.13.0 - 2026-09-03 diff --git a/apple/include/OdrCoreObjC/ODRFile.h b/apple/include/OdrCoreObjC/ODRFile.h index 2f1e6eae3..137ad8bea 100644 --- a/apple/include/OdrCoreObjC/ODRFile.h +++ b/apple/include/OdrCoreObjC/ODRFile.h @@ -190,8 +190,7 @@ NS_SWIFT_NAME(File) @property(nonatomic, readonly) ODRFileLocation location; @property(nonatomic, readonly) NSUInteger size; -/// What the file is called, without any directory - the file name for one on -/// disk, the entry name for one inside an archive. Empty where nobody named it. +/// The file name, without any directory; empty where there is none. @property(nonatomic, readonly, copy) NSString *name; /// The path, when the file is on disk. @property(nonatomic, readonly, nullable, copy) NSString *diskPath; diff --git a/jni/java/app/opendocument/core/File.java b/jni/java/app/opendocument/core/File.java index bfc651713..7660e9fcd 100644 --- a/jni/java/app/opendocument/core/File.java +++ b/jni/java/app/opendocument/core/File.java @@ -22,10 +22,7 @@ public long size() { return sizeNative(handle()); } - /** - * What the file is called, without any directory - the file name for one on disk, the entry name - * for one inside an archive. Empty where nobody named it. - */ + /** The file name, without any directory; empty where there is none. */ public String name() { return nameNative(handle()); } diff --git a/python/src/bind_file.cpp b/python/src/bind_file.cpp index 8b9a49d16..1ee6a53a3 100644 --- a/python/src/bind_file.cpp +++ b/python/src/bind_file.cpp @@ -166,15 +166,14 @@ void odr_python::bind_file(py::module_ &m) { return odr::File::from_memory(std::string(data), std::move(name)); }, py::arg("data"), py::arg("name") = std::string(), - "A file held in memory; `data` is its bytes and `name` what it is " - "called, where the caller knows.") + "A file held in memory; `data` is its bytes, `name` what it is " + "called, if known.") .def("__bool__", [](const odr::File &file) { return file.impl() != nullptr; }) .def("location", &odr::File::location) .def("size", &odr::File::size) .def("name", &odr::File::name, - "What the file is called, without any directory; empty where " - "nobody said.") + "The file name, without any directory; empty where there is none.") .def("disk_path", &odr::File::disk_path) .def( "read", diff --git a/src/odr/file.hpp b/src/odr/file.hpp index 4aa8c4494..36bd8b092 100644 --- a/src/odr/file.hpp +++ b/src/odr/file.hpp @@ -313,13 +313,11 @@ class File final { public: /// @brief A file read from @p path on disk. [[nodiscard]] static File from_disk(const std::string &path); - /// @brief A file held in memory; @p data is its bytes, moved in, and - /// @p name what it is called, where the caller knows. + /// @brief A file held in memory; @p data is its bytes, moved in, @p name + /// what it is called, if known. /// /// The only way to hand the library a file that has no path — a download, a - /// browser upload, a decrypted payload. Such a file has no name of its own, - /// so pass the one it arrived under: @ref DecodedFile reads a type off it - /// that no content probe can find. + /// browser upload, a decrypted payload. [[nodiscard]] static File from_memory(std::string data, std::string name = {}); @@ -334,11 +332,7 @@ class File final { [[nodiscard]] FileLocation location() const noexcept; [[nodiscard]] std::size_t size() const; - /// @brief The file's own name, without any directory. - /// - /// The file name for one on disk, the entry name for one inside an archive, - /// and what @ref from_memory was given for one in memory — empty where - /// nobody said. + /// The file name, without any directory; empty where there is none. [[nodiscard]] std::string name() const; [[nodiscard]] std::optional disk_path() const; diff --git a/src/odr/internal/abstract/file.hpp b/src/odr/internal/abstract/file.hpp index c9876eb8a..2bd4167aa 100644 --- a/src/odr/internal/abstract/file.hpp +++ b/src/odr/internal/abstract/file.hpp @@ -25,7 +25,7 @@ class File { [[nodiscard]] virtual FileLocation location() const noexcept = 0; [[nodiscard]] virtual std::size_t size() const = 0; - /// The file's own name, without any directory — empty where there is none. + /// The file name, without any directory; empty where there is none. [[nodiscard]] virtual std::string name() const = 0; [[nodiscard]] virtual std::optional disk_path() const = 0; diff --git a/wasm/README.md b/wasm/README.md index 0ee4ecdbf..1f80c39df 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -34,8 +34,7 @@ that runs against them. Unzip it where your pages are served from and import import { Odr } from '@opendocument/odr-core'; const odr = await Odr.load(); -// `file.name` is worth passing: bytes carry no name, and for a format with no -// signature - markdown - it is the only thing that can offer the type. +// `name` lets a signature-less format like markdown be detected. const doc = odr.open(new Uint8Array(await file.arrayBuffer()), { name: file.name, }); diff --git a/wasm/js/index.d.ts b/wasm/js/index.d.ts index 60dd30809..c69ab08e7 100644 --- a/wasm/js/index.d.ts +++ b/wasm/js/index.d.ts @@ -124,8 +124,8 @@ export interface HtmlConfig { export interface OpenOptions extends HtmlConfig { /** Force an interpretation instead of detecting one. */ fileType?: number; - /** What the upload was called. Bytes carry no name, and for a format with no - * signature — markdown — it is the only thing that can offer the type. */ + /** The upload's name; lets a signature-less format like markdown be + * detected. */ name?: string; } @@ -140,7 +140,7 @@ export declare class Document { * is in private fields and clones away to an empty object. */ readonly handle: number; readonly fileType: number; - /** What the bytes were called, as passed to `open`; empty where nothing was. */ + /** The name passed to `open`; empty where none was. */ readonly fileName: string; meta(): Record; diff --git a/wasm/js/index.js b/wasm/js/index.js index e3ee8c652..2403cc59e 100644 --- a/wasm/js/index.js +++ b/wasm/js/index.js @@ -41,7 +41,7 @@ export class Document { return unwrap(this.#core.fileType(this.#handle)); } - // What the bytes were called, as passed to `open`; empty where nothing was. + // The name passed to `open`; empty where none was. get fileName() { return unwrap(this.#core.fileName(this.#handle)); } @@ -131,9 +131,7 @@ export class Odr { return this.#core.fileTypes(); } - // `name` is what the upload was called. Bytes carry no name, and for a - // format with no signature — markdown — it is the only thing that can offer - // the type. + // `name` lets a signature-less format like markdown be detected. detect(bytes, name = '') { return unwrap(this.#core.detect(bytes, name)); } diff --git a/wasm/src/wasm_file.cpp b/wasm/src/wasm_file.cpp index b1f151ddc..77bee6161 100644 --- a/wasm/src/wasm_file.cpp +++ b/wasm/src/wasm_file.cpp @@ -17,10 +17,6 @@ namespace { /// An embind `std::string` *parameter* takes a `Uint8Array` and copies the /// bytes verbatim, so this is binary-safe — unlike a `std::string` *return*, /// which goes through `UTF8ToString`. -/// -/// @p name is what the caller's upload was called, empty where it knows no -/// name. Bytes carry no name of their own, and it is the only thing that can -/// offer a signature-less type — see `File::name`. File from_bytes(const std::string &bytes, std::string name) { return File::from_memory(bytes, std::move(name)); }