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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ The release run heads these entries with the version and opens a fresh

## Unreleased

- 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

- A `.md` opened by path decodes as markdown rather than as plain text, and
Expand Down
2 changes: 2 additions & 0 deletions apple/include/OdrCoreObjC/ODRFile.h
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ NS_SWIFT_NAME(File)

@property(nonatomic, readonly) ODRFileLocation location;
@property(nonatomic, readonly) NSUInteger size;
/// 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;

Expand Down
4 changes: 4 additions & 0 deletions apple/src/ODRFile.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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 * {
Expand Down
15 changes: 15 additions & 0 deletions apple/tests/OdrCoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 7 additions & 0 deletions jni/java/app/opendocument/core/File.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ public long size() {
return sizeNative(handle());
}

/** The file name, without any directory; empty where there is none. */
public String name() {
return nameNative(handle());
}

/** Path on disk; {@code null} for in-memory files. */
public String diskPath() {
return diskPathNative(handle());
Expand Down Expand Up @@ -51,6 +56,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);
Expand Down
7 changes: 7 additions & 0 deletions jni/src/jni_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<odr::File>(handle)->name());
});
}

extern "C" JNIEXPORT jstring JNICALL
Java_app_opendocument_core_File_diskPathNative(JNIEnv *env, jobject,
jlong handle) {
Expand Down
20 changes: 20 additions & 0 deletions jni/tests/app/opendocument/core/FileTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 7 additions & 3 deletions python/src/bind_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -162,14 +162,18 @@ 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, `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,
"The file name, without any directory; empty where there is none.")
.def("disk_path", &odr::File::disk_path)
.def(
"read",
Expand Down
24 changes: 24 additions & 0 deletions python/tests/test_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
7 changes: 5 additions & 2 deletions src/odr/file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ File File::from_disk(const std::string &path) {
return File(std::make_shared<internal::DiskFile>(path));
}

File File::from_memory(std::string data) {
return File(std::make_shared<internal::MemoryFile>(std::move(data)));
File File::from_memory(std::string data, std::string name) {
return File(
std::make_shared<internal::MemoryFile>(std::move(data), std::move(name)));
}

File::File() = default;
Expand All @@ -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<std::string> File::disk_path() const {
if (const std::optional<internal::AbsPath> path = deref(m_impl).disk_path()) {
return path->string();
Expand Down
9 changes: 7 additions & 2 deletions src/odr/file.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -313,11 +313,13 @@ 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, @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.
[[nodiscard]] static File from_memory(std::string data);
[[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.
Expand All @@ -330,6 +332,9 @@ class File final {
[[nodiscard]] FileLocation location() const noexcept;
[[nodiscard]] std::size_t size() const;

/// The file name, without any directory; empty where there is none.
[[nodiscard]] std::string name() const;

[[nodiscard]] std::optional<std::string> disk_path() const;
[[nodiscard]] std::optional<std::string_view> memory_data() const;

Expand Down
3 changes: 3 additions & 0 deletions src/odr/internal/abstract/file.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ class File {
[[nodiscard]] virtual FileLocation location() const noexcept = 0;
[[nodiscard]] virtual std::size_t size() const = 0;

/// The file name, without any directory; empty where there is none.
[[nodiscard]] virtual std::string name() const = 0;

[[nodiscard]] virtual std::optional<AbsPath> disk_path() const = 0;
/// The file's bytes if it is held in memory, else nullopt.
[[nodiscard]] virtual std::optional<std::string_view> memory_data() const = 0;
Expand Down
2 changes: 2 additions & 0 deletions src/odr/internal/cfb/cfb_util.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<AbsPath> disk_path() const override {
return std::nullopt;
}
Expand Down
10 changes: 8 additions & 2 deletions src/odr/internal/common/file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<AbsPath> DiskFile::disk_path() const { return m_path; }

std::optional<std::string_view> DiskFile::memory_data() const {
Expand All @@ -36,9 +38,11 @@ std::unique_ptr<std::istream> DiskFile::stream() const {
return std::make_unique<std::ifstream>(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<std::int64_t>(file.size());
istream->read(m_data.data(), size);
Expand All @@ -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<AbsPath> MemoryFile::disk_path() const { return std::nullopt; }

std::optional<std::string_view> MemoryFile::memory_data() const {
Expand Down
8 changes: 7 additions & 1 deletion src/odr/internal/common/file.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<AbsPath> disk_path() const final;
[[nodiscard]] std::optional<std::string_view> memory_data() const final;

Expand All @@ -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<AbsPath> disk_path() const override;
[[nodiscard]] std::optional<std::string_view> memory_data() const override;

Expand All @@ -48,6 +53,7 @@ class MemoryFile final : public abstract::File {

private:
std::string m_data;
std::string m_name;
};

} // namespace odr::internal
6 changes: 3 additions & 3 deletions src/odr/internal/markdown/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/odr/internal/markdown/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/odr/internal/open_strategy.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,11 @@ template <typename T> auto priority_comparator(const std::vector<T> &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<AbsPath> 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;
}
Expand Down
11 changes: 8 additions & 3 deletions src/odr/internal/zip/zip_util.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,9 @@ class FileInZipIstream final : public std::istream {

class FileInZip final : public abstract::File {
public:
FileInZip(std::shared_ptr<const Archive> archive, const std::uint32_t index)
: m_archive{std::move(archive)}, m_index{index} {
FileInZip(std::shared_ptr<const Archive> 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");
}
Expand All @@ -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<AbsPath> disk_path() const override {
return std::nullopt;
}
Expand All @@ -112,6 +115,7 @@ class FileInZip final : public abstract::File {
private:
std::shared_ptr<const Archive> m_archive;
std::uint32_t m_index;
std::string m_name;
};

} // namespace
Expand Down Expand Up @@ -148,7 +152,8 @@ std::shared_ptr<abstract::File> Archive::Entry::file() const {
if (!is_file()) {
return nullptr;
}
return std::make_shared<FileInZip>(m_archive->shared_from_this(), m_index);
return std::make_shared<FileInZip>(m_archive->shared_from_this(), m_index,
path().basename());
}

ReadSource::ReadSource(std::shared_ptr<abstract::File> file)
Expand Down
Loading
Loading