iOS实现PDF多页合并&分页

4,854 阅读3分钟

一、问题背景

在使用 WKWebViewcreatePDF 方法把一个网页的内容生成为 PDF 的时候,发现通常生成的 PDF 都是只有一页,但当网页足够长时,生成的 PDF 会被分为多页。

例如,使用 这个很长的网页 进行测试,会发现生成的 PDF 被分为了 6 页,前 5 页的分辨率为 390 × 14400,在 72dpi 下,1 厘米 ≈ 28.346 像素,所以对应 13.758 厘米 × 508.000 厘米,也就是一页 PDF 被限制到了这么高。

截屏2021-12-03_13.18.46.png 截屏2021-12-03_13.20.36.png 截屏2021-12-03_13.22.53.png

下文就对这个 input.pdf 进行操作。(本来想上传pdf文件的,但掘金好像现在还不支持?)

二、解决历程

1、首先判断是否是 iOS 系统生成 PDF 时存在天然限制

那么我就尝试生成一个长于 14400 的 pdf 文件,发现是可行的。

let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 390, height: 20000))
let data = renderer.pdfData { context in
}
try? data.write(to: URL(fileURLWithPath: "/Users/macbookpro/Desktop/test.pdf"))

截屏2021-12-04_15.26.29.png

2、把 PDF 的每页绘制到同一个单页 PDF 上

既然能够生成长的 PDF ,那我把被分页了的 PDF 的每一页都画到一个 context 上,最后不就拿到了一个单页的 PDF 吗?

我想起来之前用 Core Graphics 画图片的时候,大概是这样写的:

let text = "text"
text.draw(at: CGPoint(x: 10, y: 30))
let image = UIImage(named: "test_image")
image?.draw(in: CGRect(x: 0, y: 0, width: 100, height: 100))

那么只要我手动把 PDF 的每个 page 画在同一个 page 的特定地方就行了。

可是却发现 PDFPage 只有下面这个 draw 方法,并没有让我们自定义绘制的位置。

func draw(with box: PDFDisplayBox, to context: CGContext)

PDFDisplayBox 是一个枚举类型,希望绘制 PDF 的整个页面就用 .mediaBox。

这里要注意的一点是,PDFDocument 有两个,分别是 PDFDocument 和 CGPDFDocument,后者是 Core Graphics 里原生的表示一个 PDF 文件的类,前者是 PDFKit 中的类,相当于是封装了一层。它们的方法不太一样:

let path = "/Users/macbookpro/Desktop/input.pdf"
let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 390, height: 20000))

// 使用 PDFDocument
let data = renderer.pdfData { context in
    context.beginPage()
    // PDFDocument 的 page 是从 0 开始的
    let pdf = PDFDocument(url: URL(fileURLWithPath: path))!
    for i in 0..<pdf.pageCount {
        let page = pdf.page(at: i)!
        page.draw(with: .mediaBox, to: context.cgContext)
    }
}

// 使用 CGPDFDocument
let data = renderer.pdfData { context in
    context.beginPage()
    let cgPdf = CGPDFDocument(URL(fileURLWithPath: path) as CFURL)!
    // CGPDFDocument 的 page 是从 1 开始的
    for i in 1...cgPdf.numberOfPages {
        let page = cgPdf.page(at: i)!
        context.cgContext.drawPDFPage(page)
    }
}

我就先这样写试了下:

let path = "/Users/macbookpro/Desktop/input.pdf"
let pdf = PDFDocument(url: URL(fileURLWithPath: path))!
var width: CGFloat = 0
var totalHeight: CGFloat = 0
var allPages: [PDFPage] = []
for i in 0..<pdf.pageCount {
    let page = pdf.page(at: i)!
    width = page.bounds(for: .mediaBox).width
    totalHeight += page.bounds(for: .mediaBox).height
    allPages.append(page)
}

let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: width, height: totalHeight))
let data = renderer.pdfData { context in
    context.beginPage()
    // 转换坐标系
    context.cgContext.translateBy(x: 0, y: totalHeight)
    context.cgContext.scaleBy(x: 1.0, y: -1.0)
    for page in allPages {
        page.draw(with: .mediaBox, to: context.cgContext)
    }
}
try? data.write(to: URL(fileURLWithPath: "/Users/macbookpro/Desktop/output.pdf"))

