update at 2026-03-17 10:37:27
This commit is contained in:
@@ -27,16 +27,28 @@ enum ExportError: Error, CustomStringConvertible {
|
||||
}
|
||||
|
||||
final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
||||
private enum ExportOrientation {
|
||||
case portrait
|
||||
case landscape
|
||||
}
|
||||
|
||||
private struct ExportLayout {
|
||||
let orientation: ExportOrientation
|
||||
let viewportSize: CGSize
|
||||
let outputSize: CGSize
|
||||
}
|
||||
|
||||
private let url: URL
|
||||
private let pngOutputURL: URL
|
||||
private let regionOutputURL: URL
|
||||
private let completion: (Result<Void, Error>) -> Void
|
||||
// 直接按 Kindle Voyage 的系统屏保尺寸导出,避免额外旋转和补边。
|
||||
private let targetSize = CGSize(width: 1072, height: 1448)
|
||||
// 网页渲染尺寸和 Kindle framebuffer 输出尺寸不是一回事。
|
||||
// landscape 需要先按 1448x1072 渲染,再旋转成 1072x1448 输出给设备。
|
||||
private let layout: ExportLayout
|
||||
|
||||
private lazy var window: NSWindow = {
|
||||
let window = NSWindow(
|
||||
contentRect: CGRect(origin: .zero, size: targetSize),
|
||||
contentRect: CGRect(origin: .zero, size: layout.viewportSize),
|
||||
styleMask: [.borderless],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
@@ -48,7 +60,7 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
||||
|
||||
private lazy var webView: WKWebView = {
|
||||
let config = WKWebViewConfiguration()
|
||||
let view = WKWebView(frame: CGRect(origin: .zero, size: targetSize), configuration: config)
|
||||
let view = WKWebView(frame: CGRect(origin: .zero, size: layout.viewportSize), configuration: config)
|
||||
view.navigationDelegate = self
|
||||
return view
|
||||
}()
|
||||
@@ -58,9 +70,29 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
||||
self.pngOutputURL = pngOutputURL
|
||||
self.regionOutputURL = regionOutputURL
|
||||
self.completion = completion
|
||||
self.layout = SnapshotExporter.resolveLayout(url: url)
|
||||
super.init()
|
||||
}
|
||||
|
||||
private static func resolveLayout(url: URL) -> ExportLayout {
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
let orientation = components?.queryItems?.first(where: { $0.name == "orientation" })?.value
|
||||
|
||||
if orientation == "landscape" {
|
||||
return ExportLayout(
|
||||
orientation: .landscape,
|
||||
viewportSize: CGSize(width: 1448, height: 1072),
|
||||
outputSize: CGSize(width: 1072, height: 1448)
|
||||
)
|
||||
}
|
||||
|
||||
return ExportLayout(
|
||||
orientation: .portrait,
|
||||
viewportSize: CGSize(width: 1072, height: 1448),
|
||||
outputSize: CGSize(width: 1072, height: 1448)
|
||||
)
|
||||
}
|
||||
|
||||
func start() {
|
||||
window.contentView = webView
|
||||
window.orderBack(nil)
|
||||
@@ -79,12 +111,22 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
||||
(() => {
|
||||
const node = document.querySelector('[data-clock-region="true"]');
|
||||
if (!node) return null;
|
||||
const dashboard = document.querySelector('.dashboard-frame');
|
||||
const rect = node.getBoundingClientRect();
|
||||
const frameRect = dashboard ? dashboard.getBoundingClientRect() : { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight };
|
||||
return {
|
||||
x: Math.round(rect.left),
|
||||
y: Math.round(rect.top),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height)
|
||||
clock: {
|
||||
x: Math.round(rect.left),
|
||||
y: Math.round(rect.top),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height)
|
||||
},
|
||||
frame: {
|
||||
x: Math.round(frameRect.left),
|
||||
y: Math.round(frameRect.top),
|
||||
width: Math.round(frameRect.width),
|
||||
height: Math.round(frameRect.height)
|
||||
}
|
||||
};
|
||||
})();
|
||||
"""
|
||||
@@ -95,14 +137,18 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
guard let region = value as? [String: NSNumber] else {
|
||||
guard
|
||||
let result = value as? [String: Any],
|
||||
let region = result["clock"] as? [String: NSNumber],
|
||||
let frame = result["frame"] as? [String: NSNumber]
|
||||
else {
|
||||
self.finish(.failure(ExportError.clockRegionMissing))
|
||||
return
|
||||
}
|
||||
|
||||
let snapshotConfig = WKSnapshotConfiguration()
|
||||
snapshotConfig.rect = CGRect(origin: .zero, size: self.targetSize)
|
||||
snapshotConfig.snapshotWidth = NSNumber(value: Float(self.targetSize.width))
|
||||
snapshotConfig.rect = CGRect(origin: .zero, size: self.layout.viewportSize)
|
||||
snapshotConfig.snapshotWidth = NSNumber(value: Float(self.layout.viewportSize.width))
|
||||
|
||||
self.webView.takeSnapshot(with: snapshotConfig) { image, snapshotError in
|
||||
if let snapshotError {
|
||||
@@ -116,8 +162,8 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
||||
}
|
||||
|
||||
do {
|
||||
try self.savePNG(image: image, to: self.pngOutputURL)
|
||||
try self.saveRegion(region: region)
|
||||
try self.savePNG(image: image, to: self.pngOutputURL, frameOffset: frame)
|
||||
try self.saveRegion(region: region, frameOffset: frame)
|
||||
self.finish(.success(()))
|
||||
} catch {
|
||||
self.finish(.failure(error))
|
||||
@@ -126,18 +172,18 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func savePNG(image: NSImage, to url: URL) throws {
|
||||
let normalizedImage = NSImage(size: NSSize(width: targetSize.width, height: targetSize.height))
|
||||
private func savePNG(image: NSImage, to url: URL, frameOffset: [String: NSNumber]) throws {
|
||||
let normalizedImage = NSImage(size: NSSize(width: layout.viewportSize.width, height: layout.viewportSize.height))
|
||||
normalizedImage.lockFocus()
|
||||
image.draw(in: NSRect(origin: .zero, size: targetSize))
|
||||
image.draw(in: NSRect(origin: .zero, size: layout.viewportSize))
|
||||
normalizedImage.unlockFocus()
|
||||
|
||||
guard let sourceCGImage = normalizedImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
throw ExportError.pngEncodingFailed(url.path)
|
||||
}
|
||||
|
||||
let width = Int(targetSize.width)
|
||||
let height = Int(targetSize.height)
|
||||
let width = Int(layout.outputSize.width)
|
||||
let height = Int(layout.outputSize.height)
|
||||
let colorSpace = CGColorSpaceCreateDeviceGray()
|
||||
|
||||
// 输出 8-bit 灰度 PNG,但页面本身仍按纯白底和纯黑字设计,避免额外灰阶装饰。
|
||||
@@ -156,7 +202,38 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
||||
context.setFillColor(gray: 1, alpha: 1)
|
||||
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||
context.interpolationQuality = .high
|
||||
context.draw(sourceCGImage, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||
let offsetX = CGFloat(frameOffset["x"]?.doubleValue ?? 0)
|
||||
let offsetY = CGFloat(frameOffset["y"]?.doubleValue ?? 0)
|
||||
|
||||
// Kindle framebuffer 固定是纵向;landscape 导出时这里把横向截图旋转成设备图。
|
||||
switch layout.orientation {
|
||||
case .portrait:
|
||||
context.draw(
|
||||
sourceCGImage,
|
||||
in: CGRect(
|
||||
x: -offsetX,
|
||||
y: -offsetY,
|
||||
width: layout.viewportSize.width,
|
||||
height: layout.viewportSize.height
|
||||
)
|
||||
)
|
||||
case .landscape:
|
||||
context.saveGState()
|
||||
// 横向网页需要先顺时针旋转 90 度写入纵向 framebuffer,
|
||||
// 这样 Kindle 在 logo_right 摆放时,最终看到的页面方向才是正的。
|
||||
context.translateBy(x: 0, y: layout.outputSize.height)
|
||||
context.rotate(by: -.pi / 2)
|
||||
context.draw(
|
||||
sourceCGImage,
|
||||
in: CGRect(
|
||||
x: -offsetX,
|
||||
y: -offsetY,
|
||||
width: layout.viewportSize.width,
|
||||
height: layout.viewportSize.height
|
||||
)
|
||||
)
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
guard let grayscaleImage = context.makeImage() else {
|
||||
throw ExportError.pngEncodingFailed(url.path)
|
||||
@@ -180,18 +257,50 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func saveRegion(region: [String: NSNumber]) throws {
|
||||
private func saveRegion(region: [String: NSNumber], frameOffset: [String: NSNumber]) throws {
|
||||
let transformedRegion = transformRegion(region: region, frameOffset: frameOffset)
|
||||
let jsonObject: [String: Int] = [
|
||||
"x": region["x"]?.intValue ?? 0,
|
||||
"y": region["y"]?.intValue ?? 0,
|
||||
"width": region["width"]?.intValue ?? 0,
|
||||
"height": region["height"]?.intValue ?? 0,
|
||||
"x": transformedRegion["x"]?.intValue ?? 0,
|
||||
"y": transformedRegion["y"]?.intValue ?? 0,
|
||||
"width": transformedRegion["width"]?.intValue ?? 0,
|
||||
"height": transformedRegion["height"]?.intValue ?? 0,
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted, .sortedKeys])
|
||||
try FileManager.default.createDirectory(at: regionOutputURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try data.write(to: regionOutputURL)
|
||||
}
|
||||
|
||||
private func transformRegion(region: [String: NSNumber], frameOffset: [String: NSNumber]) -> [String: NSNumber] {
|
||||
let normalizedX = CGFloat(region["x"]?.doubleValue ?? 0) - CGFloat(frameOffset["x"]?.doubleValue ?? 0)
|
||||
let normalizedY = CGFloat(region["y"]?.doubleValue ?? 0) - CGFloat(frameOffset["y"]?.doubleValue ?? 0)
|
||||
let normalizedRegion: [String: NSNumber] = [
|
||||
"x": NSNumber(value: Int(round(normalizedX))),
|
||||
"y": NSNumber(value: Int(round(normalizedY))),
|
||||
"width": region["width"] ?? 0,
|
||||
"height": region["height"] ?? 0,
|
||||
]
|
||||
|
||||
guard layout.orientation == .landscape else {
|
||||
return normalizedRegion
|
||||
}
|
||||
|
||||
let x = CGFloat(normalizedRegion["x"]?.doubleValue ?? 0)
|
||||
let y = CGFloat(normalizedRegion["y"]?.doubleValue ?? 0)
|
||||
let width = CGFloat(normalizedRegion["width"]?.doubleValue ?? 0)
|
||||
let height = CGFloat(normalizedRegion["height"]?.doubleValue ?? 0)
|
||||
|
||||
// “logo 在右侧”对应设备图坐标系下,横向网页需要顺时针旋转 90 度写入 framebuffer。
|
||||
let transformedX = layout.viewportSize.height - (y + height)
|
||||
let transformedY = x
|
||||
|
||||
return [
|
||||
"x": NSNumber(value: Int(round(transformedX))),
|
||||
"y": NSNumber(value: Int(round(transformedY))),
|
||||
"width": NSNumber(value: Int(round(height))),
|
||||
"height": NSNumber(value: Int(round(width))),
|
||||
]
|
||||
}
|
||||
|
||||
private func finish(_ result: Result<Void, Error>) {
|
||||
completion(result)
|
||||
NSApplication.shared.terminate(nil)
|
||||
|
||||
Reference in New Issue
Block a user