Skip to content

长截图功能所遇到的问题以及解决方式

轩辕十四
Published date:

Table of contents

Open Table of contents

截图清晰度

截图处理

由于笔记截图需要缩放以适应预览视图,所以截图时会出现不清晰的问题。故我们需要用笔记截图的原数据进行渲染绘制。

我们将预览视图分为四大块儿进行处理,分别为,header,note,footer,user info,因为 header 和 footer 只有在设置主题的情况下才可能存在,所以需要将 header,footer 和 user info 分别处理。如下图:

预览视图分区示意

我们将分别截取 header,footer,user info。note 的截图直接使用 pdfDataAndForcePDFAttachments(toRender: Bool) 生成的 image 数据即可,这样可以保持图片的最高清晰度。

我们对 note 截图进行放大两倍绘制,这样就能非常高清。视网膜屏幕截图的像素宽高默认为图片 size 的两倍。我们这里没有使用 lockFocus()unlockFocus() 方法,是因为这个方法的绘制与当前显示器有关,如果显示器设备不是视网膜屏那么绘制出来的图像则并不清晰。所以我们调用 NSBitmapImageRep 手动创建图像的 bitmap 数据,然后使用 NSGraphicsContext 相关方法进行最终图片的 bitmap 数据生成。

对 header,footer 和 user info 三块儿的截图我们需要将其等比放大到与 note 截图等宽大小,相当于这三块儿内容也进行了放大两倍处理,这样最终效果就会非常清晰。

/// 对 View 进行截图
/// - Parameter scale: 缩放比例
/// - Returns: 截图
func screenShot(scale: CGFloat = 1) -> NSImage? {
  // scale 为 0 无意义
  guard scale > 0 else { return nil }
  let targetSize = NSSize(width: bounds.width * scale, height: bounds.height * scale)
  guard let bitmapRep = NSBitmapImageRep(bitmapDataPlanes: nil,
                                         pixelsWide: Int(targetSize.width),
                                         pixelsHigh: Int(targetSize.height),
                                         bitsPerSample: 8,
                                         samplesPerPixel: 4,
                                         hasAlpha: true,
                                         isPlanar: false,
                                         colorSpaceName: .calibratedRGB,
                                         bytesPerRow: 0,
                                         bitsPerPixel: 0) else { return nil }
  NSGraphicsContext.saveGraphicsState()
  guard let graphicsContext = NSGraphicsContext(bitmapImageRep: bitmapRep) else { return nil }
  NSGraphicsContext.current = graphicsContext
  graphicsContext.cgContext.scaleBy(x: scale, y: scale)
  self.displayIgnoringOpacity(bounds, in: graphicsContext)
  NSGraphicsContext.restoreGraphicsState()
  
  let result = NSImage(size: bitmapRep.size)
  result.addRepresentation(bitmapRep)
  
  return result
}

主题处理

当我们设置主题时,最终效果会有一个背景色的添加。此时我们需要对 note 截图 image 数据进行处理,为其添加相应的背景色,代码如下:

/// 为图像添加背景色
/// - Parameters:
///   - color: 背景色
///   - img: 图像
///   - size: 图像最终大小
convenience init(color: NSColor, img: NSImage, size: NSSize) {
  guard let bitmapRep = NSBitmapImageRep(bitmapDataPlanes: nil,
                                         pixelsWide: Int(size.width),
                                         pixelsHigh: Int(size.height),
                                         bitsPerSample: 8,
                                         samplesPerPixel: 4,
                                         hasAlpha: true,
                                         isPlanar: false,
                                         colorSpaceName: .calibratedRGB,
                                         bytesPerRow: 0,
                                         bitsPerPixel: 0) else {
    self.init(size: size)
    return
  }
  NSGraphicsContext.saveGraphicsState()
  NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmapRep)
  
  color.drawSwatch(in: NSRect(origin: .zero, size: size))
  img.draw(in: NSRect(origin: .zero, size: size), from: .zero, operation: .sourceOver, fraction: 1)
  
  NSGraphicsContext.restoreGraphicsState()
  
  self.init(size: size)
  self.addRepresentation(bitmapRep)
}

水印处理

当我们设置了水印时,我们先将水印进行截图。然后绘制到最终的效果图上,绘制的 operation 参数一定要是 .sourceOver 类型,如果用 .copy 将会覆盖住最终截图,就好像水印背景并非透明一般。

最终代码如下:

