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
10 changes: 6 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ 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.
- `PdfFile::annotate` writes highlight, underline, strike-out, squiggly and ink
annotations into a pdf as an incremental update — source bytes untouched,
any viewer reading them — in every binding, with an `annotate` capability.

- The rendered pdf view exposes `odr.annotation`: the five tools, live preview
and undo, whose `getAnnotations()` produces exactly what `annotate` takes.

- **Breaking**: `html::edit` becomes `Document::edit`, in every binding —
java's `Html.edit(document, diff)` becomes `document.edit(diff)`, and so on.
Expand Down
6 changes: 6 additions & 0 deletions apple/include/OdrCoreObjC/ODRFile.h
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ NS_SWIFT_NAME(FileTypeCapabilities)
@property(nonatomic, readonly) BOOL save;
/// Saving with a password is supported.
@property(nonatomic, readonly) BOOL encrypt;
/// `ODRPdfFile.annotate` is supported.
@property(nonatomic, readonly) BOOL annotate;

- (instancetype)init NS_UNAVAILABLE;
+ (instancetype)new NS_UNAVAILABLE;
Expand Down Expand Up @@ -403,6 +405,10 @@ NS_SWIFT_NAME(DocumentFile)
/// A decoded PDF — `odr::PdfFile`.
NS_SWIFT_NAME(PdfFile)
@interface ODRPdfFile : ODRDecodedFile
/// Applies markup annotations — the payload the rendered page's
/// `odr.annotation.getAnnotations()` collects — and returns the annotated pdf.
- (nullable NSData *)annotate:(NSString *)annotations
error:(NSError **)error NS_SWIFT_NAME(annotate(_:));
- (nullable ODRPdfFile *)decryptWithPassword:(NSString *)password
error:(NSError **)error;
@end
Expand Down
10 changes: 10 additions & 0 deletions apple/src/ODRFile.mm
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

#include <istream>
#include <optional>
#include <sstream>
#include <vector>

using odr::apple::guarded;
Expand Down Expand Up @@ -166,6 +167,7 @@ + (instancetype)capabilitiesWithHandle:
result->_edit = handle.edit ? YES : NO;
result->_save = handle.save ? YES : NO;
result->_encrypt = handle.encrypt ? YES : NO;
result->_annotate = handle.annotate ? YES : NO;
return result;
}

