diff --git a/CHANGELOG.md b/CHANGELOG.md index a41b5df19..7981b7ea4 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()`: 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 diff --git a/apple/include/OdrCoreObjC/ODRFile.h b/apple/include/OdrCoreObjC/ODRFile.h index 5516194c6..137ad8bea 100644 --- a/apple/include/OdrCoreObjC/ODRFile.h +++ b/apple/include/OdrCoreObjC/ODRFile.h @@ -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; 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..7660e9fcd 100644 --- a/jni/java/app/opendocument/core/File.java +++ b/jni/java/app/opendocument/core/File.java @@ -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()); @@ -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); 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..1ee6a53a3 100644 --- a/python/src/bind_file.cpp +++ b/python/src/bind_file.cpp @@ -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", 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/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..36bd8b092 100644 --- a/src/odr/file.hpp +++ b/src/odr/file.hpp @@ -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. @@ -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 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..2bd4167aa 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 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(); diff --git a/wasm/README.md b/wasm/README.md index a6d7a4b31..1f80c39df 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -34,7 +34,10 @@ 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())); +// `name` lets a signature-less format like markdown be detected. +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..c69ab08e7 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; + /** The upload's name; lets a signature-less format like markdown be + * detected. */ + 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; + /** The name passed to `open`; empty where none 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..2403cc59e 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)); } + // The name passed to `open`; empty where none was. + get fileName() { + return unwrap(this.#core.fileName(this.#handle)); + } + meta() { return JSON.parse(unwrap(this.#core.meta(this.#handle))); } @@ -126,16 +131,18 @@ export class Odr { return this.#core.fileTypes(); } - detect(bytes) { - return unwrap(this.#core.detect(bytes)); + // `name` lets a signature-less format like markdown be detected. + 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..77bee6161 100644 --- a/wasm/src/wasm_file.cpp +++ b/wasm/src/wasm_file.cpp @@ -17,7 +17,9 @@ 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); } +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 +31,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 +48,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 +101,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 +138,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 {