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

## Unreleased

- `PdfFile::annotate` writes markup annotations — highlight, underline,
strike-out, squiggly and freehand ink — into a pdf as an incremental update,
so the source bytes are left as they are and any viewer reads them as
ordinary pdf annotations. `FileTypeCapabilities` gains an `annotate` flag.

- **Breaking**: `html::edit` becomes `Document::edit`, in every binding —
java's `Html.edit(document, diff)` becomes `document.edit(diff)`, and so on.
`Text::set_content` is unchanged.
Expand Down
5 changes: 5 additions & 0 deletions src/odr/file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,11 @@ PdfFile PdfFile::decrypt(const std::string &password) const {
return DecodedFile::decrypt(password).as_pdf_file();
}

void PdfFile::annotate(const std::string_view annotations, std::ostream &out,
const Logger &logger) const {
m_impl->annotate(annotations, out, logger);
}

std::shared_ptr<internal::abstract::PdfFile> PdfFile::impl() const {
return m_impl;
}
Expand Down
16 changes: 15 additions & 1 deletion src/odr/file.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,8 @@ struct FileTypeCapabilities final {
bool color_scheme{}; ///< the view honors @ref HtmlConfig::color_scheme
bool edit{}; ///< @ref Document::is_editable can be `true`
bool save{}; ///< @ref Document::save is supported
bool encrypt{}; ///< @ref Document::save with a password is supported
bool encrypt{}; ///< @ref Document::save with a password is supported
bool annotate{}; ///< @ref PdfFile::annotate is supported
};

/// Collection of encryption states.
Expand Down Expand Up @@ -528,6 +529,19 @@ class PdfFile final : public DecodedFile {

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

/// @brief Applies markup @p annotations, writing the annotated pdf to
/// @p out.
///
/// The wire format our browser-side annotator produces: highlight, underline,
/// strike-out, squiggly and freehand ink, placed in pdf user space. The
/// source is copied and the annotations appended, so nothing else about the
/// file changes.
/// @throws std::invalid_argument if @p annotations is malformed.
/// @throws std::runtime_error if the file cannot take them — its
/// cross-reference table was recovered, or it is encrypted.
void annotate(std::string_view annotations, std::ostream &out,
const Logger &logger = Logger::null()) const;

[[nodiscard]] std::shared_ptr<internal::abstract::PdfFile> impl() const;

private:
Expand Down
4 changes: 4 additions & 0 deletions src/odr/internal/abstract/file.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ class PdfFile : public DecodedFile {
[[nodiscard]] std::string_view mimetype() const noexcept final {
return "application/pdf";
}

/// Apply `annotations` and write the result to `out`.
virtual void annotate(std::string_view annotations, std::ostream &out,
const Logger &logger) const = 0;
};

class FontFile : public DecodedFile {
Expand Down
3 changes: 2 additions & 1 deletion src/odr/internal/file_type_table.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,8 @@ constexpr std::array table{
{.detect_by_content = true,
.open = true,
.decrypt = true,
.translate_html = true}},
.translate_html = true,
.annotate = true}},