/// 长截图
/// - Returns: 长截图 bitmap 数据
func screenshot() -> NSBitmapImageRep? {
  // 主题背景色,默认白色
  var color = NSColor.white
  if let bgImage = theme?.bgImage {
    color = NSColor(patternImage: bgImage)
  }
  // 笔记截图
  let noteSize = NSSize(width: noteScreenshot.size.width * 2.0, height: noteScreenshot.size.height * 2.0)
  let noteImage = NSImage(color: color,
                          img: noteScreenshot,
                          size: noteSize)
  // 等比缩放
  let scale = noteSize.width / bounds.width
  // 主题头部截图
  var headerImage: NSImage?
  if headerHeight > 0, let image = headerView.screenShot(scale: scale) {
    headerImage = NSImage(color: color, img: image, size: image.size)
  }
  // 主题页脚截图
  var footerImage: NSImage?
  if footerHeight > 0, let image = footerView.screenShot(scale: scale) {
    footerImage = NSImage(color: color, img: image, size: image.size)
  }
  // 用户信息组件截图
  var userInfoImage: NSImage?
  if let image = userInfoShowView.screenShot(scale: scale) {
    userInfoImage = NSImage(color: color, img: image, size: image.size)
  }
  // 水印截图,水印无需添加背景色
  let watermarkImage = watermarkView?.screenShot(scale: scale)
  
  // 截图的最终宽度
  let width = noteImage.size.width
  let headerHeight = headerImage?.size.height ?? 0
  let footerHeight = footerImage?.size.height ?? 0
  let userInfoHeight = userInfoImage?.size.height ?? 0
  let noteHeight = noteImage.size.height
  // 截图最终高度
  let height = headerHeight + noteHeight + footerHeight + userInfoHeight
  
  let targetSize = NSSize(width: width, height: height)
  // 图像绘制
  guard let bitmapRep = NSBitmapImageRep(bitmapDataPlanes: nil,
                                         pixelsWide: Int(targetSize.width),
                                         pixelsHigh: Int(targetSize.height),
                                         bitsPerSample: 8,
                                         samplesPerPixel: 4,
                                         hasAlpha: true,
                                         isPlanar: false,
                                         colorSpaceName: .calibratedRGB,
                                         bytesPerRow: 0,
                                         bitsPerPixel: 0) else { return nil }
  NSGraphicsContext.saveGraphicsState()
  guard let context = NSGraphicsContext(bitmapImageRep: bitmapRep) else { return nil }
  NSGraphicsContext.current = context
  
  if let userInfoImage = userInfoImage {
    let userInfoRect = NSRect(x: 0, y: 0, width: width, height: userInfoHeight)
    userInfoImage.draw(in: userInfoRect, from: .zero, operation: .copy, fraction: 1.0)
  }
  if let footerImage = footerImage {
    let footerRect = NSRect(x: 0, y: userInfoHeight, width: width, height: footerHeight)
    footerImage.draw(in: footerRect, from: .zero, operation: .copy, fraction: 1.0)
  }
  let noteRect = NSRect(x: 0, y: userInfoHeight + footerHeight, width: width, height: noteHeight)
  noteImage.draw(in: noteRect, from: .zero, operation: .copy, fraction: 1.0)
  if let headerImage = headerImage {
    let headerRect = NSRect(x: 0, y: userInfoHeight + noteHeight + footerHeight, width: width, height: headerHeight)
    headerImage.draw(in: headerRect, from: .zero, operation: .copy, fraction: 1.0)
  }
  if let watermarkImage = watermarkImage {
    let watermarkRect = NSRect(origin: .zero, size: targetSize)
    watermarkImage.draw(in: watermarkRect, from: .zero, operation: .sourceOver, fraction: 1.0)
  }
  
  NSGraphicsContext.restoreGraphicsState()
  
  return bitmapRep
}

矩形水印旋转后的间距计算

设计师所给的水印旋转效果如下图:

由于圆形无论怎么旋转,间距是不会变的,所以我们这里可以忽略对圆形水印的间距重计算。矩形旋转之后,对矩形四个顶点做一个外切矩形,外切矩形的宽高是有相应的变化,所以我们需要重新计算矩形的间距。

假设我们以矩形的对角线交点为圆心,画一个外切圆,矩形无论怎么绕着中心点旋转,四个顶点一定在这个外切圆上,并且对角线长度即为这个外切圆的直径(d),从而我们可得外切圆的半径(r)。圆的中心我们作为平面直角坐标系的中心点(0,0),那么我们可以知道矩形四个顶点的坐标。由四个顶点的坐标我们可以算出 d 和 r。根据相应的余弦定理和圆上坐标公式可得旋转后图形的顶点坐标,从而可以计算出旋转之后矩形。然后我们就计算出相应的间距即可。几何坐标表示如下:

图中 ABCD 灰色区域是起始位置,A’B’C’D’ 是逆时针旋转 15° 之后的我们展示水印的位置,灰色的圆为以对角线交点 E 为圆心,对角线长为直径的圆,红色的 FGHI 为旋转 15° 后的外切矩形,也即需要我们计算出宽高的矩形。

首先我们可得 ABCD 四个点的坐标,我们利用 D(xd, yd),B(xb, yb) 两点间距离公式可计算出圆的直径 d,进而可得圆的半径 r。两点间距离公式如下:

我们的最终目标是求出 B‘ 的 y 坐标和 C’ 的 x 坐标,y * 2 即为红色矩形的高,x * 2 即为红色矩形的宽。

