Skip to content

Commit

Permalink
Add uncrop-pdf
Browse files Browse the repository at this point in the history
  • Loading branch information
alin23 committed Oct 19, 2023
1 parent 052d6ac commit af1ee98
Show file tree
Hide file tree
Showing 4 changed files with 132 additions and 54 deletions.
11 changes: 10 additions & 1 deletion Clop/PDF.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
152 changes: 99 additions & 53 deletions ClopCLI/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -674,6 +719,7 @@ struct Clop: ParsableCommand {
Crop.self,
Downscale.self,
CropPdf.self,
UncropPdf.self,
]
)
}
Expand Down
1 change: 1 addition & 0 deletions ReleaseNotes/2.2.6.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
22 changes: 22 additions & 0 deletions Shared.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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) }
Expand Down

0 comments on commit af1ee98

Please sign in to comment.