(这里忘记截图了)

果然,直接 draw 的话,后面的 page 会覆盖掉之前的 page。可以看到 output.pdf 中第 6 页由于短,只盖住了第 5 页的下面一小部分。

3、把 PDF 的每页分别绘制成图片,然后再绘制图片到 PDF 上的特定位置

在 Google 搜了一些相关的,看到一篇将 PDF 转为图片的文章,就想到了这个思路,写了个小 demo 尝试。

let path = "/Users/macbookpro/Desktop/input.pdf"
let pdf = PDFDocument(url: URL(fileURLWithPath: path))!
var width: CGFloat = 0
var totalHeight: CGFloat = 0

// 把每页生成为图片
var allImages: [UIImage] = []
for i in 0..<pdf.pageCount {
    let page = pdf.page(at: i)!
    width = page.bounds(for: .mediaBox).width
    totalHeight += page.bounds(for: .mediaBox).height
    let renderer = UIGraphicsImageRenderer(size: page.bounds(for: .mediaBox).size)
    let image = renderer.image { context in
        page.draw(with: .mediaBox, to: context.cgContext)
    }
    allImages.append(image)
}

// 把所有图片画成一个单页 PDF
let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: width, height: totalHeight))
let data = renderer.pdfData { context in
    context.beginPage()
    var offset: CGFloat = 0
    // 转换坐标系
    context.cgContext.translateBy(x: 0, y: totalHeight)
    context.cgContext.scaleBy(x: 1.0, y: -1.0)
    for image in allImages {
        offset += image.size.height
        image.draw(in: CGRect(x: 0, y: totalHeight - offset, width: image.size.width, height: image.size.height))
    }
}
try? data.write(to: URL(fileURLWithPath: "/Users/macbookpro/Desktop/output.pdf"))

发现成品基本能满足要求,缺点也是很明显的:

  • 增加了耗时,画了一遍图片,再画一遍 PDF。
  • PDF 文件变大了,且渲染图片更为消耗性能,我在电脑上用 WPS 打开它,一卡一卡的。
  • PDF 是没有灵性的。首先不能再像原来一样选择 PDF 里的文字了,下图一是原来的 PDF;其次,放大到一定程度可以看到下图二下图三的对比。

a1c2016c-418d-492c-ad8c-76ae75705345.png

3ea5e2f3-4d08-4f4e-bcae-2d0cc5934493.png 348861e9-ab51-4cfd-8f86-48866b55559e.png

回顾这个思路,我最终要的是 PDF,最初的原料也是 PDF,我把它转成图片再转回来,这根本就不合理啊,还是再看看怎么在画 PDF 时能控制画的位置吧。

4、通过改变坐标系,来控制将要绘制的位置

上面的代码里可以看到这样两句:

// 转换坐标系
context.cgContext.translateBy(x: 0, y: totalHeight)
context.cgContext.scaleBy(x: 1.0, y: -1.0)

这是因为在 Quartz 2D 中默认的坐标系统是:原点(0,0)位于左下角,沿着 x 轴从左到右坐标值逐渐增大;沿着 y 轴从下到上坐标值逐渐增大。这和 UIView 或 PDFDocument 的坐标系是不同的,所以需要转换坐标系后再 draw。

那么是不是就可以通过在画每个 PDFPage 之前对坐标系进行一定的转换,就能控制 PDFPage 所绘制的位置了呢?经过几番尝试,终于成功了。

let path = "/Users/macbookpro/Desktop/input.pdf"
let pdf = PDFDocument(url: URL(fileURLWithPath: path))!
var width: CGFloat = 0
var totalHeight: CGFloat = 0
var allPages: [PDFPage] = []
for i in 0..<pdf.pageCount {
    let page = pdf.page(at: i)!
    width = page.bounds(for: .mediaBox).width
    totalHeight += page.bounds(for: .mediaBox).height
    allPages.append(page)
}