因为 B’ 和 C‘ 都在外切圆上,所以利用外切圆的坐标公式即可求出两点坐标,圆上点的坐标公式如下:

圆心 E 的坐标(x0,y0)、半径为r、角度为 angle。

x1 = x0 + r * cos(angle * PI / 180)
y1 = y0 + r * sin(angle * PI / 180)

由公式可知,要想算出 B’ 和 C‘ 坐标,我们需要知道角度 angle。角度的起始位置(0°)为 EJ,所以我们需要计算的角度为 ∠B’EJ(以下称为 α) 和 ∠JEC‘(以下称为 β)。

α 的角度计算

通过图我们可知,α 的大小为等腰三角形 △BEC 两腰的夹角 ∠BEC(以下称为 γ) 的一半再加上 15°。也即 ∠B‘EB + ∠BEJ。两腰的夹角我们可以通过余弦定理算出。余弦定理公式如下:

假设等腰三角形的角为 γ 此时我们可以计算出 cos弧度 的值(macOS 中的旋转是以弧度为单位)。然后我们利用弧度与角度换算公式可知:

弧度与角度换算公式:1° = π / 180
cos弧度 = cos(γ * PI / 180)

故,具体的角度我们可以用反三角函数 acos 算出。计算方式如下:

γ = acos(cos弧度) / (.pi / 180)

旋转角度知道后就可以计算出 B’ 的 y 坐标

y = r * sin((γ / 2 + rotationAngle) * .pi / 180)

所以红色矩形的高为 2 * y。

β 的角度计算

C‘ 我们可以看成是 J 顺时针旋转一定的角度得到。则 β 为等腰三角形 △BEC 两腰的夹角 ∠BEC 减去 15° 得到。等腰三角形的夹角 γ 我们已算出,所以 C’ 的 x 坐标我们也很容易得到:

β = -(γ / 2 - rotationAngle) // 顺时针为负数
x = r * cos(β * .pi / 180)

所以红色矩形的宽为:2 * x

完整计算代码如下:

/// 返回旋转后水印组件外切矩形的大小
/// - Parameters:
///   - watermarkSize: 水印组件大小
///   - rotationAngle: 旋转角度
/// - Returns: 旋转后外切矩形大小
func getReal(size watermarkSize: CGSize, angle rotationAngle: CGFloat) -> CGSize {
  // 矩形旋转后行间距偏移量
  var realSize = CGSize.zero
  // 判断是否是矩形
  if watermarkSize.width != watermarkSize.height {
    // 我们以矩形外切圆的圆心(即矩形对角线的交点)为平面直角坐标系的原点,则圆心的坐标为 (0, 0)
    // 矩形右上角点的坐标(第一象限)
    let rightTop = CGPoint(x: watermarkSize.width / 2, y: watermarkSize.height / 2)
    // 矩形左下角点的坐标(第三象限)
    let leftBottom = CGPoint(x: -watermarkSize.width / 2, y: -watermarkSize.height / 2)
    // 半径
    // 两点间距离公式 sqrt(pow(x1 - x2, 2) + pow(y1 - y2, 2))
    let r = sqrt(pow(rightTop.x - leftBottom.x, 2) + pow(rightTop.y - leftBottom.y, 2)) / 2
    // 余弦定理求等腰三角形两腰的夹角的 cos 值
    // 这里两腰的长为外切圆半径 r,两腰夹角的对边为矩形的高
    // 故 cos 值为:(pow(r, 2) + pow(r, 2) - pow(height, 2)) / (2 * r * r)
    let cosRadians = (pow(r, 2) * 2 - pow(watermarkSize.height, 2)) / (2 * r * r)
    // 反三角函数求出弧度 angle * PI / 180 的值,则进一步可以求出两腰夹角的角度
    let angle = acos(cosRadians) / (.pi / 180)
    // 圆上任意一点坐标计算公式,(x0, y0)为圆心坐标
    // x1 = x0 + r * cos(angle * PI / 180)
    // y1 = y0 + r * sin(angle * PI / 180)
    // 右上角点在坐标系中的夹角为:两腰夹角 / 2 + 旋转角度
    // 则右上角的点旋转后位于矩形外切圆的 y 坐标如下
    let rightTopY = r * sin((angle / 2 + rotationAngle) * .pi / 180)
    // 旋转后四个点组成的新矩形高为:rightTopY * 2
    let newHeight = rightTopY * 2
    // 右下角的旋转角度,我们按顺时针旋转来看,则为负数
    let rightBottomRotateAngle = -(angle / 2 - rotationAngle)
    // 圆上任意一点坐标计算公式计算右下角点的 x 坐标
    let rightBottomX = r * cos(rightBottomRotateAngle * .pi / 180)
    // 同理得旋转后的矩形长为 rightBottomX * 2
    let newWidth = rightBottomX * 2
    realSize = CGSize(width: newWidth, height: newHeight)
  } else {
    realSize = watermarkSize
  }
  return realSize
}
Previous
如何成为一名优秀的技术经理
Next
进程间通信简介