Row{FileType::text_file,
"txt"sv,
Expand Down
138 changes: 138 additions & 0 deletions src/odr/internal/pdf/pdf_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,25 @@
#include <odr/file.hpp>

#include <odr/internal/abstract/file.hpp>
#include <odr/internal/pdf/pdf_annotation.hpp>
#include <odr/internal/pdf/pdf_document.hpp>
#include <odr/internal/pdf/pdf_document_element.hpp>
#include <odr/internal/pdf/pdf_document_parser.hpp>
#include <odr/internal/pdf/pdf_encoding.hpp>
#include <odr/internal/pdf/pdf_encryption.hpp>
#include <odr/internal/pdf/pdf_object.hpp>
#include <odr/internal/pdf/pdf_writer.hpp>

#include <array>
#include <cstdint>
#include <map>
#include <memory>
#include <optional>
#include <stdexcept>
#include <string>
#include <vector>

#include <nlohmann/json.hpp>

namespace odr::internal::pdf {

Expand Down Expand Up @@ -175,4 +185,132 @@ DocumentParser PdfFile::create_parser(const Logger &logger) const {
return DocumentParser(m_file->stream(), m_decryptor, logger);
}

namespace {

/// A required member of `value`, refused rather than defaulted.
const nlohmann::json &at(const nlohmann::json &value, const char *key) {
const auto it = value.find(key);
if (it == value.end()) {
throw std::invalid_argument(std::string("annotation is missing /") + key);
}
return *it;
}

std::array<double, 3> read_color(const nlohmann::json &value) {
if (!value.is_array() || value.size() != 3) {
throw std::invalid_argument("color is not three components");
}
return {value[0].get<double>(), value[1].get<double>(),
value[2].get<double>()};
}

AnnotationCommon read_common(const nlohmann::json &value) {
AnnotationCommon result;
result.color = read_color(at(value, "color"));
result.opacity = value.value("opacity", 1.0);
result.author = value.value("author", std::string());
result.contents = value.value("contents", std::string());
return result;
}

TextMarkupKind read_markup_kind(const std::string &type) {
if (type == "highlight") {
return TextMarkupKind::highlight;
}
if (type == "underline") {
return TextMarkupKind::underline;
}
if (type == "strikeOut") {
return TextMarkupKind::strike_out;
}
if (type == "squiggly") {
return TextMarkupKind::squiggly;
}
throw std::invalid_argument("unknown annotation type " + type);
}

TextMarkup read_text_markup(const nlohmann::json &value,
const std::string &type) {
TextMarkup result;
result.kind = read_markup_kind(type);
for (const nlohmann::json &quad : at(value, "quads")) {
if (!quad.is_array() || quad.size() != 8) {
throw std::invalid_argument("quad is not eight coordinates");
}
Quad &out = result.quads.emplace_back();
for (std::size_t i = 0; i < out.size(); ++i) {
out[i] = quad[i].get<double>();
}
}
result.common = read_common(value);
return result;
}

Ink read_ink(const nlohmann::json &value) {
Ink result;
for (const nlohmann::json &stroke : at(value, "strokes")) {
if (!stroke.is_array() || stroke.empty() || stroke.size() % 2 != 0) {
throw std::invalid_argument("stroke is not a sequence of x y pairs");
}
result.strokes.push_back(stroke.get<std::vector<double>>());
}
result.width = value.value("width", 1.0);
result.common = read_common(value);
return result;
}

/// @throws std::invalid_argument for a payload this build cannot write.
void write_annotations(DocumentParser &parser, const nlohmann::json &json,
std::ostream &out) {
// the guard against a payload from a frontend this build does not know
if (json.value("version", 0) != 1) {
throw std::invalid_argument("unsupported annotation format version");
}

const std::unique_ptr<Document> document = parser.parse_document();
const std::vector<Page *> pages = document->collect_pages();

IncrementalWriter writer(parser);
// one page rewrite per page, however many annotations land on it
std::map<std::size_t, std::vector<ObjectReference>> by_page;

for (const nlohmann::json &value :
json.value("annotations", nlohmann::json::array())) {
const auto index = at(value, "page").get<std::size_t>();
if (index >= pages.size()) {
throw std::invalid_argument("annotation names page " +
std::to_string(index) +
", which is not there");
}
const auto type = at(value, "type").get<std::string>();

by_page[index].push_back(
type == "ink"
? write_ink(writer, read_ink(value))
: write_text_markup(writer, read_text_markup(value, type)));
}

for (const auto &[index, references] : by_page) {
append_page_annotations(writer, *pages[index], references);
}

writer.write(out);
}

} // namespace

void PdfFile::annotate(const std::string_view annotations, std::ostream &out,
const Logger &logger) const {
try {
const nlohmann::json json = nlohmann::json::parse(annotations);
DocumentParser parser = create_parser(logger);
write_annotations(parser, json, out);
} catch (const nlohmann::json::exception &e) {
// nlohmann reports a member of the wrong type in a hierarchy of its own,
// and that is a malformed payload like any other
throw std::invalid_argument(std::string("annotations are malformed: ") +
e.what());
}
}

} // namespace odr::internal::pdf
3 changes: 3 additions & 0 deletions src/odr/internal/pdf/pdf_file.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ class PdfFile final : public abstract::PdfFile {

[[nodiscard]] bool is_decodable() const noexcept override;

void annotate(std::string_view annotations, std::ostream &out,
const Logger &logger) const override;

[[nodiscard]] DocumentParser
create_parser(const Logger &logger = Logger::null()) const;

Expand Down
1 change: 1 addition & 0 deletions test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ add_executable(odr_test
"src/html_test.cpp"
"src/logger_test.cpp"
"src/odr_test.cpp"
"src/pdf_annotate_test.cpp"
"src/quantity_test.cpp"
"src/table_position_test.cpp"

Expand Down
1 change: 1 addition & 0 deletions test/src/odr_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ TEST(FileTypeTable, capabilities_build_on_each_other) {
EXPECT_FALSE(capabilities.translate_html) << file_type_to_string(type);
EXPECT_FALSE(capabilities.edit) << file_type_to_string(type);
EXPECT_FALSE(capabilities.save) << file_type_to_string(type);
EXPECT_FALSE(capabilities.annotate) << file_type_to_string(type);
}
if (!capabilities.save) {
EXPECT_FALSE(capabilities.encrypt) << file_type_to_string(type);
Expand Down
Loading
Loading