let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: width, height: totalHeight))
let data = renderer.pdfData { context in
    context.beginPage()
    var offset: CGFloat = 0
    for page in allPages {
    let pageBounds = page.bounds(for: .mediaBox)
        context.cgContext.translateBy(x: 0, y: offset + pageBounds.height)
        context.cgContext.scaleBy(x: 1.0, y: -1.0)
        page.draw(with: .mediaBox, to: context.cgContext)
        context.cgContext.translateBy(x: 0, y: offset + pageBounds.height)
        context.cgContext.scaleBy(x: 1.0, y: -1.0)
        offset += pageBounds.height
    }
}
try? data.write(to: URL(fileURLWithPath: "/Users/macbookpro/Desktop/output.pdf"))

最终的效果是很符合预期的。

三、最终效果

左为解决之前,右为解决之后。

40c34c91-fa24-43ee-94ee-5143a40c84d0.png

拼接处细节:

74cf1ee5-5c77-4c09-9f5e-cddd25c00229.png

封装好的函数:

func convertMultiPageToSinglePage(data oldPdfDataData) -> Data? {

    guard let oldPdf = PDFDocument(data: oldPdfData) else { return nil }
    if oldPdf.pageCount == 0 { return nil }
    if oldPdf.pageCount == 1 { return oldPdfData }

    var allPages: [PDFPage= []
    var totalHeight: CGFloat = 0
    var width: CGFloat = 0
    for i in 0..<oldPdf.pageCount {
        guard let page = oldPdf.page(at: i) else { continue }
        let bounds = page.bounds(for: .mediaBox)
        width = bounds.width
        totalHeight += bounds.height
        allPages.append(page)
    }

    let pdfBounds = CGRect(x: 0, y: 0, width: width, height: totalHeight)
    let renderer = UIGraphicsPDFRenderer(bounds: pdfBounds)
    let data = renderer.pdfData { context in
        context.beginPage()
        var offset: CGFloat = 0
        for page in allPages {
            let pageBounds = page.bounds(for: .mediaBox)
            context.cgContext.translateBy(x: 0, y: offset + pageBounds.height)
            context.cgContext.scaleBy(x: 1.0, y: -1.0)
            page.draw(with: .mediaBox, to: context.cgContext)
            context.cgContext.translateBy(x: 0, y: offset + pageBounds.height)
            context.cgContext.scaleBy(x: 1.0, y: -1.0)
            offset += pageBounds.height
        }
    }
    return data
}

四、拓展:PDF 分页

同样基于这个思路,很容易实现 PDF 的分页。

// ratioWidth 和 ratioHeight 的数值大小无所谓,传这两个是为了知道想要得到的 PDF 页的宽高比例
func convertSinglePageToMultiPage(data singlePagePdfData: Data, ratioWidth: CGFloat, ratioHeight: CGFloat) -> Data? {

    guard let singlePagePdf = PDFDocument(data: singlePagePdfData) else { return nil }
    guard let oldPage = singlePagePdf.page(at: 0) else { return nil }

    let oldPdfWidth = oldPage.bounds(for: .mediaBox).width
    let oldPdfHeight = oldPage.bounds(for: .mediaBox).height
    let pageHeight = oldPdfWidth * ratioHeight / ratioWidth
    let pageNum = Int(ceil(oldPdfHeight / pageHeight))

    let newPdfBounds = CGRect(x: 0, y: 0, width: oldPdfWidth, height: pageHeight)
    let renderer = UIGraphicsPDFRenderer(bounds: newPdfBounds)
    let data = renderer.pdfData { context in
        for i in 0..<pageNum {
            let offset = pageHeight * CGFloat(i)
            context.beginPage()
            context.cgContext.translateBy(x: 0, y: oldPdfHeight - offset)
            context.cgContext.scaleBy(x: 1.0, y: -1.0)
            oldPage.draw(with: .mediaBox, to: context.cgContext)
        }
    }
    return data
}

本文实际写于 2021 年 12 月,使用 iOS 15.0,若因时效性原因导致文中内容有所纰漏,敬请谅解,也欢迎批评指正。