diff --git a/CHANGELOG.md b/CHANGELOG.md index 8456090..b197fd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ Rules: `Unreleased`. - Add the date and the compare link once the version tag exists. +## [Unreleased] + +### Fixed + +- A PDF prints as the file itself, one page to a sheet. A PDF with a password + or with unsaved marks prints from the screen as before. +- Printing starts on the paper and orientation of the document, for example A4 + or a landscape slide, where the printer has that paper. + ## [1.48] ### Added diff --git a/OpenDocumentReader/CoreWrapper.swift b/OpenDocumentReader/CoreWrapper.swift index fd3f422..92550a9 100644 --- a/OpenDocumentReader/CoreWrapper.swift +++ b/OpenDocumentReader/CoreWrapper.swift @@ -86,6 +86,10 @@ private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [ /// Whether the file is plain text, which takes typing but no formatting. @objc var isPlainText: Bool { lock.withLock { textFile != nil } } + /// The page size the document is laid out for, in points; zero where it has none. + @objc private(set) var pageSize: CGSize = .zero + /// Whether the file is a pdf the print system can read as it is. + @objc private(set) var isPrintablePdf = false private var document: OdrCoreObjC.Document? private var textFile: TextFile? @@ -120,6 +124,8 @@ private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [ textFile = nil pdfFile = nil isArchive = false + pageSize = .zero + isPrintablePdf = false let fileTypes = (try? DecodedFile.listFileTypes(path: inputPath)) ?? [] guard !fileTypes.isEmpty else { @@ -127,7 +133,8 @@ private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [ } var file = try DecodedFile.decode(path: inputPath) - if file.isPasswordEncrypted { + let encrypted = file.isPasswordEncrypted + if encrypted { do { file = try file.decrypt(withPassword: password ?? "") } catch let error as NSError @@ -184,6 +191,7 @@ private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [ let openedDocument: OdrCoreObjC.Document? var openedTextFile: TextFile? var openedPdfFile: PdfFile? + var openedPageSize: CGSize? let service: HtmlService if file.isDocumentFile { @@ -194,6 +202,7 @@ 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 + openedPageSize = Self.pageSize(of: document) service = try HtmlTranslator.translate(document: document, config: config) } else { // `.unknown` keeps the single view each of these has - @@ -227,12 +236,32 @@ private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [ self.document = openedDocument self.textFile = openedTextFile self.pdfFile = openedPdfFile + pageSize = openedPageSize ?? .zero + isPrintablePdf = file.isPdfFile && !encrypted isArchive = file.isArchiveFile pageNames = views.map(\.name) pageURLs = views.map { base.appendingPathComponent($0.path) } } + /// The first page's: a presentation has one size, and a drawing seldom more. + private static func pageSize(of document: OdrCoreObjC.Document) -> CGSize? { + guard let root = try? document.rootElement() else { + return nil + } + let layout: PageLayout? + switch document.documentType { + case .text: layout = (root as? TextRoot)?.pageLayout + case .presentation: layout = (root.firstChild as? Slide)?.pageLayout + case .drawing: layout = (root.firstChild as? OdrCoreObjC.Page)?.pageLayout + default: layout = nil + } + guard let layout else { + return nil + } + return PrintPaper.size(width: layout.width, height: layout.height) + } + /// 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 { diff --git a/OpenDocumentReader/Document.swift b/OpenDocumentReader/Document.swift index dd8ed67..06ea804 100644 --- a/OpenDocumentReader/Document.swift +++ b/OpenDocumentReader/Document.swift @@ -88,6 +88,10 @@ class Document: UIDocument { public var isAnnotatable = false /// Whether this is plain text - see `CoreWrapper.isPlainText`. public var isPlainText = false + /// See `CoreWrapper.pageSize`; nil where it has none. + public var pageSize: CGSize? + /// See `CoreWrapper.isPrintablePdf`. + public var isPrintablePdf = false override func load(fromContents contents: Any, ofType typeName: String?) throws { parse() @@ -103,6 +107,8 @@ class Document: UIDocument { isEditable = false isAnnotatable = false isPlainText = false + pageSize = nil + isPrintablePdf = false result = nil pageURLs = nil pageNames = nil @@ -134,6 +140,8 @@ class Document: UIDocument { isEditable = coreWrapper.isEditable isAnnotatable = coreWrapper.isAnnotatable isPlainText = coreWrapper.isPlainText + pageSize = coreWrapper.pageSize == .zero ? nil : coreWrapper.pageSize + isPrintablePdf = coreWrapper.isPrintablePdf loadProgress.completedUnitCount = loadProgress.totalUnitCount diff --git a/OpenDocumentReader/DocumentViewController.swift b/OpenDocumentReader/DocumentViewController.swift index cbb7427..29d2a37 100644 --- a/OpenDocumentReader/DocumentViewController.swift +++ b/OpenDocumentReader/DocumentViewController.swift @@ -1308,12 +1308,33 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel printInfo.outputType = UIPrintInfo.OutputType.general printInfo.jobName = "OpenDocument Reader - Document" + // the controller is shared, so each job states all of them + printController.printingItem = nil + printController.printFormatter = nil + + let pageSize: CGSize? + // the file has no unsaved marks, and prints at its own page size + if let document, document.isPrintablePdf, !hasUnsavedEdits { + printController.printingItem = document.fileURL + pageSize = PrintPaper.firstPageSize(ofPDFAt: document.fileURL) + } else { + printController.printFormatter = webview.viewPrintFormatter() + pageSize = document?.pageSize + } + + paperChooser = pageSize.map { PaperChooser(pageSize: $0) } + if let paperChooser { + printInfo.orientation = paperChooser.orientation + } + printController.delegate = paperChooser printController.printInfo = printInfo - printController.printFormatter = webview.viewPrintFormatter() printController.present(animated: true, completionHandler: nil) } + /// Held here: the print controller holds its delegate weakly. + private var paperChooser: PaperChooser? + /// Loading and error pages follow system appearance. private func loadMessage(_ body: String) { webview.loadHTMLString( diff --git a/OpenDocumentReader/PrintPaper.swift b/OpenDocumentReader/PrintPaper.swift new file mode 100644 index 0000000..e62a9ea --- /dev/null +++ b/OpenDocumentReader/PrintPaper.swift @@ -0,0 +1,66 @@ +import CoreGraphics +import OdrCoreObjC +import UIKit + +/// The sheet a document prints on, in points, the unit UIKit prints in. +enum PrintPaper { + /// Nil where either side is missing or not a length. + static func size(width: Measure?, height: Measure?) -> CGSize? { + guard let width, let height, + let widthPoints = points(width.magnitude, unit: width.unit), + let heightPoints = points(height.magnitude, unit: height.unit) + else { + return nil + } + return CGSize(width: widthPoints, height: heightPoints) + } + + static func points(_ magnitude: Double, unit: String) -> CGFloat? { + let perUnit: Double + switch unit { + case "pt": perUnit = 1 + case "in": perUnit = 72 + case "cm": perUnit = 72 / 2.54 + case "mm": perUnit = 72 / 25.4 + case "pc": perUnit = 12 + case "px": perUnit = 0.75 + default: return nil + } + let result = magnitude * perUnit + return result > 0 ? CGFloat(result) : nil + } + + /// The first page as a viewer shows it: the crop box, turned by `/Rotate`. + static func firstPageSize(ofPDFAt url: URL) -> CGSize? { + guard let page = CGPDFDocument(url as CFURL)?.page(at: 1) else { + return nil + } + let box = page.getBoxRect(.cropBox) + return page.rotationAngle % 180 == 0 + ? box.size : CGSize(width: box.height, height: box.width) + } +} + +/// Picks the printer's paper for one page size. UIKit takes the size portrait +/// and turns the sheet by `UIPrintInfo.orientation`. +final class PaperChooser: NSObject, UIPrintInteractionControllerDelegate { + private let pageSize: CGSize + + init(pageSize: CGSize) { + self.pageSize = pageSize + } + + var orientation: UIPrintInfo.Orientation { + pageSize.width > pageSize.height ? .landscape : .portrait + } + + func printInteractionController( + _ printInteractionController: UIPrintInteractionController, + choosePaper paperList: [UIPrintPaper] + ) -> UIPrintPaper { + let portrait = CGSize( + width: min(pageSize.width, pageSize.height), + height: max(pageSize.width, pageSize.height)) + return UIPrintPaper.bestPaper(forPageSize: portrait, withPapersFrom: paperList) + } +} diff --git a/OpenDocumentReaderTests/PrintPaperTests.swift b/OpenDocumentReaderTests/PrintPaperTests.swift new file mode 100644 index 0000000..ae6519d --- /dev/null +++ b/OpenDocumentReaderTests/PrintPaperTests.swift @@ -0,0 +1,58 @@ +import XCTest + +@testable import OpenDocumentReader + +class PrintPaperTests: XCTestCase { + private let temporaryDirectory = NSTemporaryDirectory() + + private func fixture(_ name: String, _ pathExtension: String) throws -> URL { + let documentsURL = try FileManager.default.url( + for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) + let url = documentsURL.appendingPathComponent(name + "." + pathExtension) + try? FileManager.default.removeItem(at: url) + let bundlePath = try XCTUnwrap( + Bundle(for: Self.self).path(forResource: name, ofType: pathExtension)) + try FileManager.default.copyItem(at: URL(fileURLWithPath: bundlePath), to: url) + return url + } + + private func translated(_ url: URL, password: String? = nil) throws -> CoreWrapper { + let wrapper = CoreWrapper() + try wrapper.translate( + url.path, into: temporaryDirectory, with: password, editable: false, scope: .document) + return wrapper + } + + func testLengthsBecomePoints() { + XCTAssertEqual(PrintPaper.points(8.5, unit: "in"), 612) + XCTAssertEqual(try XCTUnwrap(PrintPaper.points(21, unit: "cm")), 595.28, accuracy: 0.01) + XCTAssertEqual(try XCTUnwrap(PrintPaper.points(297, unit: "mm")), 841.89, accuracy: 0.01) + XCTAssertEqual(PrintPaper.points(96, unit: "px"), 72) + XCTAssertNil(PrintPaper.points(50, unit: "%")) + XCTAssertNil(PrintPaper.points(0, unit: "cm")) + } + + func testATextDocumentPrintsOnItsPage() throws { + let wrapper = try translated(try fixture("test", "odt")) + XCTAssertGreaterThan(wrapper.pageSize.height, wrapper.pageSize.width) + XCTAssertFalse(wrapper.isPrintablePdf) + } + + func testASlidePrintsLandscape() throws { + let wrapper = try translated(try fixture("test", "odp")) + XCTAssertGreaterThan(wrapper.pageSize.width, wrapper.pageSize.height) + XCTAssertEqual(PaperChooser(pageSize: wrapper.pageSize).orientation, .landscape) + } + + func testAPdfPrintsItself() throws { + let url = try fixture("test", "pdf") + let wrapper = try translated(url) + XCTAssertTrue(wrapper.isPrintablePdf) + XCTAssertNotNil(PrintPaper.firstPageSize(ofPDFAt: url)) + } + + func testAnEncryptedPdfPrintsThePage() throws { + let wrapper = try translated(try fixture("test-encrypted", "pdf"), password: "secret") + XCTAssertFalse(wrapper.isPrintablePdf) + } +}