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

## Unreleased

- New `DocumentFile::thumbnail()`, the preview the package carries or
`nullopt`, mirrored in the JNI, Apple and Python bindings. Closes #21.

- **Breaking** (Swift only): `HtmlConfig`'s optional settings are Swift
optionals of the real type rather than `NSNumber`/`NSValue` boxes β€”
`spreadsheetLimit` is a `TableDimensions?`, `initialZoom` a `Double?`, and so
Expand Down
3 changes: 3 additions & 0 deletions apple/include/OdrCoreObjC/ODRFile.h
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,9 @@ NS_SWIFT_NAME(DocumentFile)
- (nullable instancetype)initWithPath:(NSString *)path error:(NSError **)error;

@property(nonatomic, readonly) ODRDocumentType documentType;
/// The preview image the package carries, `nil` where it carries none or is
/// still encrypted. Never rendered by us.
@property(nonatomic, readonly, nullable) ODRFile *thumbnail;
- (nullable ODRDocumentFile *)decryptWithPassword:(NSString *)password
error:(NSError **)error;
/// Decodes the document. The expensive step.
Expand Down
11 changes: 11 additions & 0 deletions apple/src/ODRFile.mm
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,17 @@ - (ODRDocumentType)documentType {
ODRDocumentTypeUnknown);
}

- (nullable ODRFile *)thumbnail {
return guarded_value(
[&]() -> ODRFile * {
const std::optional<odr::File> thumbnail =
self.documentHandle.thumbnail();
return thumbnail.has_value() ? [ODRFile fileWithHandle:*thumbnail]
: nil;
},
nil);
}

