diff --git a/Ads/Linked.swift b/Ads/Linked.swift index 0ee1f6c..08906a1 100644 --- a/Ads/Linked.swift +++ b/Ads/Linked.swift @@ -1,2 +1,4 @@ /// Read through ``Features``. let LINKS_ADS = true +/// Read through ``Features``. +let ADVANCED_EDITING = false diff --git a/CHANGELOG.md b/CHANGELOG.md index e7ec6c6..bc57da9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,19 @@ once the version tag exists. ### Added - The name of the open document is shown at the top, between the buttons. +- Editing tools under the bar: bold, italic, underline, strikethrough, text + colour, highlight, text size, undo and redo. +- A PDF can be marked up with highlights, lines and drawings. +- Presentations, Excel files and plain text files can be edited and saved. + +### Changed + +- The engine is odrcore 7.1.0, up from 6.13.0. +- Entering an edit keeps your place in the document. +- The pencil turns editing on and off, and a new save button saves without + leaving the edit. +- Lite edits text inside a paragraph. Formatting, paragraphs and PDF marks are + part of Pro. ### Fixed diff --git a/NoAds/Linked.swift b/NoAds/Linked.swift index 512c141..76887f0 100644 --- a/NoAds/Linked.swift +++ b/NoAds/Linked.swift @@ -1,2 +1,4 @@ /// Read through ``Features``. let LINKS_ADS = false +/// Read through ``Features``. +let ADVANCED_EDITING = true diff --git a/OpenDocumentReader.xcodeproj/project.pbxproj b/OpenDocumentReader.xcodeproj/project.pbxproj index 2502410..627f082 100644 --- a/OpenDocumentReader.xcodeproj/project.pbxproj +++ b/OpenDocumentReader.xcodeproj/project.pbxproj @@ -830,7 +830,7 @@ repositoryURL = "https://github.com/opendocument-app/OpenDocument.core.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 6.13.0; + minimumVersion = 7.1.0; }; }; AD584FCD41577C8CDEE974AA /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */ = { diff --git a/OpenDocumentReader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/OpenDocumentReader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9feccd1..8ed0f6c 100644 --- a/OpenDocumentReader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/OpenDocumentReader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/opendocument-app/OpenDocument.core.git", "state" : { - "revision" : "1b1599864aefe78a23f01da07d3501e617810a05", - "version" : "6.13.0" + "revision" : "cee4ef5e7dbc4e781bb8459dc5269cded98f824a", + "version" : "7.1.0" } }, { diff --git a/OpenDocumentReader/CoreWrapper.swift b/OpenDocumentReader/CoreWrapper.swift index 743b18d..eaf19e5 100644 --- a/OpenDocumentReader/CoreWrapper.swift +++ b/OpenDocumentReader/CoreWrapper.swift @@ -84,11 +84,19 @@ private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [ /// Whether odrcore saw only a container, so the page is a listing of what is inside it. @objc private(set) var isArchive = false - /// Whether `backTranslate` has a document to apply an edit to. Only a - /// document that said it takes one is kept, so having it *is* the answer. - @objc var isEditable: Bool { lock.withLock { document != nil } } + /// Whether `save` has something to apply an edit to. Only a file that said + /// it takes one is kept, so having it *is* the answer. + @objc var isEditable: Bool { lock.withLock { document != nil || textFile != nil } } + + /// Whether the file is a pdf that takes marks. + @objc var isAnnotatable: Bool { lock.withLock { pdfFile != nil } } + + /// Whether the file is plain text, which takes typing but no formatting. + @objc var isPlainText: Bool { lock.withLock { textFile != nil } } private var document: OdrCoreObjC.Document? + private var textFile: TextFile? + private var pdfFile: PdfFile? private let lock = NSRecursiveLock() /// The largest sheet region translated, as on OpenDocument.droid. @@ -99,10 +107,10 @@ private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [ @objc func translate( _ inputPath: String, - cache cachePath: String, into outputPath: String, with password: String?, - editable: Bool + editable: Bool, + scope: HtmlEditingScope ) throws { lock.lock() defer { lock.unlock() } @@ -110,6 +118,8 @@ private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [ pageNames = [] pageURLs = [] document = nil + textFile = nil + pdfFile = nil isArchive = false let fileTypes = (try? DecodedFile.listFileTypes(path: inputPath)) ?? [] @@ -141,6 +151,9 @@ private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [ // decided from these let config = HtmlConfig() config.editable = editable + // how far an edit may reach: inside one paragraph, or across the + // document with formatting + config.editingScope = scope // resource paths are resolved relative to an output directory, and in // server mode there is none — odrcore rejects the combination config.relativeResourcePaths = false @@ -167,6 +180,8 @@ private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [ let documentType: DocumentType let openedDocument: OdrCoreObjC.Document? + var openedTextFile: TextFile? + var openedPdfFile: PdfFile? let service: HtmlService if file.isDocumentFile { @@ -177,15 +192,23 @@ private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [ // the document's own answer: a format odrcore renders but cannot write // back would otherwise offer Edit and fail at the save openedDocument = document.isEditable && document.isSavable ? document : nil - service = try HtmlTranslator.translate( - document: document, cachePath: cachePath, config: config) + service = try HtmlTranslator.translate(document: document, config: config) } else { - // nothing to edit, and `.unknown` keeps the single view each of - // these has - `.spreadsheet` would ask for a tab per sheet + // `.unknown` keeps the single view each of these has - + // `.spreadsheet` would ask for a tab per sheet documentType = .unknown openedDocument = nil - service = try HtmlTranslator.translate( - file: file, cachePath: cachePath, config: config) + + if file.isTextFile, let text = try? file.asTextFile(), text.isSavable { + openedTextFile = text + } + if file.isPdfFile, file.capabilities.annotate, let pdf = try? file.asPdfFile(), + pdf.isAnnotatable + { + openedPdfFile = pdf + } + + service = try HtmlTranslator.translate(file: file, config: config) } let views = selectViews(service.views, documentType) @@ -197,27 +220,31 @@ private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [ throw coreWrapperError(.unknown, "could not serve the translated document") } - // only once nothing can throw any more: backTranslate must not be handed - // a document whose pages were never served + // only once nothing can throw any more: a save must not be handed a + // file whose pages were never served self.document = openedDocument + self.textFile = openedTextFile + self.pdfFile = openedPdfFile isArchive = file.isArchiveFile pageNames = views.map(\.name) pageURLs = views.map { base.appendingPathComponent($0.path) } } - @objc func backTranslate(_ diff: String, into outputPath: String) throws { + /// The script the page hands its edits back through: the editor's log for + /// a document or a text file, the marks for a pdf. + @objc var editPayloadScript: String { + isAnnotatable ? "odr.annotation.getAnnotations()" : "odr.editing.getOperations()" + } + + /// Writes the file with `payload` applied - the page's operations, or its + /// marks for a pdf. + @objc func save(_ payload: String, into outputPath: String) throws { lock.lock() defer { lock.unlock() } - guard let document else { - throw coreWrapperError(.unknown, "no document has been translated yet") - } - - try HtmlTranslator.edit(document: document, diff: diff) - - // odrcore streams the parts the edit did not touch out of the file it opened, and - // truncates the destination first - so saving onto the open document empties it + // odrcore streams the parts an edit did not touch out of the file it opened, and + // truncates the destination first - so saving onto the open file empties it let output = URL(fileURLWithPath: outputPath) let staging = try stagingDirectory(for: output) @@ -225,11 +252,36 @@ private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [ let temporary = stagedFile(in: staging, for: output) - try document.save(to: temporary.path) + if let document { + if Self.holdsOperations(payload) { + try document.edit(operations: payload) + } + try document.save(to: temporary.path) + } else if let textFile { + try textFile.edit(operations: payload) + try textFile.save(to: temporary.path) + } else if let pdfFile { + try pdfFile.annotate(payload).write(to: temporary) + } else { + throw coreWrapperError(.unknown, "no editable file has been translated yet") + } try moveIntoPlace(from: temporary, to: output) } + /// Whether the envelope carries any operation: an empty one is a save of + /// the file as it is. + private static func holdsOperations(_ payload: String) -> Bool { + guard let data = payload.data(using: .utf8), + let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let ops = envelope["ops"] as? [Any] + else { + return true + } + + return !ops.isEmpty + } + /// A directory on `output`'s own volume, because `replaceItemAt` cannot swap across one. /// The caller has to delete it. private func stagingDirectory(for output: URL) throws -> URL { diff --git a/OpenDocumentReader/Document.swift b/OpenDocumentReader/Document.swift index 912f1b7..d067262 100644 --- a/OpenDocumentReader/Document.swift +++ b/OpenDocumentReader/Document.swift @@ -8,6 +8,11 @@ protocol DocumentDelegate: AnyObject { func documentLoadingStarted(_ doc: Document) func documentLoadingCompleted(_ doc: Document) func documentPagesChanged(_ doc: Document) + /// The edit mode was turned on. The page is the one already on screen. + func documentEditingStarted(_ doc: Document) + /// The edit mode was turned off, and the page on screen stays: it holds + /// nothing the file does not. + func documentEditingEnded(_ doc: Document) } enum DocumentError: Error { @@ -39,12 +44,34 @@ class Document: UIDocument { parse() } } + /// Entering an edit turns it on in the page. Leaving renders the file + /// again, unless ``endEdit(renderingAgain:)`` says not to. public var edit = false { didSet { - parse() + if edit { + notify { $0.documentEditingStarted(self) } + } else if rendersAgainOnLeave { + parse() + } else { + notify { $0.documentEditingEnded(self) } + } } } + private var rendersAgainOnLeave = true + + /// Leaves the edit. Without `renderingAgain` only the mode goes off. + func endEdit(renderingAgain: Bool) { + rendersAgainOnLeave = renderingAgain + edit = false + rendersAgainOnLeave = true + } + + /// Renders the file again and keeps the mode: what a save stays in. + func reload() { + parse() + } + public var webview: WKWebView? public var isOdf = false @@ -52,6 +79,10 @@ class Document: UIDocument { public var isArchive = false /// Whether the menu should offer to edit this one - see `CoreWrapper.isEditable`. public var isEditable = false + /// Whether this is a pdf that takes marks - see `CoreWrapper.isAnnotatable`. + public var isAnnotatable = false + /// Whether this is plain text - see `CoreWrapper.isPlainText`. + public var isPlainText = false private var wasPageCountAnnounced = false override func load(fromContents contents: Any, ofType typeName: String?) throws { @@ -66,19 +97,19 @@ class Document: UIDocument { isOdf = false isArchive = false isEditable = false + isAnnotatable = false + isPlainText = false result = nil pageURLs = nil notify { $0.documentUpdateContent(self) } - let temporaryDirectory = NSTemporaryDirectory() - do { try coreWrapper.translate( fileURL.path, - cache: temporaryDirectory, - into: temporaryDirectory, + into: NSTemporaryDirectory(), with: password, - editable: edit + editable: true, + scope: Features.advancedEditing ? .document : .paragraph ) } catch let error as NSError where error.domain == CoreWrapperErrorDomain @@ -96,6 +127,8 @@ class Document: UIDocument { isOdf = true isArchive = coreWrapper.isArchive isEditable = coreWrapper.isEditable + isAnnotatable = coreWrapper.isAnnotatable + isPlainText = coreWrapper.isPlainText loadProgress.completedUnitCount = loadProgress.totalUnitCount @@ -152,12 +185,12 @@ class Document: UIDocument { override func writeContents( _ contents: Any, to url: URL, for saveOperation: UIDocument.SaveOperation, originalContentsURL: URL? ) throws { - let diff = try generateDiff() + let payload = try collectEdits() // the document handle CoreWrapper holds is only valid together with the - // web view that produced the diff, so the edit stays on the main thread + // web view that produced the edits, so the save stays on the main thread try onMainThread { - try coreWrapper.backTranslate(diff, into: url.path) + try coreWrapper.save(payload, into: url.path) } } @@ -173,9 +206,10 @@ class Document: UIDocument { /// Blocks the calling save thread until the web view has handed back the /// edits the user made. - private func generateDiff() throws -> String { + private func collectEdits() throws -> String { let semaphore = DispatchSemaphore(value: 0) var result: Result = .failure(DocumentError.getHtml) + let script = coreWrapper.editPayloadScript DispatchQueue.main.async { guard let webview = self.webview else { @@ -185,7 +219,7 @@ class Document: UIDocument { return } - webview.evaluateJavaScript("odr.generateDiff()") { value, error in + webview.evaluateJavaScript(script) { value, error in defer { semaphore.signal() } if let error { diff --git a/OpenDocumentReader/DocumentViewController.swift b/OpenDocumentReader/DocumentViewController.swift index 0e48413..843efd7 100644 --- a/OpenDocumentReader/DocumentViewController.swift +++ b/OpenDocumentReader/DocumentViewController.swift @@ -13,7 +13,8 @@ import WebKit // taken from: https://developer.apple.com/documentation/uikit/view_controllers/building_a_document_browser-based_app class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDelegate, - SKStoreProductViewControllerDelegate, WKNavigationDelegate, WKUIDelegate + SKStoreProductViewControllerDelegate, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, + UIColorPickerViewControllerDelegate { private var browserTransition: DocumentBrowserTransitioningDelegate? @@ -37,6 +38,8 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel } @IBOutlet weak var toolBar: UIToolbar! + /// The bar and what hangs under it: the editing tools, the progress line. + @IBOutlet weak var barStack: UIStackView! @IBOutlet weak var searchBar: UISearchBar! @IBOutlet weak var pageTabBar: PageTabBar! @@ -65,10 +68,62 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel /// 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() } } - /// The same slot the pencil sits in, showing the way out of the edit it - /// started — as on OpenDocument.droid, where edit mode replaces the bar - /// rather than emptying it. - private var isEditingDocument = false { didSet { updateEditButtonRole() } } + /// Whether the document is a pdf that takes marks. + private var canMark = false { didSet { updateEditButtonRole() } } + private var isEditingDocument = false { + didSet { + updateEditButtonRole() + updateToolBar() + + if !isEditingDocument { + editToolBar.layout = nil + } + } + } + + /// Saves the edit, and stays in it. Only in the bar while editing. + lazy var saveButton: UIBarButtonItem = { + let item = UIBarButtonItem( + image: UIImage(systemName: "square.and.arrow.down"), style: .plain, target: self, + action: #selector(saveTapped(_:))) + item.accessibilityLabel = NSLocalizedString("action_edit_save", comment: "") + item.isEnabled = false + + return item + }() + + private lazy var saveButtonSpacer: UIBarButtonItem = { + let item = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil) + item.width = 10 + + return item + }() + + /// The row of tools under the bar while a document is edited. + let editToolBar = EditToolBar() + + /// The colour each marker on a pdf takes, until the reader picks another. + private var markColors: [EditToolBar.Tool: UIColor] = [:] + + /// The colour the highlight button turns on; the selection's own where it + /// has one. + private var highlightColor = UIColor(hex: EditToolBar.highlightColors[0].hex) + + /// Whether the selection shows a highlight, as the page last said. + private var selectionHasHighlight = false + + /// Which menu the system colour picker was opened from. + private var colorPickerTool: EditToolBar.Tool? + + /// Whether the Pro offer was shown during this edit, so it shows once. + private var hasOfferedProForThisEdit = false + + /// How many formula cells the edits so far left out of date; said each + /// time the number grows. + private var staleCells = 0 + + /// Set by a save, so the page that loads next is put back into the mode. + private var resumesEditAfterLoad = false private var canSearch = false { didSet { updateToolBar() @@ -124,6 +179,9 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel searchBarHeightWhenShown = searchBar.heightAnchor.constraint(equalToConstant: 56) searchBarHeightWhenHidden = searchBar.heightAnchor.constraint(equalToConstant: 0) + setUpEditToolBar() + setUpPageMessages() + setVCconstraints() hideSearchBar() @@ -132,6 +190,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel barButtonItem.accessibilityLabel = NSLocalizedString("back_to_documents", comment: "") updateEditButtonRole() + setUpSaveButton() setUpDocumentTitle() // nothing is editable or searchable until a page says so @@ -257,6 +316,15 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { updateSearchButton() + // a save renders the file again, and the edit goes on in the new page + if let documentNavigation, navigation === documentNavigation, resumesEditAfterLoad { + resumesEditAfterLoad = false + + if document?.edit == true { + beginEditSession() + } + } + // the document is drawn, which is what a screenshot of it waits for - // and only the document: the "loading" page finishes first, and a // picture of it is a picture of the word "loading" @@ -291,13 +359,10 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel return case .edit: - // Entering an edit reloads the page as editable, so this comes back - // here a second time - and that pass is the one worth photographing. - guard document?.edit == true else { - editDocument() + // ready once the tools are up, which `beginEditSession` says + editDocument() - return - } + return default: break @@ -382,7 +447,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true searchBar.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true - searchBar.topAnchor.constraint(equalTo: toolBar.bottomAnchor, constant: Self.toolBarBottomMargin).isActive = + searchBar.topAnchor.constraint(equalTo: barStack.bottomAnchor, constant: Self.toolBarBottomMargin).isActive = true bannerSlot.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true @@ -497,23 +562,398 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel findAll(searchText: searchText) } - /// One button, both ways: the pencil starts an edit and the save glyph ends - /// it. See ``updateEditButtonRole()``. - @IBAction func editOrSave(_ sender: UIBarButtonItem) { + /// The pen: turns the mode on, and off again. Leaving with changes the page + /// alone holds asks first. See ``updateEditButtonRole()``. + @IBAction func toggleEdit(_ sender: UIBarButtonItem) { if isEditingDocument { - // the file holds the edit once it is written, so leaving edit mode - // reads back what was saved. A save that failed stays in the edit, - // which is the only place that text still exists. - saveContent { success in - guard success else { return } - - self.document?.edit = false - } + leaveEdit() + } else if canMark, !Features.advancedEditing { + offerPro(.pdf) } else { editDocument() } } + /// Without changes the page on screen is the file, so the mode only goes + /// off. With them: save, discard, or stay. + func leaveEdit() { + guard hasUnsavedEdits else { + document?.endEdit(renderingAgain: false) + + return + } + + AnalyticsManager.shared.report("show_alert_unsaved_changes") + + let alert = UIAlertController( + title: NSLocalizedString("alert_unsaved_changes", comment: ""), + message: NSLocalizedString("alert_save_now", comment: ""), preferredStyle: .alert) + alert.addAction( + UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .cancel)) + alert.addAction( + UIAlertAction( + title: NSLocalizedString("no", comment: ""), style: .destructive, + handler: { _ in + AnalyticsManager.shared.report("alert_unsaved_changes_no") + + self.discardChanges() + })) + alert.addAction( + UIAlertAction( + title: NSLocalizedString("yes", comment: ""), style: .default, + handler: { _ in + AnalyticsManager.shared.report("alert_unsaved_changes_yes") + + // the file holds the edit once it is written, so leaving + // reads back what was saved + self.saveContent { success in + guard success else { return } + + self.document?.endEdit(renderingAgain: true) + } + })) + + present(alert, animated: true) + } + + /// Saves, and stays in the edit. + @objc func saveTapped(_ sender: UIBarButtonItem) { + saveAndStay() + } + + func saveAndStay(completion: ((Bool) -> Void)? = nil) { + saveContent { success in + if success { + self.resumesEditAfterLoad = true + self.document?.reload() + } + + completion?(success) + } + } + + // MARK: - the editing tools + + /// Under the bar and above the progress line, so it reads as part of the bar. + private func setUpEditToolBar() { + editToolBar.layout = nil + editToolBar.advancedEditing = Features.advancedEditing + editToolBar.onTap = { [weak self] tool in self?.editToolTapped(tool) } + editToolBar.onChoice = { [weak self] tool, choice in self?.editToolChose(tool, choice) } + + barStack.insertArrangedSubview(editToolBar, at: 1) + } + + /// Hears from the page: the log, a refusal, the style under the caret. + private func setUpPageMessages() { + let controller = webview.configuration.userContentController + + controller.add(WeakScriptMessageHandler(self), name: Self.pageMessageName) + controller.addUserScript( + WKUserScript(source: Self.pageMessageBridge, injectionTime: .atDocumentEnd, forMainFrameOnly: true)) + } + + private static let pageMessageName = "odr" + + /// Points the page's callbacks at this controller. It runs at document + /// end, after the page's own scripts. + private static let pageMessageBridge = """ + (function () { + if (typeof odr !== 'object' || !window.webkit || !webkit.messageHandlers.odr) { return; } + var post = function (message) { webkit.messageHandlers.odr.postMessage(message); }; + odr.onEditChange = function (e) { + post({ type: 'editChange', dirty: !!e.dirty, canUndo: !!e.canUndo, canRedo: !!e.canRedo }); + }; + odr.onEditRefused = function (e) { + post({ type: 'editRefused', reason: String(e.reason || ''), message: String(e.message || '') }); + }; + odr.onSelectionChange = function (style) { + post({ type: 'selection', style: style || {} }); + }; + odr.onCellsStale = function (detail) { + post({ type: 'cellsStale', count: detail && detail.cells ? detail.cells.length : 0 }); + }; + if (!odr.annotation) { return; } + // an armed tool marks a selection as it is made, which is what a + // touch screen needs + odr.annotation.setOptions({ markOnSelection: true }); + odr.onAnnotationChange = function (e) { + post({ type: 'marks', count: e && e.count ? e.count : 0 }); + }; + })(); + """ + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let body = message.body as? [String: Any], let type = body["type"] as? String else { return } + + switch type { + case "editChange": + hasUnsavedEdits = body["dirty"] as? Bool ?? false + editToolBar.setEnabled(.undo, body["canUndo"] as? Bool ?? false) + editToolBar.setEnabled(.redo, body["canRedo"] as? Bool ?? false) + + case "marks": + let count = body["count"] as? Int ?? 0 + hasUnsavedEdits = count > 0 + editToolBar.setEnabled(.undo, count > 0) + + case "cellsStale": + let count = body["count"] as? Int ?? 0 + if count > staleCells { + let message = String.localizedStringWithFormat( + NSLocalizedString("edit_cells_stale", comment: ""), count) + showToast(controller: self, message: message, seconds: 3) + } + staleCells = count + + case "editRefused": + editRefused(reason: body["reason"] as? String ?? "") + + case "selection": + let style = body["style"] as? [String: Any] ?? [:] + editToolBar.setPressed(.bold, style["bold"] as? Bool ?? false) + editToolBar.setPressed(.italic, style["italic"] as? Bool ?? false) + editToolBar.setPressed(.underline, style["underline"] as? Bool ?? false) + editToolBar.setPressed(.strikethrough, style["strikethrough"] as? Bool ?? false) + + // the bars and the size follow the selection, as the website's do + if let color = style["color"] as? String { + editToolBar.setColor(.textColor, UIColor(hex: color)) + } + let highlight = style["highlight"] as? String + selectionHasHighlight = highlight != nil + editToolBar.setPressed(.highlight, selectionHasHighlight) + if let highlight { + highlightColor = UIColor(hex: highlight) + editToolBar.setColor(.highlight, highlightColor) + } + editToolBar.setFontSize( + (style["size"] as? String).map { $0.hasSuffix("pt") ? String($0.dropLast(2)) : $0 }) + + default: + break + } + } + + /// Turns the mode on in the page already on screen and shows its tools. + /// A pdf needs no mode, only a marker that acts on a selection. + private func beginEditSession() { + hasOfferedProForThisEdit = false + hasUnsavedEdits = false + staleCells = 0 + selectionHasHighlight = false + + if document?.isAnnotatable == true { + editToolBar.layout = .pdf + editToolBar.setEnabled(.undo, false) + for tool in EditToolBar.Layout.pdf.tools where tool.showsColor { + editToolBar.setColor(tool, markColor(of: tool)) + } + showToast(controller: self, message: NSLocalizedString("mark_hint", comment: ""), seconds: 2) + editSessionReady() + + return + } + + editToolBar.setEnabled(.undo, false) + editToolBar.setEnabled(.redo, false) + + let isPlainText = document?.isPlainText == true + + webview.evaluateJavaScript("odr.editing.enable(); typeof odr.sheet === 'object'") { [weak self] isSheet, _ in + guard let self, self.isEditingDocument else { return } + + self.editToolBar.layout = isPlainText || isSheet as? Bool == true ? .plain : .text + self.editToolBar.setColor(.highlight, self.highlightColor) + self.editSessionReady() + } + } + + /// The tools are up, which is what a screenshot of an edit waits for. + private func editSessionReady() { + if ScreenshotMode.screen == .edit { + ScreenshotMode.markReady(view) + } + } + + /// Whether the page holds edits or marks that only it has, which leaving + /// would lose. + private var hasUnsavedEdits = false { + didSet { + saveButton.isEnabled = hasUnsavedEdits + } + } + + private func editToolTapped(_ tool: EditToolBar.Tool) { + if tool.isAdvanced, !Features.advancedEditing { + offerPro(canMark ? .pdf : .formatting) + + return + } + + switch tool { + case .undo: + run(canMark ? "odr.annotation.undo()" : "odr.editing.undo()") + case .redo: + run("odr.editing.redo()") + case .bold, .italic, .underline, .strikethrough: + run("odr.editing.toggle('\(tool.pageName ?? "")')") + case .highlight: + // off where the selection shows one, else on in the current colour + run( + "odr.editing.format({ highlight: \(selectionHasHighlight ? "null" : "'\(highlightColor.hexString)'") })" + ) + case .markHighlight, .markUnderline, .markStrikeOut, .markSquiggly, .markDraw: + pressMarker(tool, recolor: false) + default: + break + } + } + + private func markColor(of tool: EditToolBar.Tool) -> UIColor { + markColors[tool] ?? UIColor(hex: tool.defaultColor ?? EditToolBar.markColors[0].hex) + } + + /// A tap on a marker, or a new colour for it (`recolor`). + private func pressMarker(_ tool: EditToolBar.Tool, recolor: Bool) { + guard let name = tool.pageName else { return } + + let script = + "odr.annotation.\(recolor ? "recolor" : "press")('\(name)', { color: \(markColor(of: tool).deviceRGB), width: 2 })" + + webview.evaluateJavaScript(script) { [weak self] armed, error in + if let error { + CrashManager.shared.log(error) + } + + self?.showArmedMarker(armed as? String) + } + } + + private func showArmedMarker(_ armed: String?) { + for tool in EditToolBar.Layout.pdf.tools { + editToolBar.setPressed(tool, tool.pageName != nil && tool.pageName == armed) + } + } + + private func editToolChose(_ tool: EditToolBar.Tool, _ choice: EditToolBar.Choice) { + switch (tool, choice) { + case (.fontSize, .size(let size)): + run("odr.editing.format({ size: '\(size)pt' })") + case (.textColor, .color(let hex)): + run("odr.editing.format({ color: '\(hex ?? "")' })") + case (.highlight, .color(let hex)): + // a colour becomes the one the button turns on; none takes it off + if let hex { + highlightColor = UIColor(hex: hex) + editToolBar.setColor(.highlight, highlightColor) + } + run("odr.editing.format({ highlight: \(hex.map { "'\($0)'" } ?? "null") })") + case (.markHighlight, .color(let hex)), (.markUnderline, .color(let hex)), + (.markStrikeOut, .color(let hex)), (.markSquiggly, .color(let hex)), (.markDraw, .color(let hex)): + markColors[tool] = UIColor(hex: hex ?? tool.defaultColor ?? EditToolBar.markColors[0].hex) + editToolBar.setColor(tool, markColor(of: tool)) + pressMarker(tool, recolor: true) + case (_, .customColor): + colorPickerTool = tool + + let picker = UIColorPickerViewController() + picker.delegate = self + picker.supportsAlpha = false + switch tool { + case .highlight: picker.selectedColor = highlightColor + case .textColor: picker.selectedColor = .label + default: picker.selectedColor = markColor(of: tool) + } + present(picker, animated: true) + default: + break + } + } + + func colorPickerViewControllerDidFinish(_ viewController: UIColorPickerViewController) { + guard let tool = colorPickerTool else { return } + colorPickerTool = nil + + editToolChose(tool, .color(viewController.selectedColor.hexString)) + } + + /// The page refused an edit. In Lite, an edit out of scope offers Pro. + private func editRefused(reason: String) { + if reason == "outOfScope", !Features.advancedEditing { + guard !hasOfferedProForThisEdit else { return } + hasOfferedProForThisEdit = true + + offerPro(.formatting) + + return + } + + let key: String + switch reason { + case "newLine": key = "edit_refused_new_line" + case "formula": key = "edit_refused_formula" + case "formulaInput": key = "edit_refused_formula_input" + case "rich", "shapes": key = "edit_refused_rich" + case "readOnly": key = "edit_refused_read_only" + case "range": key = "edit_refused_range" + default: key = "edit_refused_generic" + } + + AnalyticsManager.shared.report("edit_refused", parameters: ["reason": reason]) + + showToast(controller: self, message: NSLocalizedString(key, comment: ""), seconds: 1.5) + } + + /// What Pro adds, as the reader runs into it. + enum ProFeature { + case formatting + case pdf + + var message: String { + switch self { + case .formatting: return NSLocalizedString("pro_feature_formatting", comment: "") + case .pdf: return NSLocalizedString("pro_feature_pdf", comment: "") + } + } + } + + /// Says what Pro is for, and leads to it. The Lite app's one gate. + func offerPro(_ feature: ProFeature) { + AnalyticsManager.shared.report("pro_gate_shown", parameters: ["feature": "\(feature)"]) + + let alert = UIAlertController( + title: NSLocalizedString("pro_feature_title", comment: ""), + message: feature.message, + preferredStyle: .alert) + alert.addAction( + UIAlertAction(title: NSLocalizedString("not_now", comment: ""), style: .cancel)) + alert.addAction( + UIAlertAction(title: NSLocalizedString("house_ad_cta_get_pro", comment: ""), style: .default) { _ in + AnalyticsManager.shared.report("pro_gate_tapped", parameters: ["feature": "\(feature)"]) + + self.openProOnAppStore() + }) + + present(alert, animated: true) + } + + /// A script whose answer nobody needs. + private func run(_ script: String) { + webview.evaluateJavaScript(script) { _, error in + if let error { + CrashManager.shared.log(error) + } + } + } + + /// The disc goes after the pen, as on the website. + private func setUpSaveButton() { + guard let pen = toolBarItems.firstIndex(where: { $0 === editButtonSpacer }) else { return } + + toolBarItems.insert(contentsOf: [saveButton, saveButtonSpacer], at: pen + 1) + } + /// A gap either side of the name, which is what puts it in the middle. private func setUpDocumentTitle() { // a glass capsule is what a button looks like, and this is not one @@ -574,6 +1014,9 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel if item === editButton || item === editButtonSpacer { return canEdit } + if item === saveButton || item === saveButtonSpacer { + return canEdit && isEditingDocument + } if item === searchButton || item === searchButtonSpacer { return canSearch } @@ -585,16 +1028,17 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel /// Offered for the documents that can be edited, whether or not one is being /// edited right now — the button is the way both into an edit and out of it. private func updateEditButton() { - canEdit = document?.isEditable ?? false + canMark = document?.isAnnotatable ?? false + canEdit = (document?.isEditable ?? false) || canMark isEditingDocument = document?.edit ?? false } - /// A pencil to start an edit, and the save glyph to write one. The label goes - /// with it: VoiceOver reads that, not the glyph. + /// A pencil to edit, a highlighter to mark a pdf, selected while editing. + /// VoiceOver reads the label, not the glyph. private func updateEditButtonRole() { - editButton.image = UIImage(systemName: isEditingDocument ? "square.and.arrow.down" : "pencil") - editButton.accessibilityLabel = NSLocalizedString( - isEditingDocument ? "action_edit_save" : "menu_edit", comment: "") + editButton.image = UIImage(systemName: canMark ? "highlighter" : "pencil") + editButton.accessibilityLabel = NSLocalizedString(canMark ? "mark_pdf" : "menu_edit", comment: "") + editButton.isSelected = isEditingDocument } /// Asked of the page rather than guessed from the format: odrcore writes the @@ -670,7 +1114,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel return } - if doc.edit { + if doc.edit, hasUnsavedEdits { let alert = UIAlertController( title: NSLocalizedString("alert_unsaved_changes", comment: ""), message: NSLocalizedString("alert_save_now", comment: ""), preferredStyle: .alert) @@ -848,6 +1292,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel corePageInReserve = url canEdit = false + canMark = false canSearch = false documentNavigation = webview.loadFileURL(doc.fileURL, allowingReadAccessTo: doc.fileURL) @@ -981,6 +1426,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel // neither is known until the page it produces is loaded canEdit = false + canMark = false canSearch = false } @@ -1001,6 +1447,19 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel ]) } + func documentEditingStarted(_ doc: Document) { + isEditingDocument = true + beginEditSession() + } + + func documentEditingEnded(_ doc: Document) { + run( + "if (window.odr) { if (odr.editing) { odr.editing.disable(); } if (odr.annotation) { odr.annotation.setTool(null); } }" + ) + view.endEditing(true) + isEditingDocument = false + } + func documentPagesChanged(_ doc: Document) { let pageNames = doc.pageNames ?? [] @@ -1078,3 +1537,17 @@ extension UIViewController { UIApplication.shared.open(url) } } + +/// The web view's content controller holds its handlers strongly, and this +/// controller holds the web view: a weak step in between breaks the cycle. +private final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler { + private weak var handler: WKScriptMessageHandler? + + init(_ handler: WKScriptMessageHandler) { + self.handler = handler + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + handler?.userContentController(userContentController, didReceive: message) + } +} diff --git a/OpenDocumentReader/EditToolBar.swift b/OpenDocumentReader/EditToolBar.swift new file mode 100644 index 0000000..288107d --- /dev/null +++ b/OpenDocumentReader/EditToolBar.swift @@ -0,0 +1,546 @@ +import UIKit + +/// The row of editing tools under the bar, shown while a document is edited. +final class EditToolBar: UIView { + + /// One button of the row. + enum Tool: CaseIterable { + case bold, italic, underline, strikethrough + case textColor, highlight, fontSize + case markHighlight, markUnderline, markStrikeOut, markSquiggly, markDraw + case undo, redo + + var symbol: String { + switch self { + case .bold: return "bold" + case .italic: return "italic" + case .underline: return "underline" + case .strikethrough: return "strikethrough" + case .textColor: return "character" + case .highlight, .markHighlight: return "highlighter" + case .fontSize: return "textformat.size" + case .markUnderline: return "underline" + case .markStrikeOut: return "strikethrough" + case .markSquiggly: return "scribble.variable" + case .markDraw: return "pencil.tip" + case .undo: return "arrow.uturn.backward" + case .redo: return "arrow.uturn.forward" + } + } + + var label: String { + switch self { + case .bold: return NSLocalizedString("edit_bold", comment: "") + case .italic: return NSLocalizedString("edit_italic", comment: "") + case .underline: return NSLocalizedString("edit_underline", comment: "") + case .strikethrough: return NSLocalizedString("edit_strikethrough", comment: "") + case .textColor: return NSLocalizedString("edit_text_color", comment: "") + case .highlight: return NSLocalizedString("edit_highlight", comment: "") + case .fontSize: return NSLocalizedString("edit_font_size", comment: "") + case .markHighlight: return NSLocalizedString("mark_highlight", comment: "") + case .markUnderline: return NSLocalizedString("mark_underline", comment: "") + case .markStrikeOut: return NSLocalizedString("mark_strike_out", comment: "") + case .markSquiggly: return NSLocalizedString("mark_squiggly", comment: "") + case .markDraw: return NSLocalizedString("mark_draw", comment: "") + case .undo: return NSLocalizedString("edit_undo", comment: "") + case .redo: return NSLocalizedString("edit_redo", comment: "") + } + } + + /// The tool's own name in the page: what `odr.editing.toggle` and + /// `odr.annotation.setTool` take. + var pageName: String? { + switch self { + case .bold: return "bold" + case .italic: return "italic" + case .underline: return "underline" + case .strikethrough: return "strikethrough" + case .markHighlight: return "highlight" + case .markUnderline: return "underline" + case .markStrikeOut: return "strikeOut" + case .markSquiggly: return "squiggly" + case .markDraw: return "ink" + default: return nil + } + } + + /// The tools that go past typing inside a paragraph - see + /// ``Features/advancedEditing``. + var isAdvanced: Bool { + switch self { + case .undo, .redo: return false + default: return true + } + } + + /// Whether the button opens a menu rather than acting at once. + var opensMenu: Bool { + switch self { + case .textColor, .fontSize: return true + default: return false + } + } + + /// Whether a bar under the icon shows the colour the tool applies. + var showsColor: Bool { + switch self { + case .textColor, .highlight, .markHighlight, .markUnderline, .markStrikeOut, .markSquiggly, + .markDraw: + return true + default: return false + } + } + + /// A split button: the tool acts, and the chevron beside it picks the + /// colour it acts with. + var hasColorChevron: Bool { + showsColor && self != .textColor + } + + /// The colours the tool's menu offers. + var swatches: [Swatch] { + switch self { + case .textColor: return EditToolBar.textColors + case .highlight: return EditToolBar.highlightColors + default: return EditToolBar.markColors + } + } + + /// The colour the tool starts with. + var defaultColor: String? { + switch self { + case .textColor: return EditToolBar.textColors[0].hex + case .highlight: return EditToolBar.highlightColors[0].hex + case .markHighlight: return "#ffe633" + case .markUnderline, .markStrikeOut, .markSquiggly: return "#e53935" + case .markDraw: return "#1e88e5" + default: return nil + } + } + } + + /// What the row holds, by what the page is. + enum Layout { + /// a text document or a presentation + case text + /// a spreadsheet or a plain text file: nothing to format + case plain + /// a pdf, which takes marks + case pdf + + var tools: [Tool] { + switch self { + case .text: + return [ + .bold, .italic, .underline, .strikethrough, .textColor, .highlight, .fontSize, + .undo, .redo, + ] + case .plain: + return [.undo, .redo] + case .pdf: + return [.markHighlight, .markUnderline, .markStrikeOut, .markSquiggly, .markDraw, .undo] + } + } + } + + /// A pick from one of the menus. + enum Choice { + /// `#rrggbb`, or nil for no highlight + case color(String?) + /// the system picker, for a colour the menu does not list + case customColor + /// in points + case size(Int) + } + + /// A colour the menus offer. + struct Swatch { + let name: String + let hex: String + } + + // the same colours as the website and OpenDocument.droid + static let textColors = [ + Swatch(name: "color_black", hex: "#191c1e"), + Swatch(name: "color_red", hex: "#e53935"), + Swatch(name: "color_blue", hex: "#1e88e5"), + Swatch(name: "color_green", hex: "#43a047"), + ] + + static let highlightColors = [ + Swatch(name: "color_yellow", hex: "#fff59d"), + Swatch(name: "color_green", hex: "#c5e1a5"), + Swatch(name: "color_pink", hex: "#f8bbd0"), + Swatch(name: "color_blue", hex: "#b3e5fc"), + ] + + static let markColors = [ + Swatch(name: "color_yellow", hex: "#ffe633"), + Swatch(name: "color_red", hex: "#e53935"), + Swatch(name: "color_blue", hex: "#1e88e5"), + Swatch(name: "color_green", hex: "#43a047"), + ] + + static let fontSizes = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48] + + static let height: CGFloat = 44 + + /// The tools shown; nil shows none and hides the row. + var layout: Layout? { + didSet { + rebuild() + } + } + + /// Whether the advanced tools act. Without it they are shown behind a + /// "Pro" badge, and a tap goes to `onTap`, which says what Pro is. + var advancedEditing = true { + didSet { + rebuild() + } + } + + var onTap: ((Tool) -> Void)? + var onChoice: ((Tool, Choice) -> Void)? + + private let scrollView = UIScrollView() + private let stack = UIStackView() + private var buttons: [Tool: UIButton] = [:] + private var bars: [Tool: UIView] = [:] + + override init(frame: CGRect) { + super.init(frame: frame) + + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + + configure() + } + + private func configure() { + backgroundColor = .secondarySystemBackground + + scrollView.showsHorizontalScrollIndicator = false + scrollView.alwaysBounceHorizontal = false + scrollView.translatesAutoresizingMaskIntoConstraints = false + addSubview(scrollView) + + stack.axis = .horizontal + stack.spacing = 4 + stack.alignment = .center + stack.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(stack) + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + scrollView.heightAnchor.constraint(equalToConstant: Self.height), + + stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 8), + stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -8), + stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + stack.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + ]) + } + + private func rebuild() { + for view in stack.arrangedSubviews { + stack.removeArrangedSubview(view) + view.removeFromSuperview() + } + buttons = [:] + bars = [:] + + guard let layout else { + isHidden = true + + return + } + + isHidden = false + + if !advancedEditing, layout.tools.contains(where: \.isAdvanced) { + stack.addArrangedSubview(makeBadge()) + } + + for tool in layout.tools { + let button = makeButton(for: tool) + buttons[tool] = button + + guard tool.hasColorChevron else { + stack.addArrangedSubview(button) + + continue + } + + let pair = UIStackView(arrangedSubviews: [button, makeChevron(for: tool)]) + pair.axis = .horizontal + pair.spacing = 0 + pair.alignment = .center + stack.addArrangedSubview(pair) + } + } + + /// Says the tools behind it are Pro's. + private func makeBadge() -> UIView { + let label = UILabel() + label.text = NSLocalizedString("tool_pro_badge", comment: "") + label.font = UIFont.preferredFont(forTextStyle: .caption1).withWeight(.semibold) + label.textColor = .white + label.backgroundColor = tintColor + label.textAlignment = .center + label.layer.cornerRadius = 8 + label.clipsToBounds = true + label.accessibilityIdentifier = "edit-tool-pro" + + NSLayoutConstraint.activate([ + label.heightAnchor.constraint(equalToConstant: 20), + label.widthAnchor.constraint(greaterThanOrEqualToConstant: 36), + ]) + + return label + } + + private func makeButton(for tool: Tool) -> UIButton { + var configuration = UIButton.Configuration.plain() + configuration.image = UIImage(systemName: tool.symbol) + configuration.cornerStyle = .capsule + configuration.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 10) + + let button = UIButton(configuration: configuration) + button.accessibilityLabel = tool.label + button.accessibilityIdentifier = "edit-tool-\(tool.symbol)" + // filled while pressed + button.configurationUpdateHandler = { button in + var configuration = button.configuration + if button.isSelected { + configuration?.background.backgroundColor = button.tintColor + configuration?.baseForegroundColor = .white + } else { + configuration?.background.backgroundColor = .clear + configuration?.baseForegroundColor = button.tintColor + } + button.configuration = configuration + } + + if tool.showsColor { + addBar(to: button, for: tool) + } + + if tool.opensMenu, advancedEditing || !tool.isAdvanced { + button.menu = makeMenu(for: tool) + button.showsMenuAsPrimaryAction = true + } else { + button.addAction( + UIAction { [weak self] _ in + self?.onTap?(tool) + }, for: .touchUpInside) + } + + return button + } + + /// The half of a split button that picks the tool's colour. + private func makeChevron(for tool: Tool) -> UIButton { + var configuration = UIButton.Configuration.plain() + configuration.image = UIImage( + systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(scale: .small)) + configuration.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 6) + + let chevron = UIButton(configuration: configuration) + chevron.accessibilityLabel = String( + format: NSLocalizedString("edit_color_of", comment: ""), tool.label) + chevron.accessibilityIdentifier = "edit-tool-\(tool.symbol)-color" + + if advancedEditing || !tool.isAdvanced { + chevron.menu = colorMenu(for: tool, swatches: tool.swatches, offersNone: tool == .highlight) + chevron.showsMenuAsPrimaryAction = true + } else { + chevron.addAction( + UIAction { [weak self] _ in + self?.onTap?(tool) + }, for: .touchUpInside) + } + + return chevron + } + + /// A bar under the icon in the colour the tool applies. + private func addBar(to button: UIButton, for tool: Tool) { + let bar = UIView() + bar.isUserInteractionEnabled = false + bar.layer.cornerRadius = 1 + bar.layer.borderWidth = 0.5 + bar.layer.borderColor = UIColor.separator.cgColor + bar.backgroundColor = UIColor(hex: tool.defaultColor ?? "#000000") + bar.translatesAutoresizingMaskIntoConstraints = false + button.addSubview(bar) + + NSLayoutConstraint.activate([ + bar.widthAnchor.constraint(equalToConstant: 16), + bar.heightAnchor.constraint(equalToConstant: 3), + bar.centerXAnchor.constraint(equalTo: button.centerXAnchor), + bar.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -3), + ]) + + bars[tool] = bar + } + + private func makeMenu(for tool: Tool) -> UIMenu { + switch tool { + case .fontSize: + return UIMenu( + title: tool.label, + children: Self.fontSizes.map { size in + UIAction(title: "\(size)") { [weak self] _ in + self?.onChoice?(tool, .size(size)) + } + }) + + case .textColor: + return colorMenu(for: tool, swatches: Self.textColors, offersNone: false) + + default: + return UIMenu() + } + } + + private func colorMenu(for tool: Tool, swatches: [Swatch], offersNone: Bool) -> UIMenu { + var children: [UIMenuElement] = swatches.map { swatch in + UIAction( + title: NSLocalizedString(swatch.name, comment: ""), + image: Self.swatchImage(UIColor(hex: swatch.hex)) + ) { [weak self] _ in + self?.onChoice?(tool, .color(swatch.hex)) + } + } + + if offersNone { + children.append( + UIAction( + title: NSLocalizedString("color_none", comment: ""), image: UIImage(systemName: "circle.slash") + ) { [weak self] _ in + self?.onChoice?(tool, .color(nil)) + }) + } + + children.append( + UIAction(title: NSLocalizedString("color_custom", comment: ""), image: UIImage(systemName: "paintpalette")) + { [weak self] _ in + self?.onChoice?(tool, .customColor) + }) + + return UIMenu(title: tool.label, children: children) + } + + /// A filled circle, so the menu shows the colour it names. + private static func swatchImage(_ color: UIColor) -> UIImage { + let size = CGSize(width: 20, height: 20) + + return UIGraphicsImageRenderer(size: size).image { context in + color.setFill() + context.cgContext.fillEllipse(in: CGRect(origin: .zero, size: size)) + }.withRenderingMode(.alwaysOriginal) + } + + /// Whether `tool` is drawn as the mode: a style the selection shows, or + /// the armed marker on a pdf. + func setPressed(_ tool: Tool, _ pressed: Bool) { + buttons[tool]?.isSelected = pressed + } + + func setEnabled(_ tool: Tool, _ enabled: Bool) { + buttons[tool]?.isEnabled = enabled + } + + /// Paints the bar under `tool` in the colour it now applies. + func setColor(_ tool: Tool, _ color: UIColor) { + bars[tool]?.backgroundColor = color + } + + /// Shows the selection's size as "12 pt", or the symbol if it has none. + func setFontSize(_ points: String?) { + guard let button = buttons[.fontSize] else { return } + + var configuration = button.configuration + if let points { + configuration?.image = nil + configuration?.title = String( + format: NSLocalizedString("edit_font_size_points", comment: ""), points) + } else { + configuration?.image = UIImage(systemName: Tool.fontSize.symbol) + configuration?.title = nil + } + button.configuration = configuration + } + + /// For the tests: the colour the bar under `tool` shows. + func color(of tool: Tool) -> UIColor? { + bars[tool]?.backgroundColor + } + + /// For the tests: what the size tool says. + var fontSizeTitle: String? { + buttons[.fontSize]?.configuration?.title + } + + /// For the tests: whether the row starts with the Pro badge. + var showsProBadge: Bool { + stack.arrangedSubviews.first?.accessibilityIdentifier == "edit-tool-pro" + } + + /// For the tests: whether the row shows `tool`. + func shows(_ tool: Tool) -> Bool { + buttons[tool] != nil + } + + func isPressed(_ tool: Tool) -> Bool { + buttons[tool]?.isSelected ?? false + } +} + +extension UIColor { + + /// From `#rrggbb`. + convenience init(hex: String) { + var value: UInt64 = 0 + Scanner(string: String(hex.dropFirst())).scanHexInt64(&value) + + self.init( + red: CGFloat((value >> 16) & 0xff) / 255, + green: CGFloat((value >> 8) & 0xff) / 255, + blue: CGFloat(value & 0xff) / 255, + alpha: 1) + } + + /// As `#rrggbb`, which is what the page takes. + var hexString: String { + let rgb = deviceRGB.map { Int(($0 * 255).rounded()) } + + return String(format: "#%02x%02x%02x", rgb[0], rgb[1], rgb[2]) + } + + /// As the `[r, g, b]` in 0...1 that the pdf markers take. A wide-gamut + /// colour from the picker is clamped into sRGB. + var deviceRGB: [Double] { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + getRed(&red, green: &green, blue: &blue, alpha: nil) + + return [red, green, blue].map { Double(min(max($0, 0), 1)) } + } +} + +extension UIFont { + + fileprivate func withWeight(_ weight: UIFont.Weight) -> UIFont { + UIFont.systemFont(ofSize: pointSize, weight: weight) + } +} diff --git a/OpenDocumentReader/Features.swift b/OpenDocumentReader/Features.swift index a064afc..6a52a65 100644 --- a/OpenDocumentReader/Features.swift +++ b/OpenDocumentReader/Features.swift @@ -5,4 +5,7 @@ enum Features { /// The ad banner and the consent form in front of it: Lite only. static var withAds: Bool { LINKS_ADS } + + /// Formatting, paragraph changes and pdf marks: Pro only. + static var advancedEditing: Bool { ADVANCED_EDITING } } diff --git a/OpenDocumentReader/Main.storyboard b/OpenDocumentReader/Main.storyboard index 2205f5a..5b57558 100644 --- a/OpenDocumentReader/Main.storyboard +++ b/OpenDocumentReader/Main.storyboard @@ -46,7 +46,7 @@ - + @@ -106,6 +106,7 @@ + diff --git a/OpenDocumentReader/ca.lproj/Localizable.strings b/OpenDocumentReader/ca.lproj/Localizable.strings index 63fa9ad..d03a235 100644 --- a/OpenDocumentReader/ca.lproj/Localizable.strings +++ b/OpenDocumentReader/ca.lproj/Localizable.strings @@ -106,3 +106,59 @@ /* Shown for a format odrcore does not read */ "toast_error_illegal_file_reopen" = "Format de fitxer no compatible. Proveu d'obrir-lo amb una altra aplicació."; + +/* The editing tools under the bar, read by VoiceOver */ +"edit_bold" = "Negreta"; +"edit_italic" = "Cursiva"; +"edit_underline" = "Subratllat"; +"edit_strikethrough" = "Ratllat"; +"edit_text_color" = "Color del text"; +"edit_highlight" = "Ressaltar"; +"edit_font_size" = "Mida del text"; +"edit_undo" = "Desfés"; +"edit_redo" = "Refés"; + +/* The marks a PDF takes */ +"mark_pdf" = "Marca aquest PDF"; +"mark_highlight" = "Ressalta"; +"mark_underline" = "Subratlla"; +"mark_strike_out" = "Ratlla"; +"mark_squiggly" = "Subratllat ondulat"; +"mark_draw" = "Dibuixa"; + +/* The colors the menus offer */ +"color_black" = "Negre"; +"color_red" = "Vermell"; +"color_blue" = "Blau"; +"color_green" = "Verd"; +"color_yellow" = "Groc"; +"color_pink" = "Rosa"; +"color_none" = "Sense ressaltat"; +"color_custom" = "Un altre color…"; + +/* Why an edit was not taken */ +"edit_refused_formula" = "Aquesta cel·la conté una fórmula i es queda com està."; +"edit_refused_formula_input" = "Escriure una fórmula encara no és compatible."; +"edit_refused_rich" = "Aquesta cel·la conté més que text simple i es queda com està."; +"edit_refused_generic" = "Aquesta edició no és possible aquí."; +"edit_refused_new_line" = "Un salt de línia dins d’un paràgraf no es pot desar. Prem Retorn per a un paràgraf nou."; +"edit_refused_read_only" = "Aquest document no es pot editar."; +"edit_refused_range" = "Una edició no pot passar per sobre d’una imatge o d’una taula."; + +/* What Pro adds, shown in the Lite app */ +"pro_feature_title" = "Part de Pro"; +"pro_feature_formatting" = "Formatar el text i afegir o unir paràgrafs forma part d’OpenDocument Reader Pro."; +"pro_feature_pdf" = "Marcar un PDF forma part d’OpenDocument Reader Pro."; +"not_now" = "Ara no"; + +/* Shown when the marking tools come up on a PDF */ +"mark_hint" = "Selecciona text i després una eina per marcar-lo."; + +/* The badge in front of the tools that are Pro's, in the Lite app */ +"tool_pro_badge" = "Pro"; + +/* The chevron beside a tool, which picks the color it uses: "Highlight color" */ +"edit_color_of" = "Color de %@"; + +/* The size of the selected text, on the size tool: "12 pt" */ +"edit_font_size_points" = "%@ pt"; diff --git a/OpenDocumentReader/ca.lproj/Localizable.stringsdict b/OpenDocumentReader/ca.lproj/Localizable.stringsdict new file mode 100644 index 0000000..d355446 --- /dev/null +++ b/OpenDocumentReader/ca.lproj/Localizable.stringsdict @@ -0,0 +1,22 @@ + + + + + edit_cells_stale + + NSStringLocalizedFormatKey + %#@cells@ + cells + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d cel·la amb fórmula mostra un resultat que la vostra edició ha deixat desactualitzat. El fitxer desat conserva la fórmula, i una aplicació de fulls de càlcul la torna a calcular. + other + %d cel·les amb fórmula mostren resultats que la vostra edició ha deixat desactualitzats. El fitxer desat conserva les fórmules, i una aplicació de fulls de càlcul les torna a calcular. + + + + diff --git a/OpenDocumentReader/cs.lproj/Localizable.strings b/OpenDocumentReader/cs.lproj/Localizable.strings index c38a519..b16fcbf 100644 --- a/OpenDocumentReader/cs.lproj/Localizable.strings +++ b/OpenDocumentReader/cs.lproj/Localizable.strings @@ -106,3 +106,59 @@ /* Shown for a format odrcore does not read */ "toast_error_illegal_file_reopen" = "Nepodporovaný formát souboru. Zkuste jej otevřít v jiné aplikaci."; + +/* The editing tools under the bar, read by VoiceOver */ +"edit_bold" = "Tučné"; +"edit_italic" = "Kurzíva"; +"edit_underline" = "Podtržené"; +"edit_strikethrough" = "Přeškrtnuté"; +"edit_text_color" = "Barva textu"; +"edit_highlight" = "Zvýraznit"; +"edit_font_size" = "Velikost textu"; +"edit_undo" = "Zpět"; +"edit_redo" = "Znovu"; + +/* The marks a PDF takes */ +"mark_pdf" = "Označit tento PDF"; +"mark_highlight" = "Zvýraznit"; +"mark_underline" = "Podtrhnout"; +"mark_strike_out" = "Přeškrtnout"; +"mark_squiggly" = "Vlnité podtržení"; +"mark_draw" = "Kreslit"; + +/* The colors the menus offer */ +"color_black" = "Černá"; +"color_red" = "Červená"; +"color_blue" = "Modrá"; +"color_green" = "Zelená"; +"color_yellow" = "Žlutá"; +"color_pink" = "Růžová"; +"color_none" = "Bez zvýraznění"; +"color_custom" = "Jiná barva…"; + +/* Why an edit was not taken */ +"edit_refused_formula" = "Tato buňka obsahuje vzorec a zůstává beze změny."; +"edit_refused_formula_input" = "Zadávání vzorců zatím není podporováno."; +"edit_refused_rich" = "Tato buňka obsahuje víc než prostý text a zůstává beze změny."; +"edit_refused_generic" = "Tato úprava tu není možná."; +"edit_refused_new_line" = "Zalomení řádku uvnitř odstavce nelze uložit. Stiskněte Enter pro nový odstavec."; +"edit_refused_read_only" = "Tento dokument nelze upravovat."; +"edit_refused_range" = "Úprava nemůže sahat přes obrázek nebo tabulku."; + +/* What Pro adds, shown in the Lite app */ +"pro_feature_title" = "Součást Pro"; +"pro_feature_formatting" = "Formátování textu a přidávání nebo spojování odstavců je součástí OpenDocument Reader Pro."; +"pro_feature_pdf" = "Označování PDF je součástí OpenDocument Reader Pro."; +"not_now" = "Teď ne"; + +/* Shown when the marking tools come up on a PDF */ +"mark_hint" = "Vyberte text a pak nástroj, kterým ho označíte."; + +/* The badge in front of the tools that are Pro's, in the Lite app */ +"tool_pro_badge" = "Pro"; + +/* The chevron beside a tool, which picks the color it uses: "Highlight color" */ +"edit_color_of" = "Barva: %@"; + +/* The size of the selected text, on the size tool: "12 pt" */ +"edit_font_size_points" = "%@ pt"; diff --git a/OpenDocumentReader/cs.lproj/Localizable.stringsdict b/OpenDocumentReader/cs.lproj/Localizable.stringsdict new file mode 100644 index 0000000..2559651 --- /dev/null +++ b/OpenDocumentReader/cs.lproj/Localizable.stringsdict @@ -0,0 +1,26 @@ + + + + + edit_cells_stale + + NSStringLocalizedFormatKey + %#@cells@ + cells + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d buňka se vzorcem ukazuje výsledek, který vaše úprava učinila zastaralým. Uložený soubor vzorec zachová a tabulkový program ho spočítá znovu. + few + %d buňky se vzorcem ukazují výsledky, které vaše úprava učinila zastaralými. Uložený soubor vzorce zachová a tabulkový program je spočítá znovu. + many + %d buňky se vzorcem ukazují výsledky, které vaše úprava učinila zastaralými. Uložený soubor vzorce zachová a tabulkový program je spočítá znovu. + other + %d buněk se vzorcem ukazuje výsledky, které vaše úprava učinila zastaralými. Uložený soubor vzorce zachová a tabulkový program je spočítá znovu. + + + + diff --git a/OpenDocumentReader/da.lproj/Localizable.strings b/OpenDocumentReader/da.lproj/Localizable.strings index 52dd76c..0d614ae 100644 --- a/OpenDocumentReader/da.lproj/Localizable.strings +++ b/OpenDocumentReader/da.lproj/Localizable.strings @@ -106,3 +106,59 @@ /* Shown for a format odrcore does not read */ "toast_error_illegal_file_reopen" = "Filformatet understøttes ikke. Prøv at åbne filen i en anden app."; + +/* The editing tools under the bar, read by VoiceOver */ +"edit_bold" = "Fed"; +"edit_italic" = "Kursiv"; +"edit_underline" = "Understreget"; +"edit_strikethrough" = "Gennemstreget"; +"edit_text_color" = "Tekstfarve"; +"edit_highlight" = "Fremhæv"; +"edit_font_size" = "Tekststørrelse"; +"edit_undo" = "Fortryd"; +"edit_redo" = "Gentag"; + +/* The marks a PDF takes */ +"mark_pdf" = "Markér denne PDF"; +"mark_highlight" = "Fremhæv"; +"mark_underline" = "Understreg"; +"mark_strike_out" = "Gennemstreg"; +"mark_squiggly" = "Bølget understregning"; +"mark_draw" = "Tegn"; + +/* The colors the menus offer */ +"color_black" = "Sort"; +"color_red" = "Rød"; +"color_blue" = "Blå"; +"color_green" = "Grøn"; +"color_yellow" = "Gul"; +"color_pink" = "Lyserød"; +"color_none" = "Ingen fremhævning"; +"color_custom" = "Anden farve…"; + +/* Why an edit was not taken */ +"edit_refused_formula" = "Den celle indeholder en formel og forbliver, som den er."; +"edit_refused_formula_input" = "Indtastning af formler understøttes ikke endnu."; +"edit_refused_rich" = "Den celle indeholder mere end ren tekst og forbliver, som den er."; +"edit_refused_generic" = "Den redigering er ikke mulig her."; +"edit_refused_new_line" = "Et linjeskift inde i et afsnit kan ikke gemmes. Tryk på Enter for et nyt afsnit."; +"edit_refused_read_only" = "Dette dokument kan ikke redigeres."; +"edit_refused_range" = "En redigering kan ikke række hen over et billede eller en tabel."; + +/* What Pro adds, shown in the Lite app */ +"pro_feature_title" = "En del af Pro"; +"pro_feature_formatting" = "Formatering af tekst samt tilføjelse eller sammenføjning af afsnit er en del af OpenDocument Reader Pro."; +"pro_feature_pdf" = "Markering af PDF er en del af OpenDocument Reader Pro."; +"not_now" = "Ikke nu"; + +/* Shown when the marking tools come up on a PDF */ +"mark_hint" = "Vælg tekst og derefter et værktøj for at markere den."; + +/* The badge in front of the tools that are Pro's, in the Lite app */ +"tool_pro_badge" = "Pro"; + +/* The chevron beside a tool, which picks the color it uses: "Highlight color" */ +"edit_color_of" = "Farve til %@"; + +/* The size of the selected text, on the size tool: "12 pt" */ +"edit_font_size_points" = "%@ pt"; diff --git a/OpenDocumentReader/da.lproj/Localizable.stringsdict b/OpenDocumentReader/da.lproj/Localizable.stringsdict new file mode 100644 index 0000000..5e383c6 --- /dev/null +++ b/OpenDocumentReader/da.lproj/Localizable.stringsdict @@ -0,0 +1,22 @@ + + + + + edit_cells_stale + + NSStringLocalizedFormatKey + %#@cells@ + cells + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d formelcelle viser et resultat, som din redigering har gjort forældet. Den gemte fil beholder formlen, og et regnearksprogram beregner den igen. + other + %d formelceller viser resultater, som din redigering har gjort forældede. Den gemte fil beholder formlerne, og et regnearksprogram beregner dem igen. + + + + diff --git a/OpenDocumentReader/de.lproj/Localizable.strings b/OpenDocumentReader/de.lproj/Localizable.strings index 983a200..049d332 100644 --- a/OpenDocumentReader/de.lproj/Localizable.strings +++ b/OpenDocumentReader/de.lproj/Localizable.strings @@ -106,3 +106,59 @@ /* Shown for a format odrcore does not read */ "toast_error_illegal_file_reopen" = "Dateiformat wird nicht unterstützt. Versuchen Sie, die Datei in einer anderen App zu öffnen."; + +/* The editing tools under the bar, read by VoiceOver */ +"edit_bold" = "Fett"; +"edit_italic" = "Kursiv"; +"edit_underline" = "Unterstrichen"; +"edit_strikethrough" = "Durchgestrichen"; +"edit_text_color" = "Textfarbe"; +"edit_highlight" = "Hervorheben"; +"edit_font_size" = "Textgröße"; +"edit_undo" = "Rückgängig"; +"edit_redo" = "Wiederholen"; + +/* The marks a PDF takes */ +"mark_pdf" = "Dieses PDF markieren"; +"mark_highlight" = "Hervorheben"; +"mark_underline" = "Unterstreichen"; +"mark_strike_out" = "Durchstreichen"; +"mark_squiggly" = "Wellenlinie"; +"mark_draw" = "Zeichnen"; + +/* The colors the menus offer */ +"color_black" = "Schwarz"; +"color_red" = "Rot"; +"color_blue" = "Blau"; +"color_green" = "Grün"; +"color_yellow" = "Gelb"; +"color_pink" = "Rosa"; +"color_none" = "Keine Hervorhebung"; +"color_custom" = "Andere Farbe…"; + +/* Why an edit was not taken */ +"edit_refused_formula" = "Diese Zelle enthält eine Formel und bleibt, wie sie ist."; +"edit_refused_formula_input" = "Formeln eingeben wird noch nicht unterstützt."; +"edit_refused_rich" = "Diese Zelle enthält mehr als reinen Text und bleibt, wie sie ist."; +"edit_refused_generic" = "Diese Änderung ist hier nicht möglich."; +"edit_refused_new_line" = "Ein Zeilenumbruch innerhalb eines Absatzes kann nicht gespeichert werden. Drücke die Eingabetaste für einen neuen Absatz."; +"edit_refused_read_only" = "Dieses Dokument kann nicht bearbeitet werden."; +"edit_refused_range" = "Eine Änderung kann nicht über ein Bild oder eine Tabelle hinausreichen."; + +/* What Pro adds, shown in the Lite app */ +"pro_feature_title" = "Teil von Pro"; +"pro_feature_formatting" = "Text formatieren sowie Absätze einfügen oder zusammenführen ist Teil von OpenDocument Reader Pro."; +"pro_feature_pdf" = "PDFs markieren ist Teil von OpenDocument Reader Pro."; +"not_now" = "Nicht jetzt"; + +/* Shown when the marking tools come up on a PDF */ +"mark_hint" = "Text auswählen, dann ein Werkzeug, um ihn zu markieren."; + +/* The badge in front of the tools that are Pro's, in the Lite app */ +"tool_pro_badge" = "Pro"; + +/* The chevron beside a tool, which picks the color it uses: "Highlight color" */ +"edit_color_of" = "Farbe für %@"; + +/* The size of the selected text, on the size tool: "12 pt" */ +"edit_font_size_points" = "%@ pt"; diff --git a/OpenDocumentReader/de.lproj/Localizable.stringsdict b/OpenDocumentReader/de.lproj/Localizable.stringsdict new file mode 100644 index 0000000..eb978e7 --- /dev/null +++ b/OpenDocumentReader/de.lproj/Localizable.stringsdict @@ -0,0 +1,22 @@ + + + + + edit_cells_stale + + NSStringLocalizedFormatKey + %#@cells@ + cells + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d Formelzelle zeigt ein Ergebnis, das durch Ihre Änderung veraltet ist. Die gespeicherte Datei behält die Formel, und eine Tabellenkalkulation berechnet sie neu. + other + %d Formelzellen zeigen Ergebnisse, die durch Ihre Änderung veraltet sind. Die gespeicherte Datei behält die Formeln, und eine Tabellenkalkulation berechnet sie neu. + + + + diff --git a/OpenDocumentReader/en.lproj/Localizable.strings b/OpenDocumentReader/en.lproj/Localizable.strings index 37636ca..daad039 100644 --- a/OpenDocumentReader/en.lproj/Localizable.strings +++ b/OpenDocumentReader/en.lproj/Localizable.strings @@ -106,3 +106,59 @@ /* Shown for a format odrcore does not read */ "toast_error_illegal_file_reopen" = "Unsupported file format. Try opening it in another app."; + +/* The editing tools under the bar, read by VoiceOver */ +"edit_bold" = "Bold"; +"edit_italic" = "Italic"; +"edit_underline" = "Underline"; +"edit_strikethrough" = "Strikethrough"; +"edit_text_color" = "Text color"; +"edit_highlight" = "Highlight"; +"edit_font_size" = "Text size"; +"edit_undo" = "Undo"; +"edit_redo" = "Redo"; + +/* The marks a PDF takes */ +"mark_pdf" = "Mark up this PDF"; +"mark_highlight" = "Highlight"; +"mark_underline" = "Underline"; +"mark_strike_out" = "Strike out"; +"mark_squiggly" = "Squiggly underline"; +"mark_draw" = "Draw"; + +/* The colors the menus offer */ +"color_black" = "Black"; +"color_red" = "Red"; +"color_blue" = "Blue"; +"color_green" = "Green"; +"color_yellow" = "Yellow"; +"color_pink" = "Pink"; +"color_none" = "No highlight"; +"color_custom" = "Other color…"; + +/* Why an edit was not taken */ +"edit_refused_formula" = "That cell holds a formula and stays as it is."; +"edit_refused_formula_input" = "Typing a formula is not supported yet."; +"edit_refused_rich" = "That cell holds more than plain text and stays as it is."; +"edit_refused_generic" = "That edit is not possible here."; +"edit_refused_new_line" = "A line break inside a paragraph cannot be saved. Press Return for a new paragraph."; +"edit_refused_read_only" = "This document cannot be edited."; +"edit_refused_range" = "An edit cannot reach over a picture or a table."; + +/* What Pro adds, shown in the Lite app */ +"pro_feature_title" = "Part of Pro"; +"pro_feature_formatting" = "Formatting text, and adding or joining paragraphs, is part of OpenDocument Reader Pro."; +"pro_feature_pdf" = "Marking up a PDF is part of OpenDocument Reader Pro."; +"not_now" = "Not now"; + +/* Shown when the marking tools come up on a PDF */ +"mark_hint" = "Select text, then a tool, to mark it."; + +/* The badge in front of the tools that are Pro's, in the Lite app */ +"tool_pro_badge" = "Pro"; + +/* The chevron beside a tool, which picks the color it uses: "Highlight color" */ +"edit_color_of" = "%@ color"; + +/* The size of the selected text, on the size tool: "12 pt" */ +"edit_font_size_points" = "%@ pt"; diff --git a/OpenDocumentReader/en.lproj/Localizable.stringsdict b/OpenDocumentReader/en.lproj/Localizable.stringsdict new file mode 100644 index 0000000..c1e04aa --- /dev/null +++ b/OpenDocumentReader/en.lproj/Localizable.stringsdict @@ -0,0 +1,22 @@ + + + + + edit_cells_stale + + NSStringLocalizedFormatKey + %#@cells@ + cells + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d formula cell shows a result your edit made out of date. The saved file keeps the formula, and a spreadsheet app computes it again. + other + %d formula cells show results your edit made out of date. The saved file keeps the formulas, and a spreadsheet app computes them again. + + + + diff --git a/OpenDocumentReader/es.lproj/Localizable.strings b/OpenDocumentReader/es.lproj/Localizable.strings index 923e046..1743694 100644 --- a/OpenDocumentReader/es.lproj/Localizable.strings +++ b/OpenDocumentReader/es.lproj/Localizable.strings @@ -106,3 +106,59 @@ /* Shown for a format odrcore does not read */ "toast_error_illegal_file_reopen" = "Formato de archivo no compatible. Pruebe a abrirlo en otra aplicación."; + +/* The editing tools under the bar, read by VoiceOver */ +"edit_bold" = "Negrita"; +"edit_italic" = "Cursiva"; +"edit_underline" = "Subrayado"; +"edit_strikethrough" = "Tachado"; +"edit_text_color" = "Color del texto"; +"edit_highlight" = "Resaltar"; +"edit_font_size" = "Tamaño del texto"; +"edit_undo" = "Deshacer"; +"edit_redo" = "Rehacer"; + +/* The marks a PDF takes */ +"mark_pdf" = "Marcar este PDF"; +"mark_highlight" = "Resaltar"; +"mark_underline" = "Subrayar"; +"mark_strike_out" = "Tachar"; +"mark_squiggly" = "Subrayado ondulado"; +"mark_draw" = "Dibujar"; + +/* The colors the menus offer */ +"color_black" = "Negro"; +"color_red" = "Rojo"; +"color_blue" = "Azul"; +"color_green" = "Verde"; +"color_yellow" = "Amarillo"; +"color_pink" = "Rosa"; +"color_none" = "Sin resaltado"; +"color_custom" = "Otro color…"; + +/* Why an edit was not taken */ +"edit_refused_formula" = "Esa celda contiene una fórmula y se queda como está."; +"edit_refused_formula_input" = "Escribir una fórmula aún no es compatible."; +"edit_refused_rich" = "Esa celda contiene más que texto sin formato y se queda como está."; +"edit_refused_generic" = "Esa edición no es posible aquí."; +"edit_refused_new_line" = "Un salto de línea dentro de un párrafo no se puede guardar. Pulsa Intro para un párrafo nuevo."; +"edit_refused_read_only" = "Este documento no se puede editar."; +"edit_refused_range" = "Una edición no puede pasar por encima de una imagen o una tabla."; + +/* What Pro adds, shown in the Lite app */ +"pro_feature_title" = "Parte de Pro"; +"pro_feature_formatting" = "Dar formato al texto y añadir o unir párrafos forma parte de OpenDocument Reader Pro."; +"pro_feature_pdf" = "Marcar un PDF forma parte de OpenDocument Reader Pro."; +"not_now" = "Ahora no"; + +/* Shown when the marking tools come up on a PDF */ +"mark_hint" = "Selecciona texto y luego una herramienta para marcarlo."; + +/* The badge in front of the tools that are Pro's, in the Lite app */ +"tool_pro_badge" = "Pro"; + +/* The chevron beside a tool, which picks the color it uses: "Highlight color" */ +"edit_color_of" = "Color de %@"; + +/* The size of the selected text, on the size tool: "12 pt" */ +"edit_font_size_points" = "%@ pt"; diff --git a/OpenDocumentReader/es.lproj/Localizable.stringsdict b/OpenDocumentReader/es.lproj/Localizable.stringsdict new file mode 100644 index 0000000..9f3a751 --- /dev/null +++ b/OpenDocumentReader/es.lproj/Localizable.stringsdict @@ -0,0 +1,22 @@ + + + + + edit_cells_stale + + NSStringLocalizedFormatKey + %#@cells@ + cells + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d celda con fórmula muestra un resultado que su edición dejó desactualizado. El archivo guardado conserva la fórmula y una aplicación de hojas de cálculo la vuelve a calcular. + other + %d celdas con fórmula muestran resultados que su edición dejó desactualizados. El archivo guardado conserva las fórmulas y una aplicación de hojas de cálculo las vuelve a calcular. + + + + diff --git a/OpenDocumentReader/fr.lproj/Localizable.strings b/OpenDocumentReader/fr.lproj/Localizable.strings index 9018f7d..d927f51 100644 --- a/OpenDocumentReader/fr.lproj/Localizable.strings +++ b/OpenDocumentReader/fr.lproj/Localizable.strings @@ -106,3 +106,59 @@ /* Shown for a format odrcore does not read */ "toast_error_illegal_file_reopen" = "Le format de ce fichier n'est pas pris en charge. Essayez de l'ouvrir avec une autre application."; + +/* The editing tools under the bar, read by VoiceOver */ +"edit_bold" = "Gras"; +"edit_italic" = "Italique"; +"edit_underline" = "Souligné"; +"edit_strikethrough" = "Barré"; +"edit_text_color" = "Couleur du texte"; +"edit_highlight" = "Surligner"; +"edit_font_size" = "Taille du texte"; +"edit_undo" = "Annuler"; +"edit_redo" = "Rétablir"; + +/* The marks a PDF takes */ +"mark_pdf" = "Annoter ce PDF"; +"mark_highlight" = "Surligner"; +"mark_underline" = "Souligner"; +"mark_strike_out" = "Barrer"; +"mark_squiggly" = "Soulignement ondulé"; +"mark_draw" = "Dessiner"; + +/* The colors the menus offer */ +"color_black" = "Noir"; +"color_red" = "Rouge"; +"color_blue" = "Bleu"; +"color_green" = "Vert"; +"color_yellow" = "Jaune"; +"color_pink" = "Rose"; +"color_none" = "Aucun surlignage"; +"color_custom" = "Autre couleur…"; + +/* Why an edit was not taken */ +"edit_refused_formula" = "Cette cellule contient une formule et reste telle quelle."; +"edit_refused_formula_input" = "La saisie d’une formule n’est pas encore prise en charge."; +"edit_refused_rich" = "Cette cellule contient plus que du texte brut et reste telle quelle."; +"edit_refused_generic" = "Cette modification n’est pas possible ici."; +"edit_refused_new_line" = "Un saut de ligne à l’intérieur d’un paragraphe ne peut pas être enregistré. Appuyez sur Entrée pour un nouveau paragraphe."; +"edit_refused_read_only" = "Ce document ne peut pas être modifié."; +"edit_refused_range" = "Une modification ne peut pas passer par-dessus une image ou un tableau."; + +/* What Pro adds, shown in the Lite app */ +"pro_feature_title" = "Réservé à Pro"; +"pro_feature_formatting" = "La mise en forme du texte, ainsi que l’ajout ou la fusion de paragraphes, fait partie d’OpenDocument Reader Pro."; +"pro_feature_pdf" = "L’annotation de PDF fait partie d’OpenDocument Reader Pro."; +"not_now" = "Pas maintenant"; + +/* Shown when the marking tools come up on a PDF */ +"mark_hint" = "Sélectionnez du texte, puis un outil, pour l’annoter."; + +/* The badge in front of the tools that are Pro's, in the Lite app */ +"tool_pro_badge" = "Pro"; + +/* The chevron beside a tool, which picks the color it uses: "Highlight color" */ +"edit_color_of" = "Couleur : %@"; + +/* The size of the selected text, on the size tool: "12 pt" */ +"edit_font_size_points" = "%@ pt"; diff --git a/OpenDocumentReader/fr.lproj/Localizable.stringsdict b/OpenDocumentReader/fr.lproj/Localizable.stringsdict new file mode 100644 index 0000000..5a44959 --- /dev/null +++ b/OpenDocumentReader/fr.lproj/Localizable.stringsdict @@ -0,0 +1,22 @@ + + + + + edit_cells_stale + + NSStringLocalizedFormatKey + %#@cells@ + cells + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d cellule de formule affiche un résultat que votre modification a rendu obsolète. Le fichier enregistré conserve la formule, et un tableur la recalcule. + other + %d cellules de formule affichent des résultats que votre modification a rendus obsolètes. Le fichier enregistré conserve les formules, et un tableur les recalcule. + + + + diff --git a/OpenDocumentReader/ga.lproj/Localizable.strings b/OpenDocumentReader/ga.lproj/Localizable.strings index f60011d..0ef9663 100644 --- a/OpenDocumentReader/ga.lproj/Localizable.strings +++ b/OpenDocumentReader/ga.lproj/Localizable.strings @@ -106,3 +106,59 @@ /* Shown for a format odrcore does not read */ "toast_error_illegal_file_reopen" = "Ní thacaítear leis an bhformáid chomhaid seo. Bain triail as an gcomhad a oscailt in aip eile."; + +/* The editing tools under the bar, read by VoiceOver */ +"edit_bold" = "Trom"; +"edit_italic" = "Iodálach"; +"edit_underline" = "Líne faoi"; +"edit_strikethrough" = "Líne tríd"; +"edit_text_color" = "Dath an téacs"; +"edit_highlight" = "Aibhsigh"; +"edit_font_size" = "Méid an téacs"; +"edit_undo" = "Cealaigh"; +"edit_redo" = "Athdhéan"; + +/* The marks a PDF takes */ +"mark_pdf" = "Marcáil an PDF seo"; +"mark_highlight" = "Aibhsigh"; +"mark_underline" = "Cuir líne faoi"; +"mark_strike_out" = "Cuir líne tríd"; +"mark_squiggly" = "Líne thonnach faoi"; +"mark_draw" = "Tarraing"; + +/* The colors the menus offer */ +"color_black" = "Dubh"; +"color_red" = "Dearg"; +"color_blue" = "Gorm"; +"color_green" = "Glas"; +"color_yellow" = "Buí"; +"color_pink" = "Bándearg"; +"color_none" = "Gan aibhsiú"; +"color_custom" = "Dath eile…"; + +/* Why an edit was not taken */ +"edit_refused_formula" = "Tá foirmle sa chill sin agus fanann sí mar atá."; +"edit_refused_formula_input" = "Ní thacaítear le foirmlí a chlóscríobh go fóill."; +"edit_refused_rich" = "Tá níos mó ná gnáth-théacs sa chill sin agus fanann sí mar atá."; +"edit_refused_generic" = "Ní féidir an t-athrú sin a dhéanamh anseo."; +"edit_refused_new_line" = "Ní féidir briseadh líne laistigh d’alt a shábháil. Brúigh Iontráil le haghaidh alt nua."; +"edit_refused_read_only" = "Ní féidir an doiciméad seo a chur in eagar."; +"edit_refused_range" = "Ní féidir le hathrú dul thar phictiúr ná thar thábla."; + +/* What Pro adds, shown in the Lite app */ +"pro_feature_title" = "Cuid de Pro"; +"pro_feature_formatting" = "Is cuid de OpenDocument Reader Pro é téacs a fhormáidiú agus ailt a chur leis nó a nascadh."; +"pro_feature_pdf" = "Is cuid de OpenDocument Reader Pro é PDF a mharcáil."; +"not_now" = "Ní anois"; + +/* Shown when the marking tools come up on a PDF */ +"mark_hint" = "Roghnaigh téacs, ansin uirlis, chun é a mharcáil."; + +/* The badge in front of the tools that are Pro's, in the Lite app */ +"tool_pro_badge" = "Pro"; + +/* The chevron beside a tool, which picks the color it uses: "Highlight color" */ +"edit_color_of" = "Dath: %@"; + +/* The size of the selected text, on the size tool: "12 pt" */ +"edit_font_size_points" = "%@ pt"; diff --git a/OpenDocumentReader/ga.lproj/Localizable.stringsdict b/OpenDocumentReader/ga.lproj/Localizable.stringsdict new file mode 100644 index 0000000..283fd6d --- /dev/null +++ b/OpenDocumentReader/ga.lproj/Localizable.stringsdict @@ -0,0 +1,28 @@ + + + + + edit_cells_stale + + NSStringLocalizedFormatKey + %#@cells@ + cells + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Taispeánann %d chill fhoirmle toradh atá as dáta de bharr d'eagarthóireachta. Coinníonn an comhad sábháilte an fhoirmle, agus ríomhann aip scarbhileog arís í. + two + Taispeánann %d cill fhoirmle torthaí atá as dáta de bharr d'eagarthóireachta. Coinníonn an comhad sábháilte na foirmlí, agus ríomhann aip scarbhileog arís iad. + few + Taispeánann %d cill fhoirmle torthaí atá as dáta de bharr d'eagarthóireachta. Coinníonn an comhad sábháilte na foirmlí, agus ríomhann aip scarbhileog arís iad. + many + Taispeánann %d cill fhoirmle torthaí atá as dáta de bharr d'eagarthóireachta. Coinníonn an comhad sábháilte na foirmlí, agus ríomhann aip scarbhileog arís iad. + other + Taispeánann %d cill fhoirmle torthaí atá as dáta de bharr d'eagarthóireachta. Coinníonn an comhad sábháilte na foirmlí, agus ríomhann aip scarbhileog arís iad. + + + + diff --git a/OpenDocumentReader/it.lproj/Localizable.strings b/OpenDocumentReader/it.lproj/Localizable.strings index eadefd5..aa7fca6 100644 --- a/OpenDocumentReader/it.lproj/Localizable.strings +++ b/OpenDocumentReader/it.lproj/Localizable.strings @@ -106,3 +106,59 @@ /* Shown for a format odrcore does not read */ "toast_error_illegal_file_reopen" = "Formato di file non supportato. Prova ad aprirlo in un'altra app."; + +/* The editing tools under the bar, read by VoiceOver */ +"edit_bold" = "Grassetto"; +"edit_italic" = "Corsivo"; +"edit_underline" = "Sottolineato"; +"edit_strikethrough" = "Barrato"; +"edit_text_color" = "Colore del testo"; +"edit_highlight" = "Evidenzia"; +"edit_font_size" = "Dimensione del testo"; +"edit_undo" = "Annulla"; +"edit_redo" = "Ripeti"; + +/* The marks a PDF takes */ +"mark_pdf" = "Annota questo PDF"; +"mark_highlight" = "Evidenzia"; +"mark_underline" = "Sottolinea"; +"mark_strike_out" = "Barra"; +"mark_squiggly" = "Sottolineatura ondulata"; +"mark_draw" = "Disegna"; + +/* The colors the menus offer */ +"color_black" = "Nero"; +"color_red" = "Rosso"; +"color_blue" = "Blu"; +"color_green" = "Verde"; +"color_yellow" = "Giallo"; +"color_pink" = "Rosa"; +"color_none" = "Nessuna evidenziazione"; +"color_custom" = "Altro colore…"; + +/* Why an edit was not taken */ +"edit_refused_formula" = "Quella cella contiene una formula e resta com’è."; +"edit_refused_formula_input" = "Digitare una formula non è ancora supportato."; +"edit_refused_rich" = "Quella cella contiene più di testo semplice e resta com’è."; +"edit_refused_generic" = "Questa modifica non è possibile qui."; +"edit_refused_new_line" = "Un’interruzione di riga dentro un paragrafo non può essere salvata. Premi Invio per un nuovo paragrafo."; +"edit_refused_read_only" = "Questo documento non può essere modificato."; +"edit_refused_range" = "Una modifica non può passare sopra un’immagine o una tabella."; + +/* What Pro adds, shown in the Lite app */ +"pro_feature_title" = "Parte di Pro"; +"pro_feature_formatting" = "Formattare il testo e aggiungere o unire paragrafi fa parte di OpenDocument Reader Pro."; +"pro_feature_pdf" = "Annotare un PDF fa parte di OpenDocument Reader Pro."; +"not_now" = "Non ora"; + +/* Shown when the marking tools come up on a PDF */ +"mark_hint" = "Seleziona del testo, poi uno strumento, per annotarlo."; + +/* The badge in front of the tools that are Pro's, in the Lite app */ +"tool_pro_badge" = "Pro"; + +/* The chevron beside a tool, which picks the color it uses: "Highlight color" */ +"edit_color_of" = "Colore di %@"; + +/* The size of the selected text, on the size tool: "12 pt" */ +"edit_font_size_points" = "%@ pt"; diff --git a/OpenDocumentReader/it.lproj/Localizable.stringsdict b/OpenDocumentReader/it.lproj/Localizable.stringsdict new file mode 100644 index 0000000..33fa793 --- /dev/null +++ b/OpenDocumentReader/it.lproj/Localizable.stringsdict @@ -0,0 +1,22 @@ + + + + + edit_cells_stale + + NSStringLocalizedFormatKey + %#@cells@ + cells + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d cella con formula mostra un risultato reso obsoleto dalla tua modifica. Il file salvato mantiene la formula e un'app per fogli di calcolo la ricalcola. + other + %d celle con formula mostrano risultati resi obsoleti dalla tua modifica. Il file salvato mantiene le formule e un'app per fogli di calcolo le ricalcola. + + + + diff --git a/OpenDocumentReader/ja.lproj/Localizable.strings b/OpenDocumentReader/ja.lproj/Localizable.strings index 0fdd582..f42cfbd 100644 --- a/OpenDocumentReader/ja.lproj/Localizable.strings +++ b/OpenDocumentReader/ja.lproj/Localizable.strings @@ -106,3 +106,59 @@ /* Shown for a format odrcore does not read */ "toast_error_illegal_file_reopen" = "対応していないファイル形式です。別のアプリで開いてみてください。"; + +/* The editing tools under the bar, read by VoiceOver */ +"edit_bold" = "太字"; +"edit_italic" = "斜体"; +"edit_underline" = "下線"; +"edit_strikethrough" = "取り消し線"; +"edit_text_color" = "文字の色"; +"edit_highlight" = "ハイライト"; +"edit_font_size" = "文字サイズ"; +"edit_undo" = "元に戻す"; +"edit_redo" = "やり直す"; + +/* The marks a PDF takes */ +"mark_pdf" = "このPDFにマークを付ける"; +"mark_highlight" = "ハイライト"; +"mark_underline" = "下線"; +"mark_strike_out" = "取り消し線"; +"mark_squiggly" = "波線"; +"mark_draw" = "描画"; + +/* The colors the menus offer */ +"color_black" = "黒"; +"color_red" = "赤"; +"color_blue" = "青"; +"color_green" = "緑"; +"color_yellow" = "黄"; +"color_pink" = "ピンク"; +"color_none" = "ハイライトなし"; +"color_custom" = "その他の色…"; + +/* Why an edit was not taken */ +"edit_refused_formula" = "そのセルには数式が入っているため、そのままになります。"; +"edit_refused_formula_input" = "数式の入力はまだ対応していません。"; +"edit_refused_rich" = "そのセルには書式付きの内容が入っているため、そのままになります。"; +"edit_refused_generic" = "この編集はここではできません。"; +"edit_refused_new_line" = "段落内の改行は保存できません。新しい段落にはReturnキーを押してください。"; +"edit_refused_read_only" = "この書類は編集できません。"; +"edit_refused_range" = "画像や表をまたぐ編集はできません。"; + +/* What Pro adds, shown in the Lite app */ +"pro_feature_title" = "Proの機能"; +"pro_feature_formatting" = "文字の書式設定と、段落の追加や結合はOpenDocument Reader Proの機能です。"; +"pro_feature_pdf" = "PDFへのマーク付けはOpenDocument Reader Proの機能です。"; +"not_now" = "あとで"; + +/* Shown when the marking tools come up on a PDF */ +"mark_hint" = "テキストを選択してから、ツールを選ぶとマークを付けられます。"; + +/* The badge in front of the tools that are Pro's, in the Lite app */ +"tool_pro_badge" = "Pro"; + +/* The chevron beside a tool, which picks the color it uses: "Highlight color" */ +"edit_color_of" = "%@の色"; + +/* The size of the selected text, on the size tool: "12 pt" */ +"edit_font_size_points" = "%@ pt"; diff --git a/OpenDocumentReader/ja.lproj/Localizable.stringsdict b/OpenDocumentReader/ja.lproj/Localizable.stringsdict new file mode 100644 index 0000000..5f22403 --- /dev/null +++ b/OpenDocumentReader/ja.lproj/Localizable.stringsdict @@ -0,0 +1,20 @@ + + + + + edit_cells_stale + + NSStringLocalizedFormatKey + %#@cells@ + cells + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + other + %d 個の数式セルに、編集によって古くなった結果が表示されています。保存したファイルには数式が残り、表計算アプリで再計算されます。 + + + + diff --git a/OpenDocumentReader/pl.lproj/Localizable.strings b/OpenDocumentReader/pl.lproj/Localizable.strings index d4d543b..a03b58e 100644 --- a/OpenDocumentReader/pl.lproj/Localizable.strings +++ b/OpenDocumentReader/pl.lproj/Localizable.strings @@ -106,3 +106,59 @@ /* Shown for a format odrcore does not read */ "toast_error_illegal_file_reopen" = "Nieobsługiwany format pliku. Spróbuj otworzyć go w innej aplikacji."; + +/* The editing tools under the bar, read by VoiceOver */ +"edit_bold" = "Pogrubienie"; +"edit_italic" = "Kursywa"; +"edit_underline" = "Podkreślenie"; +"edit_strikethrough" = "Przekreślenie"; +"edit_text_color" = "Kolor tekstu"; +"edit_highlight" = "Wyróżnienie"; +"edit_font_size" = "Rozmiar tekstu"; +"edit_undo" = "Cofnij"; +"edit_redo" = "Ponów"; + +/* The marks a PDF takes */ +"mark_pdf" = "Oznacz ten PDF"; +"mark_highlight" = "Wyróżnij"; +"mark_underline" = "Podkreśl"; +"mark_strike_out" = "Przekreśl"; +"mark_squiggly" = "Podkreślenie faliste"; +"mark_draw" = "Rysuj"; + +/* The colors the menus offer */ +"color_black" = "Czarny"; +"color_red" = "Czerwony"; +"color_blue" = "Niebieski"; +"color_green" = "Zielony"; +"color_yellow" = "Żółty"; +"color_pink" = "Różowy"; +"color_none" = "Bez wyróżnienia"; +"color_custom" = "Inny kolor…"; + +/* Why an edit was not taken */ +"edit_refused_formula" = "Ta komórka zawiera formułę i pozostaje bez zmian."; +"edit_refused_formula_input" = "Wpisywanie formuł nie jest jeszcze obsługiwane."; +"edit_refused_rich" = "Ta komórka zawiera więcej niż zwykły tekst i pozostaje bez zmian."; +"edit_refused_generic" = "Ta zmiana nie jest tu możliwa."; +"edit_refused_new_line" = "Podziału wiersza wewnątrz akapitu nie można zapisać. Naciśnij Enter, aby zacząć nowy akapit."; +"edit_refused_read_only" = "Tego dokumentu nie można edytować."; +"edit_refused_range" = "Zmiana nie może sięgać przez obraz ani tabelę."; + +/* What Pro adds, shown in the Lite app */ +"pro_feature_title" = "Część wersji Pro"; +"pro_feature_formatting" = "Formatowanie tekstu oraz dodawanie i łączenie akapitów to część OpenDocument Reader Pro."; +"pro_feature_pdf" = "Oznaczanie PDF to część OpenDocument Reader Pro."; +"not_now" = "Nie teraz"; + +/* Shown when the marking tools come up on a PDF */ +"mark_hint" = "Zaznacz tekst, a potem narzędzie, aby go oznaczyć."; + +/* The badge in front of the tools that are Pro's, in the Lite app */ +"tool_pro_badge" = "Pro"; + +/* The chevron beside a tool, which picks the color it uses: "Highlight color" */ +"edit_color_of" = "Kolor: %@"; + +/* The size of the selected text, on the size tool: "12 pt" */ +"edit_font_size_points" = "%@ pt"; diff --git a/OpenDocumentReader/pl.lproj/Localizable.stringsdict b/OpenDocumentReader/pl.lproj/Localizable.stringsdict new file mode 100644 index 0000000..956c086 --- /dev/null +++ b/OpenDocumentReader/pl.lproj/Localizable.stringsdict @@ -0,0 +1,26 @@ + + + + + edit_cells_stale + + NSStringLocalizedFormatKey + %#@cells@ + cells + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d komórka z formułą pokazuje wynik, który Twoja edycja zdezaktualizowała. Zapisany plik zachowuje formułę, a arkusz kalkulacyjny przeliczy ją ponownie. + few + %d komórki z formułami pokazują wyniki, które Twoja edycja zdezaktualizowała. Zapisany plik zachowuje formuły, a arkusz kalkulacyjny przeliczy je ponownie. + many + %d komórek z formułami pokazuje wyniki, które Twoja edycja zdezaktualizowała. Zapisany plik zachowuje formuły, a arkusz kalkulacyjny przeliczy je ponownie. + other + %d komórki z formułami pokazuje wyniki, które Twoja edycja zdezaktualizowała. Zapisany plik zachowuje formuły, a arkusz kalkulacyjny przeliczy je ponownie. + + + + diff --git a/OpenDocumentReader/pt-BR.lproj/Localizable.strings b/OpenDocumentReader/pt-BR.lproj/Localizable.strings index 64abe51..df214e6 100644 --- a/OpenDocumentReader/pt-BR.lproj/Localizable.strings +++ b/OpenDocumentReader/pt-BR.lproj/Localizable.strings @@ -106,3 +106,59 @@ /* Shown for a format odrcore does not read */ "toast_error_illegal_file_reopen" = "Formato de arquivo não compatível. Tente abri-lo em outro aplicativo."; + +/* The editing tools under the bar, read by VoiceOver */ +"edit_bold" = "Negrito"; +"edit_italic" = "Itálico"; +"edit_underline" = "Sublinhado"; +"edit_strikethrough" = "Tachado"; +"edit_text_color" = "Cor do texto"; +"edit_highlight" = "Destacar"; +"edit_font_size" = "Tamanho do texto"; +"edit_undo" = "Desfazer"; +"edit_redo" = "Refazer"; + +/* The marks a PDF takes */ +"mark_pdf" = "Marcar este PDF"; +"mark_highlight" = "Destacar"; +"mark_underline" = "Sublinhar"; +"mark_strike_out" = "Tachar"; +"mark_squiggly" = "Sublinhado ondulado"; +"mark_draw" = "Desenhar"; + +/* The colors the menus offer */ +"color_black" = "Preto"; +"color_red" = "Vermelho"; +"color_blue" = "Azul"; +"color_green" = "Verde"; +"color_yellow" = "Amarelo"; +"color_pink" = "Rosa"; +"color_none" = "Sem destaque"; +"color_custom" = "Outra cor…"; + +/* Why an edit was not taken */ +"edit_refused_formula" = "Essa célula contém uma fórmula e fica como está."; +"edit_refused_formula_input" = "Digitar uma fórmula ainda não é possível."; +"edit_refused_rich" = "Essa célula contém mais do que texto simples e fica como está."; +"edit_refused_generic" = "Essa edição não é possível aqui."; +"edit_refused_new_line" = "Uma quebra de linha dentro de um parágrafo não pode ser salva. Pressione Enter para um novo parágrafo."; +"edit_refused_read_only" = "Este documento não pode ser editado."; +"edit_refused_range" = "Uma edição não pode passar por cima de uma imagem ou de uma tabela."; + +/* What Pro adds, shown in the Lite app */ +"pro_feature_title" = "Parte do Pro"; +"pro_feature_formatting" = "Formatar texto e adicionar ou juntar parágrafos faz parte do OpenDocument Reader Pro."; +"pro_feature_pdf" = "Marcar um PDF faz parte do OpenDocument Reader Pro."; +"not_now" = "Agora não"; + +/* Shown when the marking tools come up on a PDF */ +"mark_hint" = "Selecione o texto e depois uma ferramenta para marcá-lo."; + +/* The badge in front of the tools that are Pro's, in the Lite app */ +"tool_pro_badge" = "Pro"; + +/* The chevron beside a tool, which picks the color it uses: "Highlight color" */ +"edit_color_of" = "Cor de %@"; + +/* The size of the selected text, on the size tool: "12 pt" */ +"edit_font_size_points" = "%@ pt"; diff --git a/OpenDocumentReader/pt-BR.lproj/Localizable.stringsdict b/OpenDocumentReader/pt-BR.lproj/Localizable.stringsdict new file mode 100644 index 0000000..98ed93d --- /dev/null +++ b/OpenDocumentReader/pt-BR.lproj/Localizable.stringsdict @@ -0,0 +1,22 @@ + + + + + edit_cells_stale + + NSStringLocalizedFormatKey + %#@cells@ + cells + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d célula com fórmula mostra um resultado que sua edição desatualizou. O arquivo salvo mantém a fórmula, e um aplicativo de planilhas a calcula de novo. + other + %d células com fórmula mostram resultados que sua edição desatualizou. O arquivo salvo mantém as fórmulas, e um aplicativo de planilhas as calcula de novo. + + + + diff --git a/OpenDocumentReader/ru.lproj/Localizable.strings b/OpenDocumentReader/ru.lproj/Localizable.strings index 98adb00..5fc5e43 100644 --- a/OpenDocumentReader/ru.lproj/Localizable.strings +++ b/OpenDocumentReader/ru.lproj/Localizable.strings @@ -106,3 +106,59 @@ /* Shown for a format odrcore does not read */ "toast_error_illegal_file_reopen" = "Формат файла не поддерживается. Попробуйте открыть его в другом приложении."; + +/* The editing tools under the bar, read by VoiceOver */ +"edit_bold" = "Жирный"; +"edit_italic" = "Курсив"; +"edit_underline" = "Подчёркнутый"; +"edit_strikethrough" = "Зачёркнутый"; +"edit_text_color" = "Цвет текста"; +"edit_highlight" = "Выделение"; +"edit_font_size" = "Размер текста"; +"edit_undo" = "Отменить"; +"edit_redo" = "Повторить"; + +/* The marks a PDF takes */ +"mark_pdf" = "Разметить этот PDF"; +"mark_highlight" = "Выделить"; +"mark_underline" = "Подчеркнуть"; +"mark_strike_out" = "Зачеркнуть"; +"mark_squiggly" = "Волнистое подчёркивание"; +"mark_draw" = "Рисовать"; + +/* The colors the menus offer */ +"color_black" = "Чёрный"; +"color_red" = "Красный"; +"color_blue" = "Синий"; +"color_green" = "Зелёный"; +"color_yellow" = "Жёлтый"; +"color_pink" = "Розовый"; +"color_none" = "Без выделения"; +"color_custom" = "Другой цвет…"; + +/* Why an edit was not taken */ +"edit_refused_formula" = "В этой ячейке формула, она остаётся как есть."; +"edit_refused_formula_input" = "Ввод формул пока не поддерживается."; +"edit_refused_rich" = "В этой ячейке не только простой текст, она остаётся как есть."; +"edit_refused_generic" = "Такое изменение здесь невозможно."; +"edit_refused_new_line" = "Перенос строки внутри абзаца нельзя сохранить. Нажмите Ввод, чтобы начать новый абзац."; +"edit_refused_read_only" = "Этот документ нельзя редактировать."; +"edit_refused_range" = "Изменение не может проходить через картинку или таблицу."; + +/* What Pro adds, shown in the Lite app */ +"pro_feature_title" = "Часть Pro"; +"pro_feature_formatting" = "Форматирование текста, а также добавление и объединение абзацев — часть OpenDocument Reader Pro."; +"pro_feature_pdf" = "Разметка PDF — часть OpenDocument Reader Pro."; +"not_now" = "Не сейчас"; + +/* Shown when the marking tools come up on a PDF */ +"mark_hint" = "Выделите текст, затем инструмент, чтобы пометить его."; + +/* The badge in front of the tools that are Pro's, in the Lite app */ +"tool_pro_badge" = "Pro"; + +/* The chevron beside a tool, which picks the color it uses: "Highlight color" */ +"edit_color_of" = "Цвет: %@"; + +/* The size of the selected text, on the size tool: "12 pt" */ +"edit_font_size_points" = "%@ пт"; diff --git a/OpenDocumentReader/ru.lproj/Localizable.stringsdict b/OpenDocumentReader/ru.lproj/Localizable.stringsdict new file mode 100644 index 0000000..a4dfc92 --- /dev/null +++ b/OpenDocumentReader/ru.lproj/Localizable.stringsdict @@ -0,0 +1,26 @@ + + + + + edit_cells_stale + + NSStringLocalizedFormatKey + %#@cells@ + cells + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d ячейка с формулой показывает результат, который ваша правка сделала устаревшим. Сохранённый файл сохраняет формулу, и табличный редактор пересчитает её. + few + %d ячейки с формулами показывают результаты, которые ваша правка сделала устаревшими. Сохранённый файл сохраняет формулы, и табличный редактор пересчитает их. + many + %d ячеек с формулами показывают результаты, которые ваша правка сделала устаревшими. Сохранённый файл сохраняет формулы, и табличный редактор пересчитает их. + other + %d ячейки с формулами показывают результаты, которые ваша правка сделала устаревшими. Сохранённый файл сохраняет формулы, и табличный редактор пересчитает их. + + + + diff --git a/OpenDocumentReader/sl.lproj/Localizable.strings b/OpenDocumentReader/sl.lproj/Localizable.strings index c87614a..3f201c8 100644 --- a/OpenDocumentReader/sl.lproj/Localizable.strings +++ b/OpenDocumentReader/sl.lproj/Localizable.strings @@ -106,3 +106,59 @@ /* Shown for a format odrcore does not read */ "toast_error_illegal_file_reopen" = "Nepodprta oblika datoteke. Poskusite jo odpreti v drugi aplikaciji."; + +/* The editing tools under the bar, read by VoiceOver */ +"edit_bold" = "Krepko"; +"edit_italic" = "Ležeče"; +"edit_underline" = "Podčrtano"; +"edit_strikethrough" = "Prečrtano"; +"edit_text_color" = "Barva besedila"; +"edit_highlight" = "Poudari"; +"edit_font_size" = "Velikost besedila"; +"edit_undo" = "Razveljavi"; +"edit_redo" = "Ponovi"; + +/* The marks a PDF takes */ +"mark_pdf" = "Označi ta PDF"; +"mark_highlight" = "Poudari"; +"mark_underline" = "Podčrtaj"; +"mark_strike_out" = "Prečrtaj"; +"mark_squiggly" = "Valovito podčrtanje"; +"mark_draw" = "Riši"; + +/* The colors the menus offer */ +"color_black" = "Črna"; +"color_red" = "Rdeča"; +"color_blue" = "Modra"; +"color_green" = "Zelena"; +"color_yellow" = "Rumena"; +"color_pink" = "Rožnata"; +"color_none" = "Brez poudarka"; +"color_custom" = "Druga barva…"; + +/* Why an edit was not taken */ +"edit_refused_formula" = "Ta celica vsebuje formulo in ostane, kot je."; +"edit_refused_formula_input" = "Vnos formul še ni podprt."; +"edit_refused_rich" = "Ta celica vsebuje več kot navadno besedilo in ostane, kot je."; +"edit_refused_generic" = "To urejanje tu ni mogoče."; +"edit_refused_new_line" = "Preloma vrstice znotraj odstavka ni mogoče shraniti. Pritisnite Enter za nov odstavek."; +"edit_refused_read_only" = "Tega dokumenta ni mogoče urejati."; +"edit_refused_range" = "Urejanje ne more segati čez sliko ali tabelo."; + +/* What Pro adds, shown in the Lite app */ +"pro_feature_title" = "Del različice Pro"; +"pro_feature_formatting" = "Oblikovanje besedila ter dodajanje ali združevanje odstavkov je del OpenDocument Reader Pro."; +"pro_feature_pdf" = "Označevanje PDF je del OpenDocument Reader Pro."; +"not_now" = "Ne zdaj"; + +/* Shown when the marking tools come up on a PDF */ +"mark_hint" = "Izberite besedilo, nato orodje, da ga označite."; + +/* The badge in front of the tools that are Pro's, in the Lite app */ +"tool_pro_badge" = "Pro"; + +/* The chevron beside a tool, which picks the color it uses: "Highlight color" */ +"edit_color_of" = "Barva: %@"; + +/* The size of the selected text, on the size tool: "12 pt" */ +"edit_font_size_points" = "%@ pt"; diff --git a/OpenDocumentReader/sl.lproj/Localizable.stringsdict b/OpenDocumentReader/sl.lproj/Localizable.stringsdict new file mode 100644 index 0000000..54ac580 --- /dev/null +++ b/OpenDocumentReader/sl.lproj/Localizable.stringsdict @@ -0,0 +1,26 @@ + + + + + edit_cells_stale + + NSStringLocalizedFormatKey + %#@cells@ + cells + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d celica s formulo prikazuje rezultat, ki ga je vaše urejanje naredilo zastarelega. Shranjena datoteka ohrani formulo, program za preglednice pa jo znova izračuna. + two + %d celici s formulo prikazujeta rezultata, ki ju je vaše urejanje naredilo zastarela. Shranjena datoteka ohrani formuli, program za preglednice pa ju znova izračuna. + few + %d celice s formulo prikazujejo rezultate, ki jih je vaše urejanje naredilo zastarele. Shranjena datoteka ohrani formule, program za preglednice pa jih znova izračuna. + other + %d celic s formulo prikazuje rezultate, ki jih je vaše urejanje naredilo zastarele. Shranjena datoteka ohrani formule, program za preglednice pa jih znova izračuna. + + + + diff --git a/OpenDocumentReader/tr.lproj/Localizable.strings b/OpenDocumentReader/tr.lproj/Localizable.strings index f435bc8..0efc3ea 100644 --- a/OpenDocumentReader/tr.lproj/Localizable.strings +++ b/OpenDocumentReader/tr.lproj/Localizable.strings @@ -106,3 +106,59 @@ /* Shown for a format odrcore does not read */ "toast_error_illegal_file_reopen" = "Desteklenmeyen dosya biçimi. Başka bir uygulamada açmayı deneyin."; + +/* The editing tools under the bar, read by VoiceOver */ +"edit_bold" = "Kalın"; +"edit_italic" = "İtalik"; +"edit_underline" = "Altı çizili"; +"edit_strikethrough" = "Üstü çizili"; +"edit_text_color" = "Metin rengi"; +"edit_highlight" = "Vurgula"; +"edit_font_size" = "Metin boyutu"; +"edit_undo" = "Geri al"; +"edit_redo" = "Yinele"; + +/* The marks a PDF takes */ +"mark_pdf" = "Bu PDF’i işaretle"; +"mark_highlight" = "Vurgula"; +"mark_underline" = "Altını çiz"; +"mark_strike_out" = "Üstünü çiz"; +"mark_squiggly" = "Dalgalı alt çizgi"; +"mark_draw" = "Çiz"; + +/* The colors the menus offer */ +"color_black" = "Siyah"; +"color_red" = "Kırmızı"; +"color_blue" = "Mavi"; +"color_green" = "Yeşil"; +"color_yellow" = "Sarı"; +"color_pink" = "Pembe"; +"color_none" = "Vurgu yok"; +"color_custom" = "Başka renk…"; + +/* Why an edit was not taken */ +"edit_refused_formula" = "Bu hücrede bir formül var ve olduğu gibi kalır."; +"edit_refused_formula_input" = "Formül yazma henüz desteklenmiyor."; +"edit_refused_rich" = "Bu hücrede düz metinden fazlası var ve olduğu gibi kalır."; +"edit_refused_generic" = "Bu düzenleme burada yapılamaz."; +"edit_refused_new_line" = "Paragraf içindeki satır sonu kaydedilemez. Yeni paragraf için Enter’a basın."; +"edit_refused_read_only" = "Bu belge düzenlenemez."; +"edit_refused_range" = "Bir düzenleme bir resmin ya da tablonun üzerinden geçemez."; + +/* What Pro adds, shown in the Lite app */ +"pro_feature_title" = "Pro’nun parçası"; +"pro_feature_formatting" = "Metni biçimlendirmek ve paragraf eklemek ya da birleştirmek OpenDocument Reader Pro’nun bir parçasıdır."; +"pro_feature_pdf" = "PDF işaretlemek OpenDocument Reader Pro’nun bir parçasıdır."; +"not_now" = "Şimdi değil"; + +/* Shown when the marking tools come up on a PDF */ +"mark_hint" = "İşaretlemek için metni, sonra bir aracı seçin."; + +/* The badge in front of the tools that are Pro's, in the Lite app */ +"tool_pro_badge" = "Pro"; + +/* The chevron beside a tool, which picks the color it uses: "Highlight color" */ +"edit_color_of" = "%@ rengi"; + +/* The size of the selected text, on the size tool: "12 pt" */ +"edit_font_size_points" = "%@ pt"; diff --git a/OpenDocumentReader/tr.lproj/Localizable.stringsdict b/OpenDocumentReader/tr.lproj/Localizable.stringsdict new file mode 100644 index 0000000..255724a --- /dev/null +++ b/OpenDocumentReader/tr.lproj/Localizable.stringsdict @@ -0,0 +1,22 @@ + + + + + edit_cells_stale + + NSStringLocalizedFormatKey + %#@cells@ + cells + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d formül hücresi, düzenlemenizin eskittiği bir sonuç gösteriyor. Kaydedilen dosya formülü korur ve bir hesap tablosu uygulaması onu yeniden hesaplar. + other + %d formül hücresi, düzenlemenizin eskittiği sonuçlar gösteriyor. Kaydedilen dosya formülleri korur ve bir hesap tablosu uygulaması onları yeniden hesaplar. + + + + diff --git a/OpenDocumentReader/zh-Hans.lproj/Localizable.strings b/OpenDocumentReader/zh-Hans.lproj/Localizable.strings index 531720e..6ecbb45 100644 --- a/OpenDocumentReader/zh-Hans.lproj/Localizable.strings +++ b/OpenDocumentReader/zh-Hans.lproj/Localizable.strings @@ -106,3 +106,59 @@ /* Shown for a format odrcore does not read */ "toast_error_illegal_file_reopen" = "不支持的文件格式。请尝试用其他应用打开。"; + +/* The editing tools under the bar, read by VoiceOver */ +"edit_bold" = "粗体"; +"edit_italic" = "斜体"; +"edit_underline" = "下划线"; +"edit_strikethrough" = "删除线"; +"edit_text_color" = "文字颜色"; +"edit_highlight" = "高亮"; +"edit_font_size" = "文字大小"; +"edit_undo" = "撤销"; +"edit_redo" = "重做"; + +/* The marks a PDF takes */ +"mark_pdf" = "标注此 PDF"; +"mark_highlight" = "高亮"; +"mark_underline" = "下划线"; +"mark_strike_out" = "删除线"; +"mark_squiggly" = "波浪线"; +"mark_draw" = "绘制"; + +/* The colors the menus offer */ +"color_black" = "黑色"; +"color_red" = "红色"; +"color_blue" = "蓝色"; +"color_green" = "绿色"; +"color_yellow" = "黄色"; +"color_pink" = "粉色"; +"color_none" = "无高亮"; +"color_custom" = "其他颜色…"; + +/* Why an edit was not taken */ +"edit_refused_formula" = "该单元格包含公式,保持不变。"; +"edit_refused_formula_input" = "暂不支持输入公式。"; +"edit_refused_rich" = "该单元格包含的不只是纯文本,保持不变。"; +"edit_refused_generic" = "此处无法进行该编辑。"; +"edit_refused_new_line" = "段落内的换行无法保存。请按回车键开始新段落。"; +"edit_refused_read_only" = "此文档无法编辑。"; +"edit_refused_range" = "编辑不能跨越图片或表格。"; + +/* What Pro adds, shown in the Lite app */ +"pro_feature_title" = "Pro 功能"; +"pro_feature_formatting" = "设置文字格式以及添加或合并段落是 OpenDocument Reader Pro 的功能。"; +"pro_feature_pdf" = "标注 PDF 是 OpenDocument Reader Pro 的功能。"; +"not_now" = "暂不"; + +/* Shown when the marking tools come up on a PDF */ +"mark_hint" = "先选择文本,再选择工具即可标注。"; + +/* The badge in front of the tools that are Pro's, in the Lite app */ +"tool_pro_badge" = "Pro"; + +/* The chevron beside a tool, which picks the color it uses: "Highlight color" */ +"edit_color_of" = "%@颜色"; + +/* The size of the selected text, on the size tool: "12 pt" */ +"edit_font_size_points" = "%@ 磅"; diff --git a/OpenDocumentReader/zh-Hans.lproj/Localizable.stringsdict b/OpenDocumentReader/zh-Hans.lproj/Localizable.stringsdict new file mode 100644 index 0000000..a23e589 --- /dev/null +++ b/OpenDocumentReader/zh-Hans.lproj/Localizable.stringsdict @@ -0,0 +1,20 @@ + + + + + edit_cells_stale + + NSStringLocalizedFormatKey + %#@cells@ + cells + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + other + %d 个公式单元格显示的结果因您的编辑而过时。保存的文件会保留公式,电子表格应用会重新计算。 + + + + diff --git a/OpenDocumentReaderTests/ArchiveDocumentTests.swift b/OpenDocumentReaderTests/ArchiveDocumentTests.swift index 9673e9f..0d376dd 100644 --- a/OpenDocumentReaderTests/ArchiveDocumentTests.swift +++ b/OpenDocumentReaderTests/ArchiveDocumentTests.swift @@ -43,7 +43,7 @@ class ArchiveDocumentTests: XCTestCase { let wrapper = CoreWrapper() try wrapper.translate( - documentURL.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false) + documentURL.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) XCTAssertEqual(wrapper.pageNames, ["files"]) } @@ -53,7 +53,7 @@ class ArchiveDocumentTests: XCTestCase { let wrapper = CoreWrapper() try wrapper.translate( - documentURL.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: true) + documentURL.path, into: temporaryDirectory, with: nil, editable: true, scope: .document) XCTAssertFalse(wrapper.isEditable) } @@ -63,7 +63,7 @@ class ArchiveDocumentTests: XCTestCase { let wrapper = CoreWrapper() try wrapper.translate( - documentURL.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false) + documentURL.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) let listingURL = try XCTUnwrap(wrapper.pageURLs.first) let (data, _) = try fetch(listingURL) diff --git a/OpenDocumentReaderTests/EditWorkflowTests.swift b/OpenDocumentReaderTests/EditWorkflowTests.swift index 4b0d7f4..67a1dfc 100644 --- a/OpenDocumentReaderTests/EditWorkflowTests.swift +++ b/OpenDocumentReaderTests/EditWorkflowTests.swift @@ -69,23 +69,48 @@ class EditWorkflowTests: XCTestCase { XCTAssertEqual(controller.editButton.image, UIImage(systemName: "pencil")) } - /// What used to happen instead: the button left the bar, and saving was only - /// reachable through the menu. - func testThePencilBecomesASaveButtonWhileEditing() throws { + /// The pencil is selected while editing. The save button shows only while + /// editing, and is enabled only after a change. + func testThePenStaysAndTheSaveButtonJoinsItWhileEditing() throws { openDocument() - controller.editOrSave(controller.editButton) + XCTAssertFalse(barContains(controller.saveButton)) + + controller.toggleEdit(controller.editButton) waitForEditablePage() XCTAssertTrue(document.edit) XCTAssertTrue(barContains(controller.editButton)) - XCTAssertEqual(controller.editButton.image, UIImage(systemName: "square.and.arrow.down")) + XCTAssertEqual(controller.editButton.image, UIImage(systemName: "pencil")) + XCTAssertTrue(controller.editButton.isSelected) + XCTAssertTrue(barContains(controller.saveButton)) + XCTAssertFalse(controller.saveButton.isEnabled) + + typeIntoTheFirstRun() + waitUntil { self.controller.saveButton.isEnabled } + } + + /// With nothing to lose the pen only turns the mode off, and the page stays. + func testThePenLeavesAnUnchangedEditAtOnce() throws { + openDocument() + + controller.toggleEdit(controller.editButton) + waitForEditablePage() + + controller.toggleEdit(controller.editButton) + waitForPage(where: "document.querySelectorAll('[contenteditable]').length === 0") + + XCTAssertFalse(document.edit) + XCTAssertFalse(controller.editButton.isSelected) + XCTAssertFalse(barContains(controller.saveButton)) + XCTAssertNil(controller.editToolBar.layout) } /// A document nothing can be written back to keeps the room for itself. func testACsvOffersNoEditButton() throws { - try present(try copyFixture(ofType: "csv")) - openDocument() + documentURL = try copyFixture(ofType: "csv") + try present(documentURL) + openDocument(where: "typeof odr === 'object'") XCTAssertFalse(controller.document?.isEditable ?? true) XCTAssertFalse(barContains(controller.editButton)) @@ -93,20 +118,20 @@ class EditWorkflowTests: XCTestCase { // MARK: - the page - /// The one thing edit mode is for. odrcore marks the text runs - /// `contenteditable`, but a tap has to reach one for the caret to be set and - /// the keyboard to unfold. + /// The one thing edit mode is for. odrcore makes the flow `contenteditable`, + /// but a tap has to reach a run for the caret to be set and the keyboard to + /// unfold. func testTappingTheTextReachesTheEditableRun() throws { openDocument() - controller.editOrSave(controller.editButton) + controller.toggleEdit(controller.editButton) waitForEditablePage() let tapped = evaluate( """ (function () { - var run = document.querySelector('[contenteditable]'); + var run = document.querySelector('x-s[data-odr-id]'); // not getBoundingClientRect: the view applies a zoom to fit, // which webkit leaves out of it but elementFromPoint expects var box = odr.getViewportRect(run); @@ -124,12 +149,157 @@ class EditWorkflowTests: XCTestCase { func testAFocusedRunTakesTheEdit() throws { openDocument() - controller.editOrSave(controller.editButton) + controller.toggleEdit(controller.editButton) waitForEditablePage() typeIntoTheFirstRun() - XCTAssertEqual(evaluate("document.querySelector('[contenteditable]').innerText") as? String, Self.editedText) + let text = evaluate("document.querySelector('x-s[data-odr-id]').textContent") as? String ?? "" + XCTAssertTrue(text.contains(Self.editedText), text) + } + + // MARK: - the tools + + /// The row under the bar: formatting for a text document, once the page + /// says it is editable, and gone again with the edit. + func testATextDocumentShowsTheFormattingToolsWhileEditing() throws { + openDocument() + + XCTAssertNil(controller.editToolBar.layout) + + controller.toggleEdit(controller.editButton) + waitForEditablePage() + waitForTools() + + XCTAssertEqual(controller.editToolBar.layout, .text) + XCTAssertTrue(controller.editToolBar.shows(.bold)) + XCTAssertTrue(controller.editToolBar.shows(.undo)) + + controller.discardChanges() + waitForPage(where: "document.querySelectorAll('x-s').length > 0") + + XCTAssertNil(controller.editToolBar.layout) + } + + /// The cells are the editor, so a spreadsheet gets only the way back. + func testASpreadsheetShowsOnlyUndoAndRedo() throws { + documentURL = try copyFixture(ofType: "ods") + try present(documentURL) + openDocument(where: "document.querySelectorAll('td').length > 0") + + controller.toggleEdit(controller.editButton) + waitForTools() + + XCTAssertEqual(controller.editToolBar.layout, .plain) + XCTAssertFalse(controller.editToolBar.shows(.bold)) + XCTAssertTrue(controller.editToolBar.shows(.undo)) + } + + /// A style the caret sits in is shown pressed, the way the page reports it. + func testTheSelectionStyleReachesTheButtons() throws { + openDocument() + + controller.toggleEdit(controller.editButton) + waitForEditablePage() + waitForTools() + + _ = evaluate("odr.onSelectionChange({ bold: true, italic: false })") + waitUntil { self.controller.editToolBar.isPressed(.bold) } + + XCTAssertFalse(controller.editToolBar.isPressed(.italic)) + } + + /// The colour bars, the size and the highlight follow the selection. + func testTheSelectionColorsAndSizeReachTheTools() throws { + openDocument() + + controller.toggleEdit(controller.editButton) + waitForEditablePage() + waitForTools() + + XCTAssertNil(controller.editToolBar.fontSizeTitle) + + _ = evaluate("odr.onSelectionChange({ color: '#e53935', highlight: '#c5e1a5', size: '12pt' })") + waitUntil { self.controller.editToolBar.isPressed(.highlight) } + + XCTAssertEqual(controller.editToolBar.color(of: .textColor)?.hexString, "#e53935") + XCTAssertEqual(controller.editToolBar.color(of: .highlight)?.hexString, "#c5e1a5") + XCTAssertEqual(controller.editToolBar.fontSizeTitle, "12 pt") + } + + /// The highlight button turns a highlight on in its colour, and off again. + func testTheHighlightButtonTogglesTheHighlight() throws { + openDocument() + + controller.toggleEdit(controller.editButton) + waitForEditablePage() + waitForTools() + selectTheFirstRun() + + controller.editToolBar.onTap?(.highlight) + waitForPage(where: "document.querySelector('x-s[data-odr-id]').style.backgroundColor !== ''") + + _ = evaluate("odr.onSelectionChange({ highlight: '#fff59d' })") + waitUntil { self.controller.editToolBar.isPressed(.highlight) } + selectTheFirstRun() + + controller.editToolBar.onTap?(.highlight) + waitForPage(where: "document.querySelector('x-s[data-odr-id]').style.backgroundColor === ''") + } + + // MARK: - a pdf + + /// The pencil is a highlighter on a pdf, and the edit is a set of marks. + func testAPdfOffersMarksAndSavesThem() throws { + documentURL = try copyFixture(ofType: "pdf") + try present(documentURL) + openDocument(where: "document.querySelectorAll('[data-odr-space]').length > 0") + + XCTAssertTrue(document.isAnnotatable) + XCTAssertTrue(barContains(controller.editButton)) + XCTAssertEqual(controller.editButton.image, UIImage(systemName: "highlighter")) + + let sizeBefore = try fileSize() + + controller.toggleEdit(controller.editButton) + waitForPage(where: "document.querySelectorAll('[data-odr-space]').length > 0") + waitForTools() + + XCTAssertEqual(controller.editToolBar.layout, .pdf) + XCTAssertTrue(controller.editToolBar.shows(.markHighlight)) + XCTAssertFalse(controller.editToolBar.shows(.redo)) + + // each marker has a colour of its own + XCTAssertEqual(controller.editToolBar.color(of: .markHighlight)?.hexString, "#ffe633") + XCTAssertEqual(controller.editToolBar.color(of: .markDraw)?.hexString, "#1e88e5") + + let marks = + evaluate( + """ + (function () { + var page = document.querySelector('[data-odr-space]'); + var range = document.createRange(); + range.selectNodeContents(page); + var selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + odr.annotation.setTool('highlight'); + odr.annotation.mark(); + return odr.annotation.list().length; + })() + """) as? Int ?? 0 + XCTAssertGreaterThan(marks, 0) + + let saved = expectation(description: "saved") + controller.saveContent { success in + XCTAssertTrue(success) + saved.fulfill() + } + wait(for: [saved], timeout: 60) + + // an incremental update: the marks are written after the file as it was + XCTAssertGreaterThan(try fileSize(), sizeBefore) + XCTAssertNoThrow(try reopenedText()) } // MARK: - the save @@ -137,7 +307,7 @@ class EditWorkflowTests: XCTestCase { func testSavingWritesTheEditToTheFile() throws { openDocument() - controller.editOrSave(controller.editButton) + controller.toggleEdit(controller.editButton) waitForEditablePage() typeIntoTheFirstRun() @@ -151,30 +321,67 @@ class EditWorkflowTests: XCTestCase { XCTAssertTrue(try reopenedText().contains(Self.editedText)) } - /// The save button is the way out of the edit as well as the way to write - /// it: a page left editable after a successful save has no button left to - /// end it. - func testSavingFromTheBarLeavesEditMode() throws { + /// A save writes the edit and stays in it, in the page rendered again. + func testSavingStaysInEditMode() throws { openDocument() - controller.editOrSave(controller.editButton) + controller.toggleEdit(controller.editButton) waitForEditablePage() typeIntoTheFirstRun() - controller.editOrSave(controller.editButton) - waitForPage(where: "document.querySelectorAll('[contenteditable]').length === 0") + let saved = expectation(description: "saved") + controller.saveAndStay { success in + XCTAssertTrue(success) + saved.fulfill() + } + wait(for: [saved], timeout: 60) - XCTAssertFalse(document.edit) - XCTAssertEqual(controller.editButton.image, UIImage(systemName: "pencil")) + waitForPage( + where: + "document.querySelectorAll('[contenteditable]').length > 0 && document.body.textContent.indexOf('\(Self.editedText)') >= 0" + ) + + XCTAssertTrue(document.edit) + XCTAssertTrue(controller.editButton.isSelected) + XCTAssertTrue(barContains(controller.saveButton)) XCTAssertTrue(try reopenedText().contains(Self.editedText)) } + /// A marker tapped with text selected marks it once and leaves no tool + /// armed. + func testAMarkerMarksASelectionOnceAndArmsNothing() throws { + documentURL = try copyFixture(ofType: "pdf") + try present(documentURL) + openDocument(where: "document.querySelectorAll('[data-odr-space]').length > 0") + + controller.toggleEdit(controller.editButton) + waitForTools() + + _ = evaluate( + """ + (function () { + var range = document.createRange(); + range.selectNodeContents(document.querySelector('[data-odr-space]')); + var selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + })() + """) + + controller.editToolBar.onTap?(.markUnderline) + waitForPage(where: "odr.annotation.list().length > 0") + + XCTAssertEqual(evaluate("odr.annotation.getTool() === null") as? Bool, true) + XCTAssertFalse(controller.editToolBar.isPressed(.markUnderline)) + waitUntil { self.controller.saveButton.isEnabled } + } + /// The way back to reading without saving, and the only one besides leaving /// the document altogether. func testDiscardingChangesLeavesEditModeAndTheFileAlone() throws { openDocument() - controller.editOrSave(controller.editButton) + controller.toggleEdit(controller.editButton) waitForEditablePage() typeIntoTheFirstRun() @@ -192,7 +399,7 @@ class EditWorkflowTests: XCTestCase { (controller.toolBar.items ?? []).contains { $0 === item } } - private func openDocument() { + private func openDocument(where condition: String = "document.querySelectorAll('x-s').length > 0") { let opened = expectation(description: "opened") document.open { success in XCTAssertTrue(success) @@ -200,7 +407,33 @@ class EditWorkflowTests: XCTestCase { } wait(for: [opened], timeout: 60) - waitForPage(where: "document.querySelectorAll('x-s').length > 0") + waitForPage(where: condition) + } + + /// The tools appear once the editable page has answered what it is. + private func waitForTools(file: StaticString = #filePath, line: UInt = #line) { + waitUntil(file: file, line: line) { self.controller.editToolBar.layout != nil } + } + + /// A message from the page lands on a later turn of the run loop. + private func waitUntil( + file: StaticString = #filePath, line: UInt = #line, _ condition: () -> Bool + ) { + let deadline = Date().addingTimeInterval(60) + + while Date() < deadline { + if condition() { return } + + _ = XCTWaiter.wait(for: [expectation(description: "a turn of the run loop")], timeout: 0.1) + } + + XCTFail("timed out waiting for the controller", file: file, line: line) + } + + private func fileSize() throws -> Int { + let attributes = try FileManager.default.attributesOfItem(atPath: documentURL.path) + + return attributes[.size] as? Int ?? 0 } private func waitForEditablePage() { @@ -224,21 +457,44 @@ class EditWorkflowTests: XCTestCase { XCTFail("timed out waiting for \(condition)", file: file, line: line) } - /// A change to the text node, which is what typing amounts to: odrcore's - /// script watches for `characterData` and notes the run it belongs to. + private func selectTheFirstRun() { + _ = evaluate( + """ + (function () { + var run = document.querySelector('x-s[data-odr-id]'); + var range = document.createRange(); + range.selectNodeContents(run); + var selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + })() + """) + } + + /// What typing amounts to: the caret in a run, and a `beforeinput` the + /// editor takes and applies itself - the same way odrcore's own tests type. private func typeIntoTheFirstRun() { _ = evaluate( """ (function () { - var run = document.querySelector('[contenteditable]'); - run.focus(); - run.firstChild.data = '\(Self.editedText)'; + var run = document.querySelector('x-s[data-odr-id]'); + var range = document.createRange(); + range.setStart(run.firstChild, 0); + range.collapse(true); + var selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + run.dispatchEvent(new InputEvent('beforeinput', { + inputType: 'insertText', + data: '\(Self.editedText) ', + bubbles: true, + cancelable: true + })); })() """) - // the mutation is reported in a microtask, so the diff is only complete - // on the next turn - _ = XCTWaiter.wait(for: [expectation(description: "the observer to run")], timeout: 0.5) + // the log is reported on the next turn + _ = XCTWaiter.wait(for: [expectation(description: "the editor to log it")], timeout: 0.5) } /// Errors are swallowed: a page that is not there yet is what the polling @@ -263,7 +519,7 @@ class EditWorkflowTests: XCTestCase { let temporaryDirectory = NSTemporaryDirectory() try wrapper.translate( - documentURL.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false) + documentURL.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) let url = try XCTUnwrap(wrapper.pageURLs.first) diff --git a/OpenDocumentReaderTests/LockedDocumentTests.swift b/OpenDocumentReaderTests/LockedDocumentTests.swift index b723344..eb4dd91 100644 --- a/OpenDocumentReaderTests/LockedDocumentTests.swift +++ b/OpenDocumentReaderTests/LockedDocumentTests.swift @@ -49,8 +49,8 @@ class LockedDocumentTests: XCTestCase { for password in [nil, "secret"] { XCTAssertThrowsError( try wrapper.translate( - documentURL.path, cache: temporaryDirectory, into: temporaryDirectory, with: password, - editable: false) + documentURL.path, into: temporaryDirectory, with: password, + editable: false, scope: .document) ) { error in XCTAssertEqual((error as NSError).code, CoreWrapperError.undecryptable.rawValue) } diff --git a/OpenDocumentReaderTests/OpenDocumentReaderTests.swift b/OpenDocumentReaderTests/OpenDocumentReaderTests.swift index 6e2975d..6060e14 100644 --- a/OpenDocumentReaderTests/OpenDocumentReaderTests.swift +++ b/OpenDocumentReaderTests/OpenDocumentReaderTests.swift @@ -42,7 +42,7 @@ class OpenDocumentReaderTests: XCTestCase { let wrapper = CoreWrapper() try wrapper.translate( - documentURL.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: true) + documentURL.path, into: temporaryDirectory, with: nil, editable: true, scope: .document) XCTAssertFalse(wrapper.pageURLs.isEmpty) XCTAssertEqual(wrapper.pageURLs.count, wrapper.pageNames.count) @@ -53,7 +53,7 @@ class OpenDocumentReaderTests: XCTestCase { let wrapper = CoreWrapper() try wrapper.translate( - documentURL.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false) + documentURL.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) XCTAssertEqual(wrapper.pageNames, ["document"]) } @@ -65,7 +65,7 @@ class OpenDocumentReaderTests: XCTestCase { let url = try copyFixture(ofType: "ods") try wrapper.translate( - url.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false) + url.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) XCTAssertEqual(wrapper.pageNames, ["Alpha", "Beta", "Gamma"]) } @@ -77,7 +77,7 @@ class OpenDocumentReaderTests: XCTestCase { let url = try copyFixture(ofType: "odp") try wrapper.translate( - url.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false) + url.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) XCTAssertEqual(wrapper.pageNames, ["document"]) } @@ -88,7 +88,7 @@ class OpenDocumentReaderTests: XCTestCase { let url = try copyFixture(ofType: "csv") try wrapper.translate( - url.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false) + url.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) XCTAssertEqual(wrapper.pageNames, ["document"]) } @@ -99,7 +99,7 @@ class OpenDocumentReaderTests: XCTestCase { let url = try copyFixture(ofType: "pdf") try wrapper.translate( - url.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false) + url.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) XCTAssertEqual(wrapper.pageNames, ["document"]) } @@ -110,7 +110,7 @@ class OpenDocumentReaderTests: XCTestCase { let url = try copyFixture(ofType: "pdf") try wrapper.translate( - url.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false) + url.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) let (data, _) = try fetch(try XCTUnwrap(wrapper.pageURLs.first)) let html = try XCTUnwrap(String(data: data, encoding: .utf8)) @@ -128,7 +128,7 @@ class OpenDocumentReaderTests: XCTestCase { for password in [nil, "wrong"] { XCTAssertThrowsError( try wrapper.translate( - url.path, cache: temporaryDirectory, into: temporaryDirectory, with: password, editable: false) + url.path, into: temporaryDirectory, with: password, editable: false, scope: .document) ) { error in XCTAssertEqual((error as NSError).code, CoreWrapperError.wrongPassword.rawValue) } @@ -140,7 +140,7 @@ class OpenDocumentReaderTests: XCTestCase { let url = try copyFixture(ofType: "pdf", named: "test-encrypted") try wrapper.translate( - url.path, cache: temporaryDirectory, into: temporaryDirectory, with: "secret", editable: false) + url.path, into: temporaryDirectory, with: "secret", editable: false, scope: .document) XCTAssertEqual(wrapper.pageNames, ["document"]) @@ -153,7 +153,7 @@ class OpenDocumentReaderTests: XCTestCase { let url = try copyFixture(ofType: "pdf") try wrapper.translate( - url.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: true) + url.path, into: temporaryDirectory, with: nil, editable: true, scope: .document) XCTAssertFalse(wrapper.isEditable) } @@ -166,7 +166,7 @@ class OpenDocumentReaderTests: XCTestCase { try Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]).write(to: image) try wrapper.translate( - image.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false) + image.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) XCTAssertEqual(wrapper.pageNames, ["image"]) } @@ -177,7 +177,7 @@ class OpenDocumentReaderTests: XCTestCase { let url = try copyFixture(ofType: "csv") try wrapper.translate( - url.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: true) + url.path, into: temporaryDirectory, with: nil, editable: true, scope: .document) XCTAssertFalse(wrapper.isEditable) } @@ -187,7 +187,7 @@ class OpenDocumentReaderTests: XCTestCase { let wrapper = CoreWrapper() try wrapper.translate( - documentURL.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: true) + documentURL.path, into: temporaryDirectory, with: nil, editable: true, scope: .document) XCTAssertTrue(wrapper.isEditable) } @@ -199,7 +199,7 @@ class OpenDocumentReaderTests: XCTestCase { let url = try copyFixture(ofType: "ods") try wrapper.translate( - url.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false) + url.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) XCTAssertFalse(wrapper.pageURLs.isEmpty) @@ -219,7 +219,7 @@ class OpenDocumentReaderTests: XCTestCase { let wrapper = CoreWrapper() try wrapper.translate( - documentURL.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false) + documentURL.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) let (data, _) = try fetch(try XCTUnwrap(wrapper.pageURLs.first)) let html = try XCTUnwrap(String(data: data, encoding: .utf8)) @@ -234,7 +234,7 @@ class OpenDocumentReaderTests: XCTestCase { let wrapper = CoreWrapper() try wrapper.translate( - documentURL.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false) + documentURL.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) let (data, _) = try fetch(try XCTUnwrap(wrapper.pageURLs.first)) let html = try XCTUnwrap(String(data: data, encoding: .utf8)) @@ -248,11 +248,11 @@ class OpenDocumentReaderTests: XCTestCase { let wrapper = CoreWrapper() try wrapper.translate( - documentURL.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false) + documentURL.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) let before = wrapper.pageURLs try wrapper.translate( - documentURL.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: true) + documentURL.path, into: temporaryDirectory, with: nil, editable: true, scope: .document) XCTAssertNotEqual(before, wrapper.pageURLs) } @@ -264,7 +264,7 @@ class OpenDocumentReaderTests: XCTestCase { let wrapper = CoreWrapper() try wrapper.translate( - documentURL.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false) + documentURL.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) let url = try XCTUnwrap(wrapper.pageURLs.first) let recorder = NavigationRecorder(finished: expectation(description: "loaded \(url)")) @@ -283,7 +283,7 @@ class OpenDocumentReaderTests: XCTestCase { let wrapper = CoreWrapper() try wrapper.translate( - documentURL.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false) + documentURL.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) let page = try XCTUnwrap(wrapper.pageURLs.first) XCTAssertTrue(CoreWrapper.isServedURL(page)) @@ -320,60 +320,120 @@ class OpenDocumentReaderTests: XCTestCase { return try result.get() } - func testBackTranslateWritesEditedDocument() throws { + /// The first run of the page, by the address an edit names it with. + private func firstRunId(of wrapper: CoreWrapper) throws -> Int { + let (data, _) = try fetch(try XCTUnwrap(wrapper.pageURLs.first)) + let html = String(decoding: data, as: UTF8.self) + + let match = try XCTUnwrap(html.range(of: #"]*data-odr-id="\d+""#, options: .regularExpression)) + let digits = html[match].split(separator: "\"").last ?? "" + + return try XCTUnwrap(Int(digits)) + } + + private func setText(_ id: Int, _ text: String) -> String { + #"{"version": 2, "ops": [{"op": "setText", "id": \#(id), "text": "\#(text)"}]}"# + } + + func testSaveWritesEditedDocument() throws { let wrapper = CoreWrapper() try wrapper.translate( - documentURL.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: true) + documentURL.path, into: temporaryDirectory, with: nil, editable: true, scope: .document) let editedURL = URL(fileURLWithPath: temporaryDirectory) .appendingPathComponent("test-edited.odt") try? FileManager.default.removeItem(at: editedURL) - let diff = """ - {"modifiedText":{"/child:3/child:0":"This is a simple test document to demonstrate the DocumentLoaderwwww example!"}} - """ - - try wrapper.backTranslate(diff, into: editedURL.path) + try wrapper.save(setText(try firstRunId(of: wrapper), "Edited by the test"), into: editedURL.path) XCTAssertTrue(FileManager.default.fileExists(atPath: editedURL.path)) } /// Where every real save lands: on the document odrcore still has open. - func testBackTranslateOverTheOpenDocumentLeavesItReadable() throws { + func testSaveOverTheOpenDocumentLeavesItReadable() throws { let wrapper = CoreWrapper() try wrapper.translate( - documentURL.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: true) - - let diff = """ - {"modifiedText":{"/child:3/child:0":"Saved over itself."}} - """ + documentURL.path, into: temporaryDirectory, with: nil, editable: true, scope: .document) - try wrapper.backTranslate(diff, into: documentURL.path) + try wrapper.save(setText(try firstRunId(of: wrapper), "Saved over itself."), into: documentURL.path) // the whole document has to survive, not only the part the edit rewrote let reopened = CoreWrapper() try reopened.translate( - documentURL.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: true) + documentURL.path, into: temporaryDirectory, with: nil, editable: true, scope: .document) XCTAssertFalse(reopened.pageURLs.isEmpty) XCTAssertTrue(reopened.isEditable) } - /// backTranslate used to dereference an empty std::optional when nothing had + /// Nothing typed is a save of the file as it is. + func testSaveWithNoOperationsWritesTheDocument() throws { + let wrapper = CoreWrapper() + + try wrapper.translate( + documentURL.path, into: temporaryDirectory, with: nil, editable: true, scope: .document) + + let editedURL = URL(fileURLWithPath: temporaryDirectory) + .appendingPathComponent("test-unedited.odt") + try? FileManager.default.removeItem(at: editedURL) + + try wrapper.save(#"{"version": 2, "ops": []}"#, into: editedURL.path) + + XCTAssertTrue(FileManager.default.fileExists(atPath: editedURL.path)) + } + + /// Saving used to dereference an empty std::optional when nothing had /// been translated yet. - func testBackTranslateWithoutTranslateFails() { + func testSaveWithoutTranslateFails() { let wrapper = CoreWrapper() let editedURL = URL(fileURLWithPath: temporaryDirectory) .appendingPathComponent("never-translated.odt") - XCTAssertThrowsError(try wrapper.backTranslate("{}", into: editedURL.path)) { error in + XCTAssertThrowsError(try wrapper.save("{}", into: editedURL.path)) { error in XCTAssertEqual((error as NSError).domain, CoreWrapperErrorDomain) } } + /// A pdf is not edited but marked, and says so; a text document does not. + func testAPdfTakesMarks() throws { + let wrapper = CoreWrapper() + let url = try copyFixture(ofType: "pdf") + + try wrapper.translate(url.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) + + XCTAssertTrue(wrapper.isAnnotatable) + XCTAssertFalse(wrapper.isEditable) + XCTAssertEqual(wrapper.editPayloadScript, "odr.annotation.getAnnotations()") + + let text = CoreWrapper() + try text.translate(documentURL.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) + + XCTAssertFalse(text.isAnnotatable) + XCTAssertEqual(text.editPayloadScript, "odr.editing.getOperations()") + } + + /// The marks go in as an update behind the file, so the file is still there. + func testSavingMarksWritesThePdf() throws { + let wrapper = CoreWrapper() + let url = try copyFixture(ofType: "pdf") + + try wrapper.translate(url.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) + + let markedURL = URL(fileURLWithPath: temporaryDirectory).appendingPathComponent("test-marked.pdf") + try? FileManager.default.removeItem(at: markedURL) + + try wrapper.save(#"{"version": 1, "annotations": []}"#, into: markedURL.path) + + let reopened = CoreWrapper() + try reopened.translate( + markedURL.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) + + XCTAssertFalse(reopened.pageURLs.isEmpty) + } + /// odrcore recognising nothing at all is the message, not a page. func testUnsupportedFileTypeReportsTypedError() throws { let wrapper = CoreWrapper() @@ -385,7 +445,7 @@ class OpenDocumentReaderTests: XCTestCase { XCTAssertThrowsError( try wrapper.translate( - notADocument.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false) + notADocument.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) ) { error in let error = error as NSError XCTAssertEqual(error.domain, CoreWrapperErrorDomain) @@ -401,11 +461,25 @@ class OpenDocumentReaderTests: XCTestCase { try "Alpha\nBeta\n".write(to: notes, atomically: true, encoding: .utf8) try wrapper.translate( - notes.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false) + notes.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) XCTAssertEqual(wrapper.pageNames, ["text"]) } + func testSavingATextFileWritesTheText() throws { + let wrapper = CoreWrapper() + + let notes = URL(fileURLWithPath: temporaryDirectory).appendingPathComponent("notes-edited.txt") + try "Alpha\nBeta\n".write(to: notes, atomically: true, encoding: .utf8) + + try wrapper.translate(notes.path, into: temporaryDirectory, with: nil, editable: true, scope: .document) + XCTAssertTrue(wrapper.isPlainText) + + try wrapper.save(#"{"version": 2, "ops": [{"op": "setContent", "text": "Gamma"}]}"#, into: notes.path) + + XCTAssertEqual(try String(contentsOf: notes, encoding: .utf8), "Gamma") + } + /// A text file comes back as `text`; a markdown one as a document, with the /// hashes and stars turned into a heading and a bold run. func testMarkdownIsReadAsProse() throws { @@ -415,7 +489,7 @@ class OpenDocumentReaderTests: XCTestCase { try "# Heading\n\nSome **bold** prose.\n".write(to: notes, atomically: true, encoding: .utf8) try wrapper.translate( - notes.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false) + notes.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) XCTAssertEqual(wrapper.pageNames, ["document"]) @@ -436,7 +510,7 @@ class OpenDocumentReaderTests: XCTestCase { try "a,b\n1,2\n".write(to: rows, atomically: true, encoding: .utf8) try wrapper.translate( - rows.path, cache: temporaryDirectory, into: temporaryDirectory, with: nil, editable: false) + rows.path, into: temporaryDirectory, with: nil, editable: false, scope: .document) XCTAssertFalse(wrapper.pageNames.isEmpty) } @@ -448,7 +522,7 @@ class OpenDocumentReaderTests: XCTestCase { measure { do { - try wrapper.translate(path, cache: directory, into: directory, with: nil, editable: true) + try wrapper.translate(path, into: directory, with: nil, editable: true, scope: .document) } catch { XCTFail("translate threw \(error)") } diff --git a/README.md b/README.md index ed6a1af..b26b15a 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,11 @@ flag cannot end up in a build whose code says otherwise. `AnalyticsManager` and `CrashManager` take no switch at all - both write to `os.Logger` and nowhere else, so there is nothing to withhold. +Pro also has `Features.advancedEditing`, from `ADVANCED_EDITING` in the same +two files. Lite renders with the editing scope `paragraph`, so odrcore refuses +formatting and paragraph changes with `outOfScope`, and the app offers Pro. +PDF marks and the tools behind the "Pro" badge offer Pro as well. + `configs/full` and `configs/lite` hold each bundle's `Info.plist` and privacy manifest, out of the synchronized folder, since anything left in there would be copied into both apps. For the same reason `scripts/make-test-fixtures.py`, which @@ -93,6 +98,20 @@ App Transport Security exception, since ATS blocks plain HTTP: local addresses and — unlike `NSAllowsArbitraryLoads` — needs no justification in App Store review. +## Editing + +Every document is rendered editable, so the pencil only calls +`odr.editing.enable()` and the page stays where it is. `EditToolBar` shows the +tools for the page: formatting for a text document, undo and redo for a sheet or +plain text, markers for a PDF. The tools match the website and +OpenDocument.droid. The page reports to the app through one +`WKScriptMessageHandler`. + +A save reads the page's log (`odr.editing.getOperations()`, or +`odr.annotation.getAnnotations()` for a PDF), and odrcore writes the file next +to the open one before it moves into place. The page then renders again and +stays in the edit. + ## Formatting Swift sources are formatted with `swift-format` from the active Xcode