Created
July 31, 2026 06:55
-
-
Save jvarn/fe1059b108d8bbbc9704d1f2c77d84bb to your computer and use it in GitHub Desktop.
macOS Swift script to repair corrupted Arabic PDF text layers using native Apple Vision OCR
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * FixArabicPDFs.swift | |
| * | |
| * A zero-dependency CLI tool for macOS that repairs PDFs containing corrupt, | |
| * uncopyable, or reversed Arabic text streams (common with MS Word PDF exports on Windows). | |
| * | |
| * HOW IT WORKS: | |
| * 1. Renders PDF pages to high-DPI in-memory bitmaps, destroying broken font/CMap tables. | |
| * 2. Applies a white margin padding canvas to prevent Arabic RTL edge-character clipping. | |
| * 3. Leverages macOS native Vision framework (`VNRecognizeTextRequest`) for Arabic OCR. | |
| * 4. Overlays a clean, invisible Unicode text layer back onto the PDF using CoreText, | |
| * making it fully searchable and copyable for PDF readers, LLMs, and NotebookLM. | |
| * | |
| * REQUIREMENTS: | |
| * - macOS 10.15 (Catalina) or later | |
| * - Swift command-line toolchain (built into macOS / Xcode Command Line Tools) | |
| * - Zero third-party dependencies (no Homebrew, Python, or Tesseract required) | |
| * | |
| * USAGE (Interpreted): | |
| * swift FixArabicPDFs.swift <file_or_directory_path> | |
| * | |
| * USAGE (Compiled - Recommended for instant startup): | |
| * swiftc -O FixArabicPDFs.swift -o fixpdf | |
| * ./fixpdf <file_or_directory_path> | |
| * | |
| * EXAMPLES: | |
| * ./fixpdf document.pdf | |
| * ./fixpdf ~/Documents/PDF_Folder | |
| */ | |
| import Foundation | |
| import PDFKit | |
| import Vision | |
| import CoreGraphics | |
| import CoreText | |
| import AppKit | |
| // Force immediate terminal output (prevents output buffering delay) | |
| func log(_ message: String, newline: Bool = true) { | |
| if newline { | |
| print(message) | |
| } else { | |
| print(message, terminator: "") | |
| } | |
| fflush(stdout) | |
| } | |
| // MARK: - Core Processing Logic | |
| struct RenderedPage { | |
| let cgImage: CGImage | |
| let canvasWidth: CGFloat | |
| let canvasHeight: CGFloat | |
| let scale: CGFloat | |
| let marginPx: CGFloat | |
| } | |
| func renderPageToCGImage(page: PDFPage, dpi: CGFloat = 300.0, marginPx: CGFloat = 0.0) -> RenderedPage? { | |
| let pageRect = page.bounds(for: .mediaBox) | |
| let scale = dpi / 72.0 | |
| let imgWidth = pageRect.width * scale | |
| let imgHeight = pageRect.height * scale | |
| let canvasWidth = imgWidth + (2 * marginPx) | |
| let canvasHeight = imgHeight + (2 * marginPx) | |
| let colorSpace = CGColorSpaceCreateDeviceRGB() | |
| guard let context = CGContext( | |
| data: nil, | |
| width: Int(canvasWidth), | |
| height: Int(canvasHeight), | |
| bitsPerComponent: 8, | |
| bytesPerRow: Int(canvasWidth) * 4, | |
| space: colorSpace, | |
| bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | |
| ) else { return nil } | |
| // Fill with white background | |
| context.setFillColor(CGColor(red: 1, green: 1, blue: 1, alpha: 1)) | |
| context.fill(CGRect(x: 0, y: 0, width: canvasWidth, height: canvasHeight)) | |
| context.saveGState() | |
| // Translate by margin and scale by DPI factor | |
| context.translateBy(x: marginPx, y: marginPx) | |
| context.scaleBy(x: scale, y: scale) | |
| page.draw(with: .mediaBox, to: context) | |
| context.restoreGState() | |
| guard let cgImage = context.makeImage() else { return nil } | |
| return RenderedPage(cgImage: cgImage, canvasWidth: canvasWidth, canvasHeight: canvasHeight, scale: scale, marginPx: marginPx) | |
| } | |
| func recognizeText(cgImage: CGImage) -> [VNRecognizedTextObservation] { | |
| var observations: [VNRecognizedTextObservation] = [] | |
| let request = VNRecognizeTextRequest { req, _ in | |
| if let results = req.results as? [VNRecognizedTextObservation] { | |
| observations = results | |
| } | |
| } | |
| request.recognitionLanguages = ["ar-SA", "en-US"] | |
| request.recognitionLevel = .accurate | |
| request.usesLanguageCorrection = true | |
| let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) | |
| try? handler.perform([request]) | |
| return observations | |
| } | |
| func processSinglePDF(inputURL: URL, outputURL: URL) { | |
| log("[INFO] Reading PDF: \(inputURL.lastPathComponent)...") | |
| guard let pdfDoc = PDFDocument(url: inputURL) else { | |
| log("[ERROR] Could not open PDF: \(inputURL.lastPathComponent)") | |
| return | |
| } | |
| let totalPages = pdfDoc.pageCount | |
| log("[INFO] Found \(totalPages) page(s). Creating output PDF...") | |
| guard let consumer = CGDataConsumer(url: outputURL as CFURL), | |
| let pdfContext = CGContext(consumer: consumer, mediaBox: nil, nil) else { | |
| log("[ERROR] Failed to create PDF output context.") | |
| return | |
| } | |
| let marginPx: CGFloat = 40.0 // 40px white border buffer for OCR | |
| for i in 0..<totalPages { | |
| let pageNum = i + 1 | |
| log(" |-- [Page \(pageNum)/\(totalPages)] Rendering image...", newline: false) | |
| guard let page = pdfDoc.page(at: i) else { | |
| log(" [FAILED to load page]") | |
| continue | |
| } | |
| let pageBounds = page.bounds(for: .mediaBox) | |
| var pageRect = pageBounds | |
| pdfContext.beginPage(mediaBox: &pageRect) | |
| // 1. Render unpadded image for the PDF visual layer | |
| guard let unpaddedPage = renderPageToCGImage(page: page, dpi: 300, marginPx: 0) else { | |
| log(" [FAILED to render image]") | |
| continue | |
| } | |
| pdfContext.draw(unpaddedPage.cgImage, in: pageBounds) | |
| // 2. Render padded image for Apple Vision OCR | |
| guard let paddedPage = renderPageToCGImage(page: page, dpi: 300, marginPx: marginPx) else { | |
| log(" [FAILED to render padded image]") | |
| continue | |
| } | |
| log(" running Vision OCR...", newline: false) | |
| let observations = recognizeText(cgImage: paddedPage.cgImage) | |
| // 3. Draw invisible text layer (translating padded coordinates back to PDF points) | |
| log(" writing text layer...", newline: false) | |
| pdfContext.saveGState() | |
| pdfContext.setTextDrawingMode(.invisible) | |
| for obs in observations { | |
| guard let candidate = obs.topCandidates(1).first else { continue } | |
| let string = candidate.string | |
| let bbox = obs.boundingBox | |
| // Calculate absolute pixel coordinates on padded canvas | |
| let pixelX = bbox.origin.x * paddedPage.canvasWidth | |
| let pixelY = bbox.origin.y * paddedPage.canvasHeight | |
| let pixelH = bbox.size.height * paddedPage.canvasHeight | |
| // Subtract margin offset and convert back to PDF point coordinates | |
| let pointX = (pixelX - marginPx) / paddedPage.scale | |
| let pointY = (pixelY - marginPx) / paddedPage.scale | |
| let fontHeightPoints = pixelH / paddedPage.scale | |
| let fontSize = max(fontHeightPoints * 0.85, 6.0) | |
| let font = CTFontCreateWithName("Helvetica" as CFString, fontSize, nil) | |
| let attributes: [NSAttributedString.Key: Any] = [.font: font] | |
| let attrString = NSAttributedString(string: string, attributes: attributes) | |
| let line = CTLineCreateWithAttributedString(attrString) | |
| pdfContext.textPosition = CGPoint(x: pointX, y: pointY) | |
| CTLineDraw(line, pdfContext) | |
| } | |
| pdfContext.restoreGState() | |
| pdfContext.endPage() | |
| log(" [OK]") | |
| } | |
| pdfContext.closePDF() | |
| log("==> Complete: \(outputURL.lastPathComponent)\n") | |
| } | |
| // MARK: - CLI Entry Point | |
| log("==> Initializing macOS Arabic PDF OCR Pipeline...") | |
| let args = CommandLine.arguments | |
| guard args.count > 1 else { | |
| log(""" | |
| Usage: | |
| swift FixArabicPDFs.swift <file_or_folder_path> | |
| """) | |
| exit(1) | |
| } | |
| let inputPath = NSString(string: args[1]).expandingTildeInPath | |
| let inputURL = URL(fileURLWithPath: inputPath) | |
| var isDir: ObjCBool = false | |
| if FileManager.default.fileExists(atPath: inputPath, isDirectory: &isDir) { | |
| if isDir.boolValue { | |
| let outputDir = inputURL.appendingPathComponent("Searchable_PDFs") | |
| try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true) | |
| let fileURLs = try FileManager.default.contentsOfDirectory(at: inputURL, includingPropertiesForKeys: nil) | |
| .filter { $0.pathExtension.lowercased() == "pdf" && !$0.lastPathComponent.hasPrefix("fixed_") } | |
| log("[INFO] Found \(fileURLs.count) PDF(s) in directory.\n") | |
| for fileURL in fileURLs { | |
| let outputURL = outputDir.appendingPathComponent("fixed_\(fileURL.lastPathComponent)") | |
| processSinglePDF(inputURL: fileURL, outputURL: outputURL) | |
| } | |
| } else { | |
| let outputURL = inputURL.deletingLastPathComponent().appendingPathComponent("fixed_\(inputURL.lastPathComponent)") | |
| processSinglePDF(inputURL: inputURL, outputURL: outputURL) | |
| } | |
| } else { | |
| log("[ERROR] Path does not exist: \(inputPath)") | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment