From af1ee981eb50d7ab30017f8878b303b0a064c41e Mon Sep 17 00:00:00 2001 From: Alin Panaitiu Date: Thu, 19 Oct 2023 14:45:09 +0300 Subject: [PATCH] Add uncrop-pdf --- Clop/PDF.swift | 11 ++- ClopCLI/main.swift | 152 +++++++++++++++++++++++++++--------------- ReleaseNotes/2.2.6.md | 1 + Shared.swift | 22 ++++++ 4 files changed, 132 insertions(+), 54 deletions(-) diff --git a/Clop/PDF.swift b/Clop/PDF.swift index 7034f44..bfa35a8 100644 --- a/Clop/PDF.swift +++ b/Clop/PDF.swift @@ -177,6 +177,15 @@ class PDF: Optimisable { lazy var document: PDFDocument? = PDFDocument(url: path.url) + @discardableResult + func uncrop(saveTo newPath: FilePath? = nil) -> Bool { + guard let document else { + return false + } + document.uncrop() + return document.write(to: newPath?.url ?? path.url) + } + @discardableResult func cropTo(aspectRatio: Double, alwaysPortrait: Bool = false, alwaysLandscape: Bool = false, saveTo newPath: FilePath? = nil) -> Bool { guard let document else { @@ -290,7 +299,7 @@ let GHOSTSCRIPT_ENV = ["GS_LIB": BIN_DIR.appending(path: "share/ghostscript/10.0 optimisedPDF = try pdf.optimise(optimiser: optimiser, aggressiveOptimisation: aggressiveOptimisation) if let cropSize { - optimisedPDF!.cropTo(aspectRatio: cropSize.aspectRatio) + optimisedPDF!.cropTo(aspectRatio: cropSize.fractionalAspectRatio) } if !allowLarger, cropSize == nil, optimisedPDF!.fileSize >= fileSize { pdf.path.restore(force: true) diff --git a/ClopCLI/main.swift b/ClopCLI/main.swift index f03c2fe..f41f781 100644 --- a/ClopCLI/main.swift +++ b/ClopCLI/main.swift @@ -257,19 +257,104 @@ extension UserDefaults { } } -extension NSSize { - var fractionalAspectRatio: Double { - min(width, height) / max(width, height) +func getPDFsFromFolder(_ folder: FilePath, recursive: Bool) -> [FilePath] { + guard let enumerator = FileManager.default.enumerator( + at: folder.url, + includingPropertiesForKeys: [.isRegularFileKey, .nameKey, .isDirectoryKey], + options: [.skipsPackageDescendants] + ) else { + return [] } -} -extension Double { - var fractionalAspectRatio: Double { - self > 1 ? 1 / self : self + + var pdfs: [FilePath] = [] + + for case let fileURL as URL in enumerator { + guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .nameKey, .isDirectoryKey]), + let isDirectory = resourceValues.isDirectory, let isRegularFile = resourceValues.isRegularFile, let name = resourceValues.name + else { + continue + } + + if isDirectory { + if !recursive || name.hasPrefix(".") || ["node_modules", ".git"].contains(name) { + enumerator.skipDescendants() + } + continue + } + + if !isRegularFile { + continue + } + + if !name.lowercased().hasSuffix(".pdf") { + continue + } + pdfs.append(FilePath(fileURL.path)) } + return pdfs } struct Clop: ParsableCommand { + struct UncropPdf: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Brings back PDFs to their original size by removing the crop box from PDFs that were cropped non-destructively." + ) + + @Option(name: .shortAndLong, help: "Output file path (defaults to modifying the PDF in place). In case of uncropping multiple files, this needs to be a folder.") + var output: String? = nil + + @Flag(name: .shortAndLong, help: "Uncrop all PDFs in subfolders (when using a folder as input)") + var recursive = false + + @Argument(help: "PDFs to uncrop (can be a file, folder, or list of files)") + var pdfs: [FilePath] = [] + + var foundPDFs: [FilePath] = [] + + mutating func validate() throws { + guard output == nil || !output!.isEmpty else { + throw ValidationError("Output path cannot be empty") + } + guard !pdfs.isEmpty else { + throw ValidationError("At least one PDF file or folder must be specified") + } + + var isDir: ObjCBool = false + if let folder = pdfs.first, FileManager.default.fileExists(atPath: folder.string, isDirectory: &isDir), isDir.boolValue { + foundPDFs = getPDFsFromFolder(folder, recursive: recursive) + } else { + foundPDFs = pdfs + } + try checkOutputIsDir(output, itemCount: foundPDFs.count) + } + + mutating func run() throws { + for pdf in foundPDFs.compactMap({ PDFDocument(url: $0.url) }) { + let pdfPath = pdf.documentURL!.filePath + print("Uncropping \(pdfPath.string)", terminator: "") + pdf.uncrop() + + let outFilePath: FilePath = + if let path = output?.filePath, path.string.contains("/") + { + path.isDir ? path.appending(pdfPath.name) : path.dir / generateFileName(template: path.name.string, for: pdfPath, autoIncrementingNumber: &UserDefaults.standard.lastAutoIncrementingNumber) + } else if let output { + pdfPath.dir / generateFileName(template: output, for: pdfPath, autoIncrementingNumber: &UserDefaults.standard.lastAutoIncrementingNumber) + } else { + pdfPath + } + + print(" -> saved to \(outFilePath.string)") + pdf.write(to: outFilePath.url) + } + } + } + struct CropPdf: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Crops PDFs to a specific aspect ratio without optimising them. The operation is non-destructive and can be reversed with the `uncrop-pdf` command." + ) + @Option(help: "Crops pages to fit the screen of a specific device (e.g. iPad Air)") var forDevice: String? = nil @@ -302,49 +387,12 @@ struct Clop: ParsableCommand { @Option(name: .shortAndLong, help: "Output file path (defaults to modifying the PDF in place). In case of cropping multiple files, this needs to be a folder.") var output: String? = nil - @Argument(help: "PDFs to crop (can be a folder)") + @Argument(help: "PDFs to crop (can be a file, folder, or list of files)") var pdfs: [FilePath] = [] var foundPDFs: [FilePath] = [] var ratio: Double! - func getPDFsFromFolder(_ folder: FilePath) -> [FilePath] { - guard let enumerator = FileManager.default.enumerator( - at: folder.url, - includingPropertiesForKeys: [.isRegularFileKey, .nameKey, .isDirectoryKey], - options: [.skipsPackageDescendants] - ) else { - return [] - } - - var pdfs: [FilePath] = [] - - for case let fileURL as URL in enumerator { - guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .nameKey, .isDirectoryKey]), - let isDirectory = resourceValues.isDirectory, let isRegularFile = resourceValues.isRegularFile, let name = resourceValues.name - else { - continue - } - - if isDirectory { - if !recursive || name.hasPrefix(".") || ["node_modules", ".git"].contains(name) { - enumerator.skipDescendants() - } - continue - } - - if !isRegularFile { - continue - } - - if !name.lowercased().hasSuffix(".pdf") { - continue - } - pdfs.append(FilePath(fileURL.path)) - } - return pdfs - } - mutating func validate() throws { if listDevices { print(DEVICES_STR) @@ -363,19 +411,16 @@ struct Clop: ParsableCommand { guard ratio > 0 else { throw ValidationError("Invalid aspect ratio, must be greater than 0") } - if pdfs.count > 1, let output, output.hasSuffix(".pdf") { - throw ValidationError("Output path must be a folder when cropping multiple PDFs") - } guard output == nil || !output!.isEmpty else { throw ValidationError("Output path cannot be empty") } guard !pdfs.isEmpty else { - throw ValidationError("At least one PDF or folder must be specified") + throw ValidationError("At least one PDF file or folder must be specified") } var isDir: ObjCBool = false if let folder = pdfs.first, FileManager.default.fileExists(atPath: folder.string, isDirectory: &isDir), isDir.boolValue { - foundPDFs = getPDFsFromFolder(folder) + foundPDFs = getPDFsFromFolder(folder, recursive: recursive) } else { foundPDFs = pdfs } @@ -463,7 +508,7 @@ struct Clop: ParsableCommand { var urls: [URL] = [] - @Argument(help: "Images, videos, PDFs or URLs to crop (can be a folder)") + @Argument(help: "Images, videos, PDFs or URLs to crop (can be a file, folder, or list of files)") var items: [String] = [] mutating func validate() throws { @@ -547,7 +592,7 @@ struct Clop: ParsableCommand { var urls: [URL] = [] - @Argument(help: "Images, videos or URLs to downscale (can be a folder)") + @Argument(help: "Images, videos or URLs to downscale (can be a file, folder, or list of files)") var items: [String] = [] mutating func validate() throws { @@ -635,7 +680,7 @@ struct Clop: ParsableCommand { var urls: [URL] = [] - @Argument(help: "Images, videos, PDFs or URLs to optimise (can be a folder)") + @Argument(help: "Images, videos, PDFs or URLs to optimise (can be a file, folder, or list of files)") var items: [String] = [] mutating func validate() throws { @@ -674,6 +719,7 @@ struct Clop: ParsableCommand { Crop.self, Downscale.self, CropPdf.self, + UncropPdf.self, ] ) } diff --git a/ReleaseNotes/2.2.6.md b/ReleaseNotes/2.2.6.md index 8ed83ca..9b0d93f 100644 --- a/ReleaseNotes/2.2.6.md +++ b/ReleaseNotes/2.2.6.md @@ -1,6 +1,7 @@ ## Improvements - Add **Share** submenu to the right click menu +- Add `uncrop-pdf` command to the CLI to reverse the `crop-pdf` command ## Fixes diff --git a/Shared.swift b/Shared.swift index 2babee0..0ef7b20 100644 --- a/Shared.swift +++ b/Shared.swift @@ -216,7 +216,17 @@ extension URL { import PDFKit +extension Double { + var fractionalAspectRatio: Double { + self > 1 ? 1 / self : self + } +} + extension NSSize { + var fractionalAspectRatio: Double { + min(width, height) / max(width, height) + } + func cropToPortrait(aspectRatio: Double) -> NSRect { let selfAspectRatio = width / height if selfAspectRatio > aspectRatio { @@ -286,6 +296,14 @@ extension PDFDocument { page.setBounds(cropRect, for: .cropBox) } } + func uncrop() { + guard pageCount > 0 else { return } + + for i in 0 ..< pageCount { + let page = page(at: i)! + page.setBounds(page.bounds(for: .mediaBox), for: .cropBox) + } + } } let PAPER_SIZES_BY_CATEGORY = [ @@ -513,6 +531,10 @@ struct CropSize: Codable, Hashable, Identifiable { var name = "" var longEdge = false + var fractionalAspectRatio: Double { + min(width, height).d / max(width, height).d + } + var id: String { "\(width == 0 ? "Auto" : width.s)×\(height == 0 ? "Auto" : height.s)" } var area: Int { (width == 0 ? height : width) * (height == 0 ? width : height) } var ns: NSSize { NSSize(width: width, height: height) }