一、问题背景
在使用 WKWebView 的 createPDF 方法把一个网页的内容生成为 PDF 的时候,发现通常生成的 PDF 都是只有一页,但当网页足够长时,生成的 PDF 会被分为多页。
例如,使用 这个很长的网页 进行测试,会发现生成的 PDF 被分为了 6 页,前 5 页的分辨率为 390 × 14400,在 72dpi 下,1 厘米 ≈ 28.346 像素,所以对应 13.758 厘米 × 508.000 厘米,也就是一页 PDF 被限制到了这么高。
下文就对这个 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"))
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;其次,放大到一定程度可以看到下图二下图三的对比。
回顾这个思路,我最终要的是 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"))
最终的效果是很符合预期的。
三、最终效果
左为解决之前,右为解决之后。
拼接处细节:
封装好的函数:
func convertMultiPageToSinglePage(data oldPdfData: Data) -> 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,若因时效性原因导致文中内容有所纰漏,敬请谅解,也欢迎批评指正。