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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ open: **a second build under the same version goes under the already cut
heading, not back under `Unreleased`.** Date the heading and add its compare link
once the version tag exists.

## [Unreleased]

### Changed

- PDFs are rendered by odrcore instead of being handed to the web view, and a
password protected one takes the prompt the other formats use. Their text is
in the page, so they become searchable once odrcore writes the search script
for a pdf page as it does for a document.
- The search button leaves the tool bar when the page cannot be searched,
rather than greying out - the same as the edit button.

## [1.40]

### Changed
Expand Down
8 changes: 8 additions & 0 deletions OpenDocumentReader.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
E2A17B1000000000000000B0 /* test.ods in Resources */ = {isa = PBXBuildFile; fileRef = E2A17B1200000000000000B2 /* test.ods */; };
E2A17B1100000000000000B1 /* test.odp in Resources */ = {isa = PBXBuildFile; fileRef = E2A17B1300000000000000B3 /* test.odp */; };
E2A17B1400000000000000B4 /* test.csv in Resources */ = {isa = PBXBuildFile; fileRef = E2A17B1500000000000000B5 /* test.csv */; };
E2A17B1600000000000000B6 /* test.pdf in Resources */ = {isa = PBXBuildFile; fileRef = E2A17B1700000000000000B7 /* test.pdf */; };
E2A17B1800000000000000B8 /* test-encrypted.pdf in Resources */ = {isa = PBXBuildFile; fileRef = E2A17B1900000000000000B9 /* test-encrypted.pdf */; };
E2A17B2000000000000000C0 /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2A17B2200000000000000C2 /* CoreFoundation.framework */; };
E2A17B2100000000000000C1 /* CFNetwork.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2A17B2300000000000000C3 /* CFNetwork.framework */; };
FB494C85E264BD924C1EA54E /* OdrCore in Frameworks */ = {isa = PBXBuildFile; productRef = E381918B979121CD165C966A /* OdrCore */; };
Expand Down Expand Up @@ -65,6 +67,8 @@
E2A17B1200000000000000B2 /* test.ods */ = {isa = PBXFileReference; lastKnownFileType = file; path = test.ods; sourceTree = "<group>"; };
E2A17B1300000000000000B3 /* test.odp */ = {isa = PBXFileReference; lastKnownFileType = file; path = test.odp; sourceTree = "<group>"; };
E2A17B1500000000000000B5 /* test.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = test.csv; sourceTree = "<group>"; };
E2A17B1700000000000000B7 /* test.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = test.pdf; sourceTree = "<group>"; };
E2A17B1900000000000000B9 /* test-encrypted.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = "test-encrypted.pdf"; sourceTree = "<group>"; };
E2A17B2200000000000000C2 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; };
E2A17B2300000000000000C3 /* CFNetwork.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CFNetwork.framework; path = System/Library/Frameworks/CFNetwork.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */
Expand Down Expand Up @@ -163,6 +167,8 @@
E2A17B1200000000000000B2 /* test.ods */,
E2A17B1300000000000000B3 /* test.odp */,
E2A17B1500000000000000B5 /* test.csv */,
E2A17B1700000000000000B7 /* test.pdf */,
E2A17B1900000000000000B9 /* test-encrypted.pdf */,
E22B252E2557F0E2001D0C52 /* OpenDocumentReaderTests.swift */,
E2A17B0300000000000000A3 /* PageTabBarTests.swift */,
E22B25302557F0E2001D0C52 /* Info.plist */,
Expand Down Expand Up @@ -353,6 +359,8 @@
E2A17B1000000000000000B0 /* test.ods in Resources */,
E2A17B1100000000000000B1 /* test.odp in Resources */,
E2A17B1400000000000000B4 /* test.csv in Resources */,
E2A17B1600000000000000B6 /* test.pdf in Resources */,
E2A17B1800000000000000B8 /* test-encrypted.pdf in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
20 changes: 10 additions & 10 deletions OpenDocumentReader/CoreWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ let CoreWrapperErrorDomain = "app.opendocument.CoreWrapperErrorDomain"
@objc enum CoreWrapperError: Int {
case unknown = 1
case wrongPassword = 2
/// Not a document odrcore can translate. PDFs land here on purpose.
/// Not something odrcore renders for us — see the guard in `translate`.
case unsupportedFileType = 3
}

Expand Down Expand Up @@ -107,11 +107,6 @@ private func isCsv(_ file: DecodedFile) -> Bool { file.fileType == .commaSeparat
guard !fileTypes.isEmpty else {
throw coreWrapperError(.unsupportedFileType, "odrcore does not recognise this file type")
}
// PDFs are handed to WKWebView instead, which renders them natively
guard !fileTypes.contains(NSNumber(value: FileType.portableDocumentFormat.rawValue)) else {
throw coreWrapperError(
.unsupportedFileType, "PDF is rendered by the web view, not by odrcore")
}

var file = try DecodedFile.decode(path: inputPath)
if file.isPasswordEncrypted {
Expand All @@ -124,8 +119,11 @@ private func isCsv(_ file: DecodedFile) -> Bool { file.fileType == .commaSeparat
}
}

guard file.isDocumentFile || isCsv(file) else {
throw coreWrapperError(.unsupportedFileType, "not a document file")
// odrcore also translates images, media and fonts, but only into an
// `<img>` or a `<video>` the web view decodes anyway. Those go to the
// system instead, through the fallback in `DocumentViewController`.
guard file.isDocumentFile || file.isPdfFile || isCsv(file) else {
throw coreWrapperError(.unsupportedFileType, "not a document, a pdf or a csv")
}

// the same answers OpenDocument.droid gives odrcore, so a document is
Expand Down Expand Up @@ -161,8 +159,10 @@ private func isCsv(_ file: DecodedFile) -> Bool { file.fileType == .commaSeparat
service = try HtmlTranslator.translate(
document: document, cachePath: cachePath, config: config)
} else {
// not `.spreadsheet`, though a csv is one: that asks for a tab per
// sheet, and a csv's single sheet is called "csv"
// a csv and a pdf have no document behind them to open or to edit.
// `.unknown` keeps the combined view for both; not `.spreadsheet`,
// though a csv is one, because that asks for a tab per sheet and a
// csv's single sheet is called "csv"
documentType = .unknown
openedDocument = nil
service = try HtmlTranslator.translate(
Expand Down
80 changes: 59 additions & 21 deletions OpenDocumentReader/DocumentViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
}
}

private let EXTENSION_WHITELIST = [
/// The fallback for what odrcore does not render: what WKWebView and the
/// system decoders can show on their own. Not derived from odrcore's format
/// table — this is the other side of it.
private let systemRenderedExtensions = [
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "rtf", "rtfd.zip", "csv", "txt", "jpg", "jpeg", "png",
"gif", "svg", "pages", "pages.zip", "numbers", "numbers.zip", "key", "key.zip", "mp3", "mp4", "flv", "mkv",
"3gp", "aac", "bmp", "css", "htm", "html", "js", "json", "mpeg", "oga", "ogv", "sh", "tif", "tiff", "weba",
Expand All @@ -52,12 +55,27 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
@IBOutlet weak var barButtonItem: UIBarButtonItem!
@IBOutlet weak var searchButton: UIBarButtonItem!
@IBOutlet weak var editButton: UIBarButtonItem!
/// The gaps behind those two. A button that leaves the bar takes its gap
/// with it, or what stays drifts off the trailing edge.
@IBOutlet weak var editButtonSpacer: UIBarButtonItem!
@IBOutlet weak var searchButtonSpacer: UIBarButtonItem!

/// The bar as the storyboard has it, and the same without the edit button.
/// Taken before anything is removed, since that is the only moment both are
/// there to be read.
/// The bar as the storyboard has it, taken before anything is removed, since
/// that is the only moment every button is there to be read.
private lazy var toolBarItems: [UIBarButtonItem] = toolBar.items ?? []
private lazy var toolBarItemsWithoutEdit: [UIBarButtonItem] = toolBarItems.filter { $0 !== editButton }

/// Whether the document on screen can be edited and searched. Neither button
/// stays in the bar when it cannot be used.
private var canEdit = false { didSet { updateToolBar() } }
private var canSearch = false {
didSet {
updateToolBar()

if !canSearch {
hideSearchBar()
}
}
}

/// What OpenDocument.droid gets from `loadWithOverviewMode`, which iOS has
/// no setting for: a page wider than the screen is zoomed out until it fits
Expand Down Expand Up @@ -160,8 +178,8 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
barButtonItem.accessibilityLabel = NSLocalizedString("back_to_documents", comment: "")
editButton.accessibilityLabel = NSLocalizedString("menu_edit", comment: "")

// nothing is editable until a document says so
showEditButton(false)
// nothing is editable or searchable until a page says so
updateToolBar()

setUpHouseAd()
}
Expand Down Expand Up @@ -208,6 +226,10 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
documentLoadingError(doc, error: DocumentError.pageNotServed)
}

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
updateSearchButton()
}

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)

Expand Down Expand Up @@ -395,14 +417,33 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
editDocument()
}

/// In the bar for the documents that can be edited, and only while they are
/// not being edited already. Every other document keeps the room for itself.
private func showEditButton(_ show: Bool) {
toolBar.items = show ? toolBarItems : toolBarItemsWithoutEdit
private func updateToolBar() {
toolBar.items = toolBarItems.filter { item in
if item === editButton || item === editButtonSpacer {
return canEdit
}
if item === searchButton || item === searchButtonSpacer {
return canSearch
}

return true
}
}

/// Offered for the documents that can be edited, and only while they are not
/// being edited already.
private func updateEditButton() {
showEditButton((document?.isEditable ?? false) && !(document?.edit ?? false))
canEdit = (document?.isEditable ?? false) && !(document?.edit ?? false)
}

/// Asked of the page rather than guessed from the format: odrcore writes the
/// `odr` object into what it renders as a document or as text, and into
/// nothing else — a pdf picks the button up on its own once it does.
private func updateSearchButton() {
webview.evaluateJavaScript("typeof odr === 'object' && typeof odr.search === 'function'") {
[weak self] available, _ in
self?.canSearch = available as? Bool ?? false
}
}

@IBAction func searchButton(_ sender: UIBarButtonItem) {
Expand Down Expand Up @@ -678,12 +719,12 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
let fileType = doc.fileURL.pathExtension.lowercased()

let fileName = doc.fileURL.absoluteString.lowercased()
if EXTENSION_WHITELIST.contains(where: fileName.hasSuffix) {
if systemRenderedExtensions.contains(where: fileName.hasSuffix) {
// not odrcore's to render, but the web view knows the format
self.webview.loadFileURL(doc.fileURL, allowingReadAccessTo: doc.fileURL)

searchButton.isEnabled = false
showEditButton(false)
canEdit = false
canSearch = false

AnalyticsManager.shared.report(
"load_success",
Expand Down Expand Up @@ -712,12 +753,9 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
progressBar.isHidden = false
progressBar.observedProgress = doc.loadProgress

// only what odrcore translated is searchable, and a later parse — after
// a password, say — may well get there
searchButton.isEnabled = true

// whether this one can be edited is not known until it is parsed
showEditButton(false)
// neither is known until the page it produces is loaded
canEdit = false
canSearch = false
}

func documentLoadingCompleted(_ doc: Document) {
Expand Down
2 changes: 2 additions & 0 deletions OpenDocumentReader/Main.storyboard
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,13 @@
<outlet property="bannerSlotHeight" destination="eXE-Ks-sQc" id="yQ1-l7-wiV"/>
<outlet property="barButtonItem" destination="yhO-7V-lbn" id="ach-z7-H9P"/>
<outlet property="editButton" destination="Ed1-tP-enc" id="Ed3-tP-enc"/>
<outlet property="editButtonSpacer" destination="Ed4-tP-enc" id="Ed5-tP-enc"/>
<outlet property="menuButton" destination="Vsd-IL-ujz" id="GoN-tp-egn"/>
<outlet property="pageTabBar" destination="sIx-zo-9kG" id="pCn-x0-Z01"/>
<outlet property="progressBar" destination="ePk-bQ-fV8" id="nz6-qw-ouf"/>
<outlet property="searchBar" destination="A5V-8n-dvk" id="AIb-1s-JO6"/>
<outlet property="searchButton" destination="fGi-i7-iOR" id="77J-cW-TAe"/>
<outlet property="searchButtonSpacer" destination="Di6-gg-JT6" id="Di7-gg-JT6"/>
<outlet property="toolBar" destination="N1x-QM-Qks" id="db4-aA-GH6"/>
<outlet property="webview" destination="VF5-Do-xh9" id="25W-hb-tHy"/>
</connections>
Expand Down
86 changes: 83 additions & 3 deletions OpenDocumentReaderTests/OpenDocumentReaderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,18 @@ class OpenDocumentReaderTests: XCTestCase {

/// Out of the read-only test bundle, and away from the temporary directory
/// translating uses for its cache and output.
private func copyFixture(ofType pathExtension: String) throws -> URL {
private func copyFixture(ofType pathExtension: String, named name: String = "test") throws -> URL {
let documentsURL = try FileManager.default.url(
for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: false)

let url = documentsURL.appendingPathComponent("test." + pathExtension)
let url = documentsURL.appendingPathComponent(name + "." + pathExtension)
try? FileManager.default.removeItem(at: url)

let bundlePath = try XCTUnwrap(
Bundle(for: Self.self).path(forResource: "test", ofType: pathExtension))
Bundle(for: Self.self).path(forResource: name, ofType: pathExtension))
try FileManager.default.copyItem(at: URL(fileURLWithPath: bundlePath), to: url)

return url
Expand Down Expand Up @@ -93,6 +93,86 @@ class OpenDocumentReaderTests: XCTestCase {
XCTAssertEqual(wrapper.pageNames, ["document"])
}

/// odrcore renders these now; they used to be handed to the web view.
func testPdfIsTranslated() throws {
let wrapper = CoreWrapper()
let url = try copyFixture(ofType: "pdf")

try wrapper.translate(
url.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false)

XCTAssertEqual(wrapper.pageNames, ["document"])
}

/// Its text has to reach the page, or there is nothing for search to walk.
func testPdfPageCarriesItsText() throws {
let wrapper = CoreWrapper()
let url = try copyFixture(ofType: "pdf")

try wrapper.translate(
url.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false)

let (data, _) = try fetch(try XCTUnwrap(wrapper.pageURLs.first))
let html = try XCTUnwrap(String(data: data, encoding: .utf8))

XCTAssertTrue(html.contains("First"), html)
XCTAssertTrue(html.contains("Second"), html)
}

/// `wrongPassword` rather than `unsupportedFileType`, because that is what
/// `Document.parse` turns into the prompt instead of the error page.
func testEncryptedPdfAsksForItsPassword() throws {
let wrapper = CoreWrapper()
let url = try copyFixture(ofType: "pdf", named: "test-encrypted")

for password in [nil, "wrong"] {
XCTAssertThrowsError(
try wrapper.translate(
url.path, cache: temporaryDirectory, into: temporaryDirectory, with: password, editable: false)
) { error in
XCTAssertEqual((error as NSError).code, CoreWrapperError.wrongPassword.rawValue)
}
}
}

func testEncryptedPdfOpensWithItsPassword() throws {
let wrapper = CoreWrapper()
let url = try copyFixture(ofType: "pdf", named: "test-encrypted")

try wrapper.translate(
url.path, cache: temporaryDirectory, into: temporaryDirectory, with: "secret", editable: false)

XCTAssertEqual(wrapper.pageNames, ["document"])

let (data, _) = try fetch(try XCTUnwrap(wrapper.pageURLs.first))
XCTAssertTrue(try XCTUnwrap(String(data: data, encoding: .utf8)).contains("First"))
}

func testPdfIsNotEditable() throws {
let wrapper = CoreWrapper()
let url = try copyFixture(ofType: "pdf")

try wrapper.translate(
url.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: true)

XCTAssertFalse(wrapper.isEditable)
}

/// A png is one of the formats odrcore translates but we leave to the system.
func testImageIsLeftToTheSystem() throws {
let wrapper = CoreWrapper()

let image = URL(fileURLWithPath: temporaryDirectory).appendingPathComponent("test.png")
try Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]).write(to: image)

XCTAssertThrowsError(
try wrapper.translate(
image.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false)
) { error in
XCTAssertEqual((error as NSError).code, CoreWrapperError.unsupportedFileType.rawValue)
}
}

/// And it has no document behind it, so the menu must not offer to edit one.
func testCsvIsNotEditable() throws {
let wrapper = CoreWrapper()
Expand Down
Loading