Expand Down Expand Up @@ -647,6 +649,14 @@ - (nullable ODRDocument *)documentWithError:(NSError **)error {

@implementation ODRPdfFile

- (nullable NSData *)annotate:(NSString *)annotations error:(NSError **)error {
return guarded(error, [&]() -> NSData * {
std::ostringstream out;
self.handle.as_pdf_file().annotate(to_string(annotations), out);
return to_nsdata(std::move(out).str());
});
}

- (nullable ODRPdfFile *)decryptWithPassword:(NSString *)password
error:(NSError **)error {
return guarded(error, [&]() -> ODRPdfFile * {
Expand Down
4 changes: 2 additions & 2 deletions apple/src/ODRInternal.h
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ std::string to_string(NSString *string);
NSString *to_nsstring(const std::string &string);
NSString *to_nsstring(std::string_view string);

/// Drains `stream` into an `NSData`. The stream APIs of odrcore hand out a
/// `std::istream`; ObjC callers want bytes.
/// Bytes as an `NSData`, drained from a stream where odrcore hands one out.
NSData *to_nsdata(const std::string &bytes);
NSData *to_nsdata(std::istream &stream);

/// Fills `*error` from the exception currently being handled. Call only from
Expand Down
7 changes: 5 additions & 2 deletions apple/src/ODRInternal.mm
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,14 @@
return result != nil ? result : @"";
}

NSData *apple::to_nsdata(const std::string &bytes) {
return [NSData dataWithBytes:bytes.data() length:bytes.size()];
}

NSData *apple::to_nsdata(std::istream &stream) {
std::ostringstream buffer;
buffer << stream.rdbuf();
const std::string bytes = buffer.str();
return [NSData dataWithBytes:bytes.data() length:bytes.size()];
return to_nsdata(std::move(buffer).str());
}

namespace {
Expand Down
32 changes: 32 additions & 0 deletions apple/tests/Fixture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,38 @@ enum Fixture {
try path("mixed-layout", "odt")
}

/// A one-page pdf written to a temporary file, its cross-reference offsets
/// computed so they are right.
static func pdf() throws -> String {
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]"
+ " /Resources << >> /Contents 4 0 R >>",
"<< /Length 5 >>\nstream\nBT ET\nendstream",
]

var out = "%PDF-1.7\n"
var offsets: [Int] = []
for (index, body) in objects.enumerated() {
offsets.append(out.utf8.count)
out += "\(index + 1) 0 obj\n\(body)\nendobj\n"
}

let start = out.utf8.count
out += "xref\n0 \(objects.count + 1)\n0000000000 65535 f \n"
for offset in offsets {
out += String(format: "%010d 00000 n \n", offset)
}
out += "trailer\n<< /Size \(objects.count + 1) /Root 1 0 R >>\n"
out += "startxref\n\(start)\n%%EOF\n"

let url = FileManager.default.temporaryDirectory
.appendingPathComponent("odr-minimal-\(UUID().uuidString).pdf")
try out.data(using: .isoLatin1)!.write(to: url)
return url.path
}

private static func path(_ name: String, _ extension: String) throws -> String {
try XCTUnwrap(
Bundle.module.url(
Expand Down
35 changes: 35 additions & 0 deletions apple/tests/OdrCoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,41 @@ final class DocumentSaveTests: XCTestCase {
}
}

final class PdfAnnotationTests: XCTestCase {
private static let highlight = """
{"version": 1, "annotations": [{"page": 0, "type": "highlight",
"quads": [[72, 700, 300, 700, 72, 688, 300, 688]],
"color": [1, 0.9, 0.2]}]}
"""

func testAnnotateIsDeclaredForPdf() throws {
let capabilities = Odr.capabilities(fileType: .portableDocumentFormat)
XCTAssertTrue(capabilities.annotate)
}

func testAnnotateAppendsToTheSource() throws {
let path = try Fixture.pdf()
let source = try Data(contentsOf: URL(fileURLWithPath: path))

let file = try DecodedFile.decode(path: path).asPdfFile()
let result = try file.annotate(Self.highlight)

XCTAssertGreaterThan(result.count, source.count)
// the source is copied through and the annotation written after it
XCTAssertEqual(result.prefix(source.count), source)

let text = String(decoding: result, as: UTF8.self)
XCTAssertTrue(text.contains("/Highlight"))
XCTAssertTrue(text.contains("/Subtype /Form"))
}

func testAnnotateRefusesAPayloadItDoesNotUnderstand() throws {
let file = try DecodedFile.decode(path: try Fixture.pdf()).asPdfFile()
XCTAssertThrowsError(try file.annotate("{\"version\": 2}"))
XCTAssertThrowsError(try file.annotate("not json"))
}
}

final class TableAddressTests: XCTestCase {
func testRoundTrips() throws {
XCTAssertEqual(TableAddress.columnNumber(from: "C"), 2)
Expand Down
13 changes: 7 additions & 6 deletions docs/design/pdf-annotation.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# PDF annotation design

Status: **underway.** This records the architecture for adding markup
Status: **landed.** This records the architecture for adding markup
annotations — text highlight and freehand drawing first — to an existing PDF,
the alternatives weighed, and the effort it costs. The format model is
validated against four viewers, and Phases 0 through 5 have landed: the browser
draws the markup and the writer appends it; the bindings are what is left.
validated against four viewers, and every phase has landed: the browser draws
the markup, the writer appends it, and every binding can apply it.

Scope is **markup only**: draw on top of a page, highlight/underline/strike
text. Editing or removing the *existing* text of a PDF is explicitly out — that
Expand Down Expand Up @@ -323,10 +323,11 @@ selection layer alone, which is what makes selecting text to highlight work.
Checks in `test/browser/annotation/`, run by hand as the repo's other emitted
scripts are.

### Phase 6 — bindings (2 d, ~470 lines)
### Phase 6 — bindings — **done** (#850)

wasm (~50 C++, ~80 TS), JNI (~60 C++, ~70 Java), Python (~40), Apple (~80 ObjC,
~90 Swift).
`annotate` and the `annotate` capability across wasm, JNI, python and Apple.
Each returns the annotated bytes rather than writing a file: none of these
callers has a filesystem the caller would want written to.

### Phase 7 — corpus and interop (2 d, ~600 test lines)

Expand Down
1 change: 1 addition & 0 deletions jni/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ if (ODR_TEST AND NOT ANDROID)
"tests/app/opendocument/core/HttpServerTest.java"
"tests/app/opendocument/core/LoggerTest.java"
"tests/app/opendocument/core/MetaTest.java"
"tests/app/opendocument/core/PdfFileTest.java"
"tests/app/opendocument/core/TextEncodingTest.java"
# shared with the instrumented suite of the AAR, see `android/`
"testfixtures/app/opendocument/core/TestFiles.java"
Expand Down
7 changes: 6 additions & 1 deletion jni/java/app/opendocument/core/FileTypeCapabilities.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ public final class FileTypeCapabilities {
/** {@link Document#save} with a password is supported. */
public final boolean encrypt;

/** {@link PdfFile#annotate} is supported. */
public final boolean annotate;

FileTypeCapabilities(
boolean detectByContent,
boolean open,
Expand All @@ -42,7 +45,8 @@ public final class FileTypeCapabilities {
boolean colorScheme,
boolean edit,
boolean save,
boolean encrypt) {
boolean encrypt,
boolean annotate) {
this.detectByContent = detectByContent;
this.open = open;
this.decrypt = decrypt;
Expand All @@ -51,5 +55,6 @@ public final class FileTypeCapabilities {
this.edit = edit;
this.save = save;
this.encrypt = encrypt;
this.annotate = annotate;
}
}
12 changes: 12 additions & 0 deletions jni/java/app/opendocument/core/PdfFile.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,17 @@ public PdfFile decrypt(String password) {
return new PdfFile(decryptPdfFileNative(handle(), password));
}

/**
* Applies markup annotations and returns the annotated pdf.
*
* @param annotations the payload the rendered page's {@code
* odr.annotation.getAnnotations()} collects.
*/
public byte[] annotate(String annotations) {
return annotateNative(handle(), annotations);
}

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

private native byte[] annotateNative(long handle, String annotations);
}
11 changes: 11 additions & 0 deletions jni/src/jni_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,17 @@ Java_app_opendocument_core_PdfFile_decryptPdfFileNative(JNIEnv *env, jobject,
});
}

extern "C" JNIEXPORT jbyteArray JNICALL
Java_app_opendocument_core_PdfFile_annotateNative(JNIEnv *env, jobject,
jlong handle,
jstring annotations) {
return guarded(env, [&] {
std::ostringstream out;
decoded(handle).as_pdf_file().annotate(to_string(env, annotations), out);
return to_jbytes(env, std::move(out).str());
});
}

// app.opendocument.core.FontFile

extern "C" JNIEXPORT jbyteArray JNICALL
Expand Down
5 changes: 3 additions & 2 deletions jni/src/jni_style.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -401,15 +401,16 @@ jobject
make_file_type_capabilities(JNIEnv *env,
const odr::FileTypeCapabilities &capabilities) {
return new_object(env, "app/opendocument/core/FileTypeCapabilities",
"(ZZZZZZZZ)V",
"(ZZZZZZZZZ)V",
static_cast<jboolean>(capabilities.detect_by_content),
static_cast<jboolean>(capabilities.open),
static_cast<jboolean>(capabilities.decrypt),
static_cast<jboolean>(capabilities.translate_html),
static_cast<jboolean>(capabilities.color_scheme),
static_cast<jboolean>(capabilities.edit),
static_cast<jboolean>(capabilities.save),
static_cast<jboolean>(capabilities.encrypt));
static_cast<jboolean>(capabilities.encrypt),
static_cast<jboolean>(capabilities.annotate));
}

jobject html_config_to_java(JNIEnv *env, const odr::HtmlConfig &config) {
Expand Down
29 changes: 29 additions & 0 deletions jni/testfixtures/app/opendocument/core/TestFiles.java
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,35 @@ static Path txtFile(Path directory) throws IOException {
return path;
}

/** A one-page pdf, its cross-reference offsets computed so they are right. */
static Path pdfFile(Path directory) throws IOException {
Path path = directory.resolve("minimal.pdf");
List<String> objects =
Arrays.asList(
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
+ "/Resources << >> /Contents 4 0 R >>",
"<< /Length 5 >>\nstream\nBT ET\nendstream");

StringBuilder out = new StringBuilder("%PDF-1.7\n");
int[] offsets = new int[objects.size()];
for (int i = 0; i < objects.size(); ++i) {
offsets[i] = out.length();
out.append(i + 1).append(" 0 obj\n").append(objects.get(i)).append("\nendobj\n");
}
int start = out.length();
out.append("xref\n0 ").append(objects.size() + 1).append("\n0000000000 65535 f \n");
for (int offset : offsets) {
out.append(String.format("%010d 00000 n \n", offset));
}
out.append("trailer\n<< /Size ").append(objects.size() + 1).append(" /Root 1 0 R >>\n");
out.append("startxref\n").append(start).append("\n%%EOF\n");

write(path, out.toString());
return path;
}

private static void write(Path path, String content) throws IOException {
Files.write(path, content.getBytes(StandardCharsets.UTF_8));
}
Expand Down
54 changes: 54 additions & 0 deletions jni/tests/app/opendocument/core/PdfFileTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package app.opendocument.core;

import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

class PdfFileTest {
@TempDir Path tempDir;

private static final String HIGHLIGHT =
"{\"version\": 1, \"annotations\": [{\"page\": 0, \"type\": \"highlight\","
+ " \"quads\": [[72, 700, 300, 700, 72, 688, 300, 688]],"
+ " \"color\": [1, 0.9, 0.2]}]}";

@Test
void annotateIsDeclaredForPdf() {
assertTrue(Odr.capabilitiesByFileType(FileType.PORTABLE_DOCUMENT_FORMAT).annotate);
}

@Test
void annotateAppendsToTheSource() throws IOException {
Path pdf = TestFiles.pdfFile(tempDir);
byte[] source = Files.readAllBytes(pdf);

try (DecodedFile file = Odr.open(pdf.toString())) {
byte[] result = file.asPdfFile().annotate(HIGHLIGHT);

assertTrue(result.length > source.length);
// the source is copied and the annotation appended after it
assertArrayEquals(source, Arrays.copyOf(result, source.length));
String text = new String(result, StandardCharsets.ISO_8859_1);
assertTrue(text.contains("/Highlight"));
assertTrue(text.contains("/Subtype /Form"));
}
}

@Test
void annotateRefusesAPayloadItDoesNotUnderstand() throws IOException {
Path pdf = TestFiles.pdfFile(tempDir);
try (DecodedFile file = Odr.open(pdf.toString())) {
PdfFile pdfFile = file.asPdfFile();
assertThrows(OdrException.class, () -> pdfFile.annotate("{\"version\": 2}"));
assertThrows(OdrException.class, () -> pdfFile.annotate("not json"));
}
}
}
Loading
Loading