452 lines
18 KiB
Swift
452 lines
18 KiB
Swift
import XCTest
|
|
import AppKit
|
|
import PDFKit
|
|
@testable import IHatePDFsCore
|
|
|
|
final class AnnotationFactoryTests: XCTestCase {
|
|
func testHighlightSelectionRoundTripsThroughPDFSave() throws {
|
|
let document = try makeSelectableTextDocument()
|
|
let page = try XCTUnwrap(document.page(at: 0))
|
|
let selection = try XCTUnwrap(page.selection(for: NSRange(location: 0, length: 29)))
|
|
let insertions = AnnotationFactory.markupInsertions(
|
|
from: selection,
|
|
style: .highlight,
|
|
comment: "Use this passage in lecture.",
|
|
author: "Professor"
|
|
)
|
|
|
|
XCTAssertEqual(insertions.count, 1)
|
|
|
|
for insertion in insertions {
|
|
insertion.page.addAnnotation(insertion.annotation)
|
|
if let popup = insertion.popup {
|
|
insertion.page.addAnnotation(popup)
|
|
}
|
|
}
|
|
|
|
let outputURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString)
|
|
.appendingPathExtension("pdf")
|
|
defer { try? FileManager.default.removeItem(at: outputURL) }
|
|
|
|
XCTAssertTrue(document.write(to: outputURL))
|
|
|
|
let reopened = try XCTUnwrap(PDFDocument(url: outputURL))
|
|
let reopenedPage = try XCTUnwrap(reopened.page(at: 0))
|
|
XCTAssertEqual(reopenedPage.string, "This is selectable academic text.")
|
|
XCTAssertTrue(
|
|
reopenedPage.annotations.contains {
|
|
AnnotationKeys.annotation($0, hasSubtype: .highlight)
|
|
&& $0.contents == "Use this passage in lecture."
|
|
}
|
|
)
|
|
}
|
|
|
|
func testSelectionBoundCommentRoundTripsAsCommentKind() throws {
|
|
let document = try makeSelectableTextDocument()
|
|
let page = try XCTUnwrap(document.page(at: 0))
|
|
let selection = try XCTUnwrap(page.selection(for: NSRange(location: 8, length: 10)))
|
|
let insertion = try XCTUnwrap(
|
|
AnnotationFactory.markupInsertions(
|
|
from: selection,
|
|
style: .comment,
|
|
comment: "Explain this phrase.",
|
|
author: "Professor"
|
|
).first
|
|
)
|
|
|
|
page.addAnnotation(insertion.annotation)
|
|
if let popup = insertion.popup {
|
|
page.addAnnotation(popup)
|
|
}
|
|
|
|
let reopenedPage = try saveAndReopen(document).page(at: 0).unwrap()
|
|
let reopenedComment = try XCTUnwrap(reopenedPage.annotations.first {
|
|
AnnotationKeys.annotation($0, hasSubtype: .highlight)
|
|
&& $0.contents == "Explain this phrase."
|
|
})
|
|
|
|
XCTAssertEqual(AcademicAnnotationKind(annotation: reopenedComment), .comment)
|
|
XCTAssertEqual(
|
|
reopenedComment.value(forAnnotationKey: AnnotationKeys.appKind) as? String,
|
|
AnnotationKeys.appKindComment
|
|
)
|
|
}
|
|
|
|
func testSelectionBoundCommentCreatedEmptyGetsPopupWhenTextIsSaved() throws {
|
|
let document = try makeSelectableTextDocument()
|
|
let page = try XCTUnwrap(document.page(at: 0))
|
|
let selection = try XCTUnwrap(page.selection(for: NSRange(location: 8, length: 10)))
|
|
let insertion = try XCTUnwrap(
|
|
AnnotationFactory.markupInsertions(
|
|
from: selection,
|
|
style: .comment,
|
|
comment: "",
|
|
author: "Professor"
|
|
).first
|
|
)
|
|
|
|
XCTAssertNil(insertion.popup)
|
|
page.addAnnotation(insertion.annotation)
|
|
|
|
let popup = try XCTUnwrap(AnnotationFactory.updateComment(
|
|
for: insertion.annotation,
|
|
on: page,
|
|
text: "Explain this phrase.",
|
|
author: "Professor"
|
|
))
|
|
page.addAnnotation(popup)
|
|
|
|
let reopenedPage = try saveAndReopen(document).page(at: 0).unwrap()
|
|
let reopenedComment = try XCTUnwrap(reopenedPage.annotations.first {
|
|
AnnotationKeys.annotation($0, hasSubtype: .highlight)
|
|
&& $0.contents == "Explain this phrase."
|
|
})
|
|
|
|
XCTAssertEqual(AcademicAnnotationKind(annotation: reopenedComment), .comment)
|
|
XCTAssertTrue(reopenedPage.annotations.contains {
|
|
AnnotationKeys.annotation($0, hasSubtype: .popup)
|
|
&& $0.contents == "Explain this phrase."
|
|
})
|
|
}
|
|
|
|
func testPopupMarkerIsPlacedInRightPageMargin() throws {
|
|
let document = try makeSelectableTextDocument()
|
|
let page = try XCTUnwrap(document.page(at: 0))
|
|
let selection = try XCTUnwrap(page.selection(for: NSRange(location: 0, length: 29)))
|
|
let insertion = try XCTUnwrap(
|
|
AnnotationFactory.markupInsertions(
|
|
from: selection,
|
|
style: .comment,
|
|
comment: "Margin marker.",
|
|
author: "Professor"
|
|
).first
|
|
)
|
|
let popup = try XCTUnwrap(insertion.popup)
|
|
let pageBounds = page.bounds(for: .cropBox)
|
|
|
|
XCTAssertEqual(popup.bounds.minX, pageBounds.maxX - 28, accuracy: 0.01)
|
|
XCTAssertGreaterThanOrEqual(popup.bounds.minY, pageBounds.minY + 12)
|
|
XCTAssertLessThanOrEqual(popup.bounds.minY, pageBounds.maxY - 40)
|
|
|
|
popup.bounds = insertion.annotation.bounds.offsetBy(dx: 10, dy: 0)
|
|
XCTAssertTrue(AnnotationFactory.normalizePopupPlacement(for: insertion.annotation, on: page))
|
|
XCTAssertEqual(popup.bounds.minX, pageBounds.maxX - 28, accuracy: 0.01)
|
|
}
|
|
|
|
func testDetachingPopupForViewerKeepsSidebarCommentText() throws {
|
|
let document = try makeSelectableTextDocument()
|
|
let page = try XCTUnwrap(document.page(at: 0))
|
|
let selection = try XCTUnwrap(page.selection(for: NSRange(location: 0, length: 29)))
|
|
let insertion = try XCTUnwrap(
|
|
AnnotationFactory.markupInsertions(
|
|
from: selection,
|
|
style: .comment,
|
|
comment: "",
|
|
author: "Professor"
|
|
).first
|
|
)
|
|
|
|
page.addAnnotation(insertion.annotation)
|
|
let popup = try XCTUnwrap(AnnotationFactory.updateComment(
|
|
for: insertion.annotation,
|
|
on: page,
|
|
text: "Visible comment text.",
|
|
author: "Professor"
|
|
))
|
|
page.addAnnotation(popup)
|
|
|
|
XCTAssertTrue(AnnotationFactory.detachPopupForViewer(from: insertion.annotation, on: page))
|
|
XCTAssertNil(insertion.annotation.popup)
|
|
XCTAssertNil(insertion.annotation.contents)
|
|
XCTAssertEqual(AnnotationKeys.commentText(for: insertion.annotation), "Visible comment text.")
|
|
XCTAssertFalse(page.annotations.contains { AnnotationKeys.annotation($0, hasSubtype: .popup) })
|
|
|
|
let snapshot = try XCTUnwrap(AnnotationReader.snapshots(in: document).first {
|
|
$0.annotation === insertion.annotation
|
|
})
|
|
XCTAssertEqual(snapshot.contents, "Visible comment text.")
|
|
}
|
|
|
|
func testRestoreCommentTextForExportWritesStandardPDFContents() throws {
|
|
let document = try makeSelectableTextDocument()
|
|
let page = try XCTUnwrap(document.page(at: 0))
|
|
let selection = try XCTUnwrap(page.selection(for: NSRange(location: 0, length: 29)))
|
|
let insertion = try XCTUnwrap(
|
|
AnnotationFactory.markupInsertions(
|
|
from: selection,
|
|
style: .comment,
|
|
comment: "Exported comment text.",
|
|
author: "Professor"
|
|
).first
|
|
)
|
|
|
|
page.addAnnotation(insertion.annotation)
|
|
if let popup = insertion.popup {
|
|
page.addAnnotation(popup)
|
|
}
|
|
AnnotationFactory.detachPopupForViewer(from: insertion.annotation, on: page)
|
|
|
|
XCTAssertNil(insertion.annotation.contents)
|
|
XCTAssertTrue(AnnotationFactory.restoreCommentTextForExport(insertion.annotation))
|
|
let popup = try XCTUnwrap(AnnotationFactory.makePopupIfNeeded(
|
|
for: insertion.annotation,
|
|
on: page,
|
|
open: false
|
|
))
|
|
if popup.page == nil {
|
|
page.addAnnotation(popup)
|
|
}
|
|
|
|
let reopenedPage = try saveAndReopen(document).page(at: 0).unwrap()
|
|
XCTAssertTrue(reopenedPage.annotations.contains {
|
|
AnnotationKeys.annotation($0, hasSubtype: .highlight)
|
|
&& $0.contents == "Exported comment text."
|
|
})
|
|
}
|
|
|
|
func testAddingAnnotationPreservesPriorAnnotation() throws {
|
|
let document = try makeSelectableTextDocument()
|
|
let page = try XCTUnwrap(document.page(at: 0))
|
|
|
|
let prior = AnnotationFactory.noteInsertion(
|
|
on: page,
|
|
near: CGPoint(x: 420, y: 700),
|
|
comment: "Existing note from another reader.",
|
|
author: "Colleague"
|
|
)
|
|
page.addAnnotation(prior.annotation)
|
|
if let popup = prior.popup {
|
|
page.addAnnotation(popup)
|
|
}
|
|
|
|
let selection = try XCTUnwrap(page.selection(for: NSRange(location: 8, length: 10)))
|
|
let highlight = try XCTUnwrap(
|
|
AnnotationFactory.markupInsertions(
|
|
from: selection,
|
|
style: .highlight,
|
|
comment: "New professor comment.",
|
|
author: "Professor"
|
|
).first
|
|
)
|
|
page.addAnnotation(highlight.annotation)
|
|
if let popup = highlight.popup {
|
|
page.addAnnotation(popup)
|
|
}
|
|
|
|
let reopenedPage = try saveAndReopen(document).page(at: 0).unwrap()
|
|
XCTAssertEqual(reopenedPage.string, "This is selectable academic text.")
|
|
XCTAssertTrue(reopenedPage.annotations.contains {
|
|
AnnotationKeys.annotation($0, hasSubtype: .text)
|
|
&& $0.contents == "Existing note from another reader."
|
|
})
|
|
XCTAssertTrue(reopenedPage.annotations.contains {
|
|
AnnotationKeys.annotation($0, hasSubtype: .highlight)
|
|
&& $0.contents == "New professor comment."
|
|
})
|
|
}
|
|
|
|
func testScannedImagePDFCanReceiveStandardTextAnnotation() throws {
|
|
let document = try makeImageOnlyDocument()
|
|
let page = try XCTUnwrap(document.page(at: 0))
|
|
let insertion = AnnotationFactory.noteInsertion(
|
|
on: page,
|
|
near: CGPoint(x: 300, y: 500),
|
|
comment: "Comment on scanned reading.",
|
|
author: "Professor"
|
|
)
|
|
page.addAnnotation(insertion.annotation)
|
|
if let popup = insertion.popup {
|
|
page.addAnnotation(popup)
|
|
}
|
|
|
|
let reopenedPage = try saveAndReopen(document).page(at: 0).unwrap()
|
|
XCTAssertNil(reopenedPage.string)
|
|
XCTAssertTrue(reopenedPage.annotations.contains {
|
|
AnnotationKeys.annotation($0, hasSubtype: .text)
|
|
&& $0.contents == "Comment on scanned reading."
|
|
})
|
|
}
|
|
|
|
func testFiveHundredPagePDFCanBeSavedWithAnnotation() throws {
|
|
let document = PDFDocument()
|
|
for index in 0..<501 {
|
|
document.insert(PDFPage(), at: index)
|
|
}
|
|
XCTAssertEqual(document.pageCount, 501)
|
|
|
|
let page = try XCTUnwrap(document.page(at: 500))
|
|
let insertion = AnnotationFactory.noteInsertion(
|
|
on: page,
|
|
near: CGPoint(x: 120, y: 120),
|
|
comment: "End of long reading.",
|
|
author: "Professor"
|
|
)
|
|
page.addAnnotation(insertion.annotation)
|
|
if let popup = insertion.popup {
|
|
page.addAnnotation(popup)
|
|
}
|
|
|
|
let reopened = try saveAndReopen(document)
|
|
XCTAssertEqual(reopened.pageCount, 501)
|
|
let reopenedPage = try XCTUnwrap(reopened.page(at: 500))
|
|
XCTAssertTrue(reopenedPage.annotations.contains {
|
|
AnnotationKeys.annotation($0, hasSubtype: .text)
|
|
&& $0.contents == "End of long reading."
|
|
})
|
|
}
|
|
|
|
func testTextAnnotationUsesStandardKeys() throws {
|
|
let page = PDFPage()
|
|
let insertion = AnnotationFactory.noteInsertion(
|
|
on: page,
|
|
near: CGPoint(x: 100, y: 100),
|
|
comment: "Discuss this claim in class.",
|
|
author: "Professor"
|
|
)
|
|
|
|
XCTAssertTrue(AnnotationKeys.annotation(insertion.annotation, hasSubtype: .text))
|
|
XCTAssertEqual(insertion.annotation.contents, "Discuss this claim in class.")
|
|
XCTAssertEqual(insertion.annotation.userName, "Professor")
|
|
XCTAssertNotNil(insertion.annotation.value(forAnnotationKey: .name))
|
|
XCTAssertNotNil(insertion.annotation.value(forAnnotationKey: AnnotationKeys.creationDate))
|
|
XCTAssertEqual(insertion.annotation.value(forAnnotationKey: AnnotationKeys.state) as? String, "Unmarked")
|
|
XCTAssertNotNil(insertion.popup)
|
|
XCTAssertTrue(insertion.popup.map { AnnotationKeys.annotation($0, hasSubtype: .popup) } ?? false)
|
|
}
|
|
|
|
func testReplyStoresHiddenTextAnnotationWithBestEffortParentID() throws {
|
|
let page = PDFPage()
|
|
let parent = AnnotationFactory.noteInsertion(
|
|
on: page,
|
|
near: CGPoint(x: 100, y: 100),
|
|
comment: "Parent",
|
|
author: "Professor"
|
|
).annotation
|
|
let reply = AnnotationFactory.replyInsertion(
|
|
to: parent,
|
|
on: page,
|
|
comment: "Reply",
|
|
author: "Reader",
|
|
parentID: "parent-id"
|
|
)
|
|
|
|
XCTAssertTrue(AnnotationKeys.annotation(reply.annotation, hasSubtype: .text))
|
|
XCTAssertEqual(reply.annotation.value(forAnnotationKey: AnnotationKeys.inReplyTo) as? String, "parent-id")
|
|
XCTAssertEqual(reply.annotation.value(forAnnotationKey: AnnotationKeys.replyType) as? String, "R")
|
|
XCTAssertFalse(reply.annotation.shouldDisplay)
|
|
XCTAssertFalse(reply.annotation.shouldPrint)
|
|
|
|
page.addAnnotation(reply.annotation)
|
|
AnnotationFactory.hideReplyMarker(reply.annotation, on: page)
|
|
XCTAssertFalse(reply.annotation.shouldDisplay)
|
|
XCTAssertFalse(reply.annotation.shouldPrint)
|
|
XCTAssertEqual(AnnotationKeys.commentText(for: reply.annotation), "Reply")
|
|
XCTAssertGreaterThan(reply.annotation.bounds.minX, page.bounds(for: .cropBox).maxX)
|
|
XCTAssertGreaterThan(reply.annotation.bounds.minY, page.bounds(for: .cropBox).maxY)
|
|
XCTAssertNil(reply.popup)
|
|
}
|
|
|
|
func testStringReplyParentIDResolvesToParentStableID() throws {
|
|
let document = PDFDocument()
|
|
let page = PDFPage()
|
|
document.insert(page, at: 0)
|
|
|
|
let parent = AnnotationFactory.noteInsertion(
|
|
on: page,
|
|
near: CGPoint(x: 100, y: 100),
|
|
comment: "Parent",
|
|
author: "Professor"
|
|
).annotation
|
|
page.addAnnotation(parent)
|
|
|
|
let parentID = try XCTUnwrap(parent.value(forAnnotationKey: .name) as? String)
|
|
let reply = AnnotationFactory.replyInsertion(
|
|
to: parent,
|
|
on: page,
|
|
comment: "Reply",
|
|
author: "Reader",
|
|
parentID: parentID
|
|
).annotation
|
|
page.addAnnotation(reply)
|
|
AnnotationFactory.hideReplyMarker(reply, on: page)
|
|
|
|
let snapshots = AnnotationReader.snapshots(in: document)
|
|
let parentSnapshot = try XCTUnwrap(snapshots.first { $0.contents == "Parent" })
|
|
let replySnapshot = try XCTUnwrap(snapshots.first { $0.contents == "Reply" })
|
|
|
|
XCTAssertEqual(replySnapshot.kind, .reply)
|
|
XCTAssertEqual(replySnapshot.parentID, parentSnapshot.id)
|
|
}
|
|
|
|
func testFreeTextCreatesStandardFreeTextAnnotation() throws {
|
|
let page = PDFPage()
|
|
let insertion = AnnotationFactory.freeTextInsertion(
|
|
on: page,
|
|
near: CGPoint(x: 200, y: 200),
|
|
text: "Important definition",
|
|
author: "Professor"
|
|
)
|
|
|
|
XCTAssertTrue(AnnotationKeys.annotation(insertion.annotation, hasSubtype: .freeText))
|
|
XCTAssertEqual(insertion.annotation.contents, "Important definition")
|
|
XCTAssertNil(insertion.popup)
|
|
}
|
|
|
|
private func makeSelectableTextDocument() throws -> PDFDocument {
|
|
let data = NSMutableData()
|
|
var mediaBox = CGRect(x: 0, y: 0, width: 612, height: 792)
|
|
let consumer = try XCTUnwrap(CGDataConsumer(data: data))
|
|
let context = try XCTUnwrap(CGContext(consumer: consumer, mediaBox: &mediaBox, nil))
|
|
|
|
context.beginPDFPage(nil)
|
|
NSGraphicsContext.saveGraphicsState()
|
|
NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: false)
|
|
let text = NSAttributedString(
|
|
string: "This is selectable academic text.",
|
|
attributes: [.font: NSFont.systemFont(ofSize: 18)]
|
|
)
|
|
text.draw(at: CGPoint(x: 72, y: 700))
|
|
NSGraphicsContext.restoreGraphicsState()
|
|
context.endPDFPage()
|
|
context.closePDF()
|
|
|
|
return try XCTUnwrap(PDFDocument(data: data as Data))
|
|
}
|
|
|
|
private func makeImageOnlyDocument() throws -> PDFDocument {
|
|
let image = NSImage(size: CGSize(width: 612, height: 792))
|
|
image.lockFocus()
|
|
NSColor.white.setFill()
|
|
NSRect(x: 0, y: 0, width: 612, height: 792).fill()
|
|
NSColor.darkGray.setStroke()
|
|
let path = NSBezierPath(rect: NSRect(x: 72, y: 580, width: 468, height: 80))
|
|
path.lineWidth = 2
|
|
path.stroke()
|
|
image.unlockFocus()
|
|
|
|
let document = PDFDocument()
|
|
document.insert(try XCTUnwrap(PDFPage(image: image)), at: 0)
|
|
return document
|
|
}
|
|
|
|
private func saveAndReopen(_ document: PDFDocument) throws -> PDFDocument {
|
|
let outputURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString)
|
|
.appendingPathExtension("pdf")
|
|
XCTAssertTrue(document.write(to: outputURL))
|
|
let reopened = try XCTUnwrap(PDFDocument(url: outputURL))
|
|
try? FileManager.default.removeItem(at: outputURL)
|
|
return reopened
|
|
}
|
|
}
|
|
|
|
private extension Optional {
|
|
func unwrap(
|
|
file: StaticString = #filePath,
|
|
line: UInt = #line
|
|
) throws -> Wrapped {
|
|
try XCTUnwrap(self, file: file, line: line)
|
|
}
|
|
}
|