187 lines
5.6 KiB
Swift
187 lines
5.6 KiB
Swift
#!/usr/bin/env swift
|
||
|
||
import AppKit
|
||
import Foundation
|
||
import WebKit
|
||
|
||
enum ExportError: Error, CustomStringConvertible {
|
||
case invalidArguments
|
||
case snapshotFailed
|
||
case pngEncodingFailed(String)
|
||
case clockRegionMissing
|
||
|
||
var description: String {
|
||
switch self {
|
||
case .invalidArguments:
|
||
return "用法:export-kindle-background.swift <url> <png-output> <region-output>"
|
||
case .snapshotFailed:
|
||
return "网页截图失败"
|
||
case let .pngEncodingFailed(path):
|
||
return "PNG 编码失败:\(path)"
|
||
case .clockRegionMissing:
|
||
return "页面里没有找到 data-clock-region 标记"
|
||
}
|
||
}
|
||
}
|
||
|
||
final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
||
private let url: URL
|
||
private let pngOutputURL: URL
|
||
private let regionOutputURL: URL
|
||
private let completion: (Result<Void, Error>) -> Void
|
||
private let targetSize = CGSize(width: 1024, height: 600)
|
||
|
||
private lazy var window: NSWindow = {
|
||
let window = NSWindow(
|
||
contentRect: CGRect(origin: .zero, size: targetSize),
|
||
styleMask: [.borderless],
|
||
backing: .buffered,
|
||
defer: false
|
||
)
|
||
window.isReleasedWhenClosed = false
|
||
window.backgroundColor = .white
|
||
return window
|
||
}()
|
||
|
||
private lazy var webView: WKWebView = {
|
||
let config = WKWebViewConfiguration()
|
||
let view = WKWebView(frame: CGRect(origin: .zero, size: targetSize), configuration: config)
|
||
view.navigationDelegate = self
|
||
return view
|
||
}()
|
||
|
||
init(url: URL, pngOutputURL: URL, regionOutputURL: URL, completion: @escaping (Result<Void, Error>) -> Void) {
|
||
self.url = url
|
||
self.pngOutputURL = pngOutputURL
|
||
self.regionOutputURL = regionOutputURL
|
||
self.completion = completion
|
||
super.init()
|
||
}
|
||
|
||
func start() {
|
||
window.contentView = webView
|
||
window.orderBack(nil)
|
||
webView.load(URLRequest(url: url))
|
||
}
|
||
|
||
func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
|
||
// 等页面数据与图片稳定下来,避免只截到骨架。
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
|
||
self.capture()
|
||
}
|
||
}
|
||
|
||
private func capture() {
|
||
let regionScript = """
|
||
(() => {
|
||
const node = document.querySelector('[data-clock-region="true"]');
|
||
if (!node) return null;
|
||
const rect = node.getBoundingClientRect();
|
||
return {
|
||
x: Math.round(rect.left),
|
||
y: Math.round(rect.top),
|
||
width: Math.round(rect.width),
|
||
height: Math.round(rect.height)
|
||
};
|
||
})();
|
||
"""
|
||
|
||
webView.evaluateJavaScript(regionScript) { value, error in
|
||
if let error {
|
||
self.finish(.failure(error))
|
||
return
|
||
}
|
||
|
||
guard let region = value 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))
|
||
|
||
self.webView.takeSnapshot(with: snapshotConfig) { image, snapshotError in
|
||
if let snapshotError {
|
||
self.finish(.failure(snapshotError))
|
||
return
|
||
}
|
||
|
||
guard let image else {
|
||
self.finish(.failure(ExportError.snapshotFailed))
|
||
return
|
||
}
|
||
|
||
do {
|
||
try self.savePNG(image: image, to: self.pngOutputURL)
|
||
try self.saveRegion(region: region)
|
||
self.finish(.success(()))
|
||
} catch {
|
||
self.finish(.failure(error))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func savePNG(image: NSImage, to url: URL) throws {
|
||
let normalizedImage = NSImage(size: NSSize(width: targetSize.width, height: targetSize.height))
|
||
normalizedImage.lockFocus()
|
||
image.draw(in: NSRect(origin: .zero, size: targetSize))
|
||
normalizedImage.unlockFocus()
|
||
|
||
guard
|
||
let tiffRepresentation = normalizedImage.tiffRepresentation,
|
||
let bitmap = NSBitmapImageRep(data: tiffRepresentation),
|
||
let pngData = bitmap.representation(using: .png, properties: [:])
|
||
else {
|
||
throw ExportError.pngEncodingFailed(url.path)
|
||
}
|
||
|
||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||
try pngData.write(to: url)
|
||
}
|
||
|
||
private func saveRegion(region: [String: NSNumber]) throws {
|
||
let jsonObject: [String: Int] = [
|
||
"x": region["x"]?.intValue ?? 0,
|
||
"y": region["y"]?.intValue ?? 0,
|
||
"width": region["width"]?.intValue ?? 0,
|
||
"height": region["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 finish(_ result: Result<Void, Error>) {
|
||
completion(result)
|
||
NSApplication.shared.terminate(nil)
|
||
}
|
||
}
|
||
|
||
guard CommandLine.arguments.count >= 4 else {
|
||
fputs("\(ExportError.invalidArguments)\n", stderr)
|
||
exit(1)
|
||
}
|
||
|
||
let url = URL(string: CommandLine.arguments[1])!
|
||
let pngOutputURL = URL(fileURLWithPath: CommandLine.arguments[2])
|
||
let regionOutputURL = URL(fileURLWithPath: CommandLine.arguments[3])
|
||
|
||
let app = NSApplication.shared
|
||
app.setActivationPolicy(.prohibited)
|
||
|
||
let exporter = SnapshotExporter(url: url, pngOutputURL: pngOutputURL, regionOutputURL: regionOutputURL) { result in
|
||
switch result {
|
||
case .success:
|
||
print("Exported \(pngOutputURL.path)")
|
||
print("Region saved to \(regionOutputURL.path)")
|
||
case let .failure(error):
|
||
fputs("\(error)\n", stderr)
|
||
exit(1)
|
||
}
|
||
}
|
||
|
||
exporter.start()
|
||
app.run()
|