- (nullable ODRDocumentFile *)decryptWithPassword:(NSString *)password
error:(NSError **)error {
return guarded(error, [&]() -> ODRDocumentFile * {
Expand Down
8 changes: 8 additions & 0 deletions apple/tests/OdrCoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ final class DecodeTests: XCTestCase {
}
}

final class ThumbnailTests: XCTestCase {
func testDocumentFileCarriesItsThumbnail() throws {
let file = try DecodedFile.decode(path: try Fixture.odt()).asDocumentFile()
let thumbnail = try XCTUnwrap(file.thumbnail)
XCTAssertGreaterThan(try thumbnail.data().count, 0)
}
}

final class HtmlTests: XCTestCase {
private func service() throws -> HtmlService {
let file = try DecodedFile.decode(path: try Fixture.odt())
Expand Down
11 changes: 11 additions & 0 deletions jni/java/app/opendocument/core/DocumentFile.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ public DocumentFile decrypt(String password) {
return new DocumentFile(decryptDocumentFileNative(handle(), password));
}

/**
* The preview image the package carries, or {@code null} where it carries
* none or is still encrypted. Never rendered by us.
*/
public File thumbnail() {
long handle = thumbnailNative(handle());
return handle == 0 ? null : new File(handle);
}

public Document document() {
return new Document(documentNative(handle()));
}
Expand All @@ -42,5 +51,7 @@ public Document document() {

private native long decryptDocumentFileNative(long handle, String password);

private native long thumbnailNative(long handle);

private native long documentNative(long handle);
}
12 changes: 12 additions & 0 deletions jni/src/jni_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <odr/filesystem.hpp>
#include <odr/odr.hpp>

#include <optional>
#include <sstream>

namespace {
Expand Down Expand Up @@ -365,6 +366,17 @@ Java_app_opendocument_core_DocumentFile_documentTypeNative(JNIEnv *env, jobject,
});
}

/// 0 where there is no thumbnail; `DocumentFile.thumbnail` maps that to null.
extern "C" JNIEXPORT jlong JNICALL
Java_app_opendocument_core_DocumentFile_thumbnailNative(JNIEnv *env, jobject,
jlong handle) {
return guarded(env, [&]() -> jlong {
const std::optional<odr::File> thumbnail =
decoded(handle).as_document_file().thumbnail();
return thumbnail.has_value() ? make_handle(*thumbnail) : 0;
});
}

extern "C" JNIEXPORT jlong JNICALL
Java_app_opendocument_core_DocumentFile_decryptDocumentFileNative(
JNIEnv *env, jobject, jlong handle, jstring password) {
Expand Down
11 changes: 11 additions & 0 deletions jni/tests/app/opendocument/core/FileTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ void openOdt() throws IOException {
}
}

@Test
void thumbnail() throws IOException {
Path odt = TestFiles.odtFile(tempDir);
try (DecodedFile file = Odr.open(odt.toString())) {
try (File thumbnail = file.asDocumentFile().thumbnail()) {
assertNotNull(thumbnail);
assertTrue(thumbnail.read().length > 0);
}
}
}

@Test
void openText() throws IOException {
Path txt = TestFiles.txtFile(tempDir);
Expand Down
3 changes: 3 additions & 0 deletions python/src/bind_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,9 @@ void odr_python::bind_file(py::module_ &m) {
.def("document_type", &odr::DocumentFile::document_type)
.def("decrypt", &odr::DocumentFile::decrypt, py::arg("password"),
py::call_guard<py::gil_scoped_release>())
.def("thumbnail", &odr::DocumentFile::thumbnail,
"The preview image the package carries, or `None` where it "
"carries none or is still encrypted. Never rendered by us.")
.def("document", &odr::DocumentFile::document);

py::class_<odr::PdfFile, odr::DecodedFile>(m, "PdfFile")
Expand Down
19 changes: 19 additions & 0 deletions python/tests/test_file.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import zipfile

import pytest

import pyodr
Expand Down Expand Up @@ -158,6 +160,23 @@ def test_document_file_from_disk_and_from_memory(odt_path):
)


def test_document_file_thumbnail(tmp_path, odt_path):
# The minimal odt the fixture builds carries none.
assert pyodr.DocumentFile.from_disk(str(odt_path)).thumbnail() is None

with_thumbnail = tmp_path / "with-thumbnail.odt"
with zipfile.ZipFile(odt_path) as source:
entries = {name: source.read(name) for name in source.namelist()}
entries["Thumbnails/thumbnail.png"] = b"not really a png"
with zipfile.ZipFile(with_thumbnail, "w") as archive:
for name, content in entries.items():
archive.writestr(name, content)

thumbnail = pyodr.DocumentFile.from_disk(str(with_thumbnail)).thumbnail()
assert thumbnail is not None
assert thumbnail.read() == b"not really a png"


def test_document_file_from_memory_rejects_a_non_document():
with pytest.raises(pyodr.Error):
pyodr.DocumentFile.from_memory(b"not a document")
Expand Down
8 changes: 8 additions & 0 deletions src/odr/file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,14 @@ DocumentFile DocumentFile::decrypt(const std::string &password) const {
return DecodedFile::decrypt(password).as_document_file();
}

std::optional<File> DocumentFile::thumbnail() const {
if (const std::shared_ptr<internal::abstract::File> thumbnail =
m_impl->thumbnail()) {
return File(thumbnail);
}
return {};
}

Document DocumentFile::document() const { return Document(m_impl->document()); }

std::shared_ptr<internal::abstract::DocumentFile> DocumentFile::impl() const {
Expand Down
7 changes: 7 additions & 0 deletions src/odr/file.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,13 @@ class DocumentFile final : public DecodedFile {

[[nodiscard]] DocumentFile decrypt(const std::string &password) const;

/// @brief The preview image the package carries, if any.
///
/// What the producing application stored β€” never rendered by us, so it is
/// only as current as the last save. Empty for a package that carries none
/// or is still encrypted.
[[nodiscard]] std::optional<File> thumbnail() const;

[[nodiscard]] Document document() const;

[[nodiscard]] std::shared_ptr<internal::abstract::DocumentFile> impl() 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 @@ -93,6 +93,9 @@ class DocumentFile : public DecodedFile {

[[nodiscard]] virtual DocumentType document_type() const = 0;

/// The preview the package carries, `nullptr` where there is none.
[[nodiscard]] virtual std::shared_ptr<File> thumbnail() const { return {}; }

[[nodiscard]] virtual std::shared_ptr<Document> document() const = 0;
};

Expand Down
10 changes: 10 additions & 0 deletions src/odr/internal/odf/odf_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ DocumentType OpenDocumentFile::document_type() const {
return m_file_meta.document_type;
}

std::shared_ptr<abstract::File> OpenDocumentFile::thumbnail() const {
// [OpenDocument] 3.9. Encrypted alongside everything else.
static const AbsPath path("/Thumbnails/thumbnail.png");
if (m_encryption_state == EncryptionState::encrypted ||
!m_filesystem->is_file(path)) {
return {};
}
return m_filesystem->open(path);
}

bool OpenDocumentFile::password_encrypted() const noexcept {
return m_file_meta.password_encrypted;
}
Expand Down
2 changes: 2 additions & 0 deletions src/odr/internal/odf/odf_file.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ class OpenDocumentFile final : public virtual abstract::DocumentFile {

[[nodiscard]] DocumentType document_type() const override;

[[nodiscard]] std::shared_ptr<abstract::File> thumbnail() const override;

[[nodiscard]] bool password_encrypted() const noexcept override;
[[nodiscard]] EncryptionState encryption_state() const noexcept override;
[[nodiscard]] std::shared_ptr<DecodedFile>
Expand Down
15 changes: 15 additions & 0 deletions src/odr/internal/ooxml/ooxml_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <odr/internal/common/file.hpp>
#include <odr/internal/ooxml/ooxml_crypto.hpp>
#include <odr/internal/ooxml/ooxml_meta.hpp>
#include <odr/internal/ooxml/ooxml_util.hpp>
#include <odr/internal/ooxml/presentation/ooxml_presentation_document.hpp>
#include <odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.hpp>
#include <odr/internal/ooxml/text/ooxml_text_document.hpp>
Expand Down Expand Up @@ -43,6 +44,20 @@ DocumentType OfficeOpenXmlFile::document_type() const {
return m_file_meta.document_type;
}

std::shared_ptr<abstract::File> OfficeOpenXmlFile::thumbnail() const {
// [ECMA-376] 15.2.10. Named by relationship, not by convention: `.jpeg`,
// `.png`, `.emf` and `.wmf` all occur.
if (m_encryption_state == EncryptionState::encrypted) {
return {};
}
const std::optional<AbsPath> path =
parse_relationship_target(*m_files, AbsPath("/"), "thumbnail");
if (!path.has_value() || !m_files->is_file(*path)) {
return {};
}
return m_files->open(*path);
}

bool OfficeOpenXmlFile::password_encrypted() const noexcept {
return m_file_meta.password_encrypted;
}
Expand Down
2 changes: 2 additions & 0 deletions src/odr/internal/ooxml/ooxml_file.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ class OfficeOpenXmlFile final : public abstract::DocumentFile {

[[nodiscard]] DocumentType document_type() const override;

[[nodiscard]] std::shared_ptr<abstract::File> thumbnail() const override;

[[nodiscard]] bool password_encrypted() const noexcept override;
[[nodiscard]] EncryptionState encryption_state() const noexcept override;
[[nodiscard]] std::shared_ptr<DecodedFile>
Expand Down
9 changes: 8 additions & 1 deletion src/odr/internal/ooxml/ooxml_util.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,13 @@ ooxml::parse_relationships(const pugi::xml_document &relations) {

namespace {

/// The root is not a part, so it has no parent to hang `_rels` off.
bool is_package_root(const AbsPath &path) { return path == AbsPath("/"); }

AbsPath relationships_path(const AbsPath &path) {
if (is_package_root(path)) {
return AbsPath("/_rels/.rels");
}
return path.parent()
.join(RelPath("_rels"))
.join(RelPath(path.basename() + ".rels"));
Expand All @@ -357,7 +363,8 @@ std::optional<AbsPath> resolve_relationship_target(const AbsPath &path,
return AbsPath(target);
}
try {
return path.parent().join(RelPath(target));
const AbsPath base = is_package_root(path) ? path : path.parent();
return base.join(RelPath(target));
} catch (const std::invalid_argument &) {
return {};
}
Expand Down
53 changes: 53 additions & 0 deletions test/src/file_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@

#include <odr/internal/common/file.hpp>
#include <odr/internal/util/file_util.hpp>
#include <odr/internal/util/stream_util.hpp>
#include <odr/internal/zip/zip_archive.hpp>

#include <test_util.hpp>

#include <memory>
#include <optional>
#include <sstream>
#include <string>
#include <tuple>

Expand Down Expand Up @@ -175,6 +179,55 @@ TEST(DocumentFile, from_memory_throws_on_a_non_document) {
NoDocumentFile);
}

TEST(DocumentFile, odf_thumbnail) {
const DocumentFile file(
TestData::test_file_path("odr-public/ods/file_example_ODS_10.ods"));

const std::optional<File> thumbnail = file.thumbnail();
ASSERT_TRUE(thumbnail.has_value());
EXPECT_LT(0, thumbnail->size());
EXPECT_EQ(DecodedFile(*thumbnail).file_type(),
FileType::portable_network_graphics);
}

TEST(DocumentFile, thumbnail_is_absent_where_the_package_has_none) {
const DocumentFile file(
TestData::test_file_path("odr-public/docx/style-various-1.docx"));

EXPECT_FALSE(file.thumbnail().has_value());
}

/// The name is deliberately unconventional: only reading the relationship
/// finds it.
TEST(DocumentFile, ooxml_thumbnail_is_named_by_the_package_relationship) {
internal::zip::ZipArchive zip;
zip.insert_file(
std::end(zip), internal::RelPath("_rels/.rels"),
std::make_shared<internal::MemoryFile>(
R"(<?xml version="1.0"?>)"
R"(<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">)"
R"(<Relationship Id="rId1" Target="docProps/preview.emf" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail"/>)"
R"(</Relationships>)"));
zip.insert_file(std::end(zip), internal::RelPath("word/document.xml"),
std::make_shared<internal::MemoryFile>(
R"(<?xml version="1.0"?><w:document )"
R"(xmlns:w="http://schemas.openxmlformats.org/)"
R"(wordprocessingml/2006/main"><w:body/></w:document>)"));
zip.insert_file(std::end(zip), internal::RelPath("docProps/preview.emf"),
std::make_shared<internal::MemoryFile>("not really an emf"));

std::stringstream out;
zip.save(out);

const DocumentFile file = DocumentFile::from_memory(out.str());
ASSERT_EQ(file.file_type(), FileType::office_open_xml_document);

const std::optional<File> thumbnail = file.thumbnail();
ASSERT_TRUE(thumbnail.has_value());
EXPECT_EQ(internal::util::stream::read(*thumbnail->stream()),
"not really an emf");
}

TEST(DecodedFile, wpd) {
const auto logger = Logger::create_stdio("odr-test", LogLevel::verbose);

Expand Down
Loading