update at 2026-03-15 15:58:14
This commit is contained in:
23
calendar/scripts/export-kindle-background.sh
Normal file
23
calendar/scripts/export-kindle-background.sh
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
|
||||
CALENDAR_DIR="$ROOT_DIR/calendar"
|
||||
DIST_DIR="$CALENDAR_DIR/dist"
|
||||
PORT=${PORT:-4173}
|
||||
URL=${1:-"http://127.0.0.1:$PORT/?mode=background"}
|
||||
OUT_PNG=${2:-"$DIST_DIR/kindlebg.png"}
|
||||
OUT_REGION=${3:-"$DIST_DIR/clock-region.json"}
|
||||
|
||||
cd "$CALENDAR_DIR"
|
||||
npm run build >/dev/null
|
||||
|
||||
python3 -m http.server "$PORT" -d "$DIST_DIR" >/tmp/kindle-calendar-http.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT INT TERM
|
||||
|
||||
sleep 1
|
||||
/usr/bin/swift "$CALENDAR_DIR/scripts/export-kindle-background.swift" "$URL" "$OUT_PNG" "$OUT_REGION"
|
||||
node "$CALENDAR_DIR/scripts/generate-dashboard-manifest.mjs" >/dev/null
|
||||
|
||||
cat "$OUT_REGION"
|
||||
186
calendar/scripts/export-kindle-background.swift
Normal file
186
calendar/scripts/export-kindle-background.swift
Normal file
@@ -0,0 +1,186 @@
|
||||
#!/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()
|
||||
50
calendar/scripts/generate-dashboard-manifest.mjs
Normal file
50
calendar/scripts/generate-dashboard-manifest.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const distDir = path.resolve(currentDir, '../dist');
|
||||
const manifestPath = path.join(distDir, 'dashboard-manifest.json');
|
||||
const clockRegionPath = path.join(distDir, 'clock-region.json');
|
||||
|
||||
const defaultClockRegion = {
|
||||
x: 313,
|
||||
y: 0,
|
||||
width: 220,
|
||||
height: 220,
|
||||
};
|
||||
|
||||
const clockRegion = fs.existsSync(clockRegionPath)
|
||||
? {
|
||||
...defaultClockRegion,
|
||||
...JSON.parse(fs.readFileSync(clockRegionPath, 'utf8')),
|
||||
}
|
||||
: defaultClockRegion;
|
||||
|
||||
const manifest = {
|
||||
background: {
|
||||
path: 'kindlebg.png',
|
||||
url: 'https://shell.biboer.cn:20001/kindlebg.png',
|
||||
updatedAt: new Date().toISOString(),
|
||||
refreshIntervalMinutes: 120,
|
||||
},
|
||||
clockRegion,
|
||||
clockFace: {
|
||||
path: 'assets/clock-face.png',
|
||||
managedOnKindle: true,
|
||||
designWidth: 220,
|
||||
designHeight: 220,
|
||||
},
|
||||
clockHands: {
|
||||
hourPattern: 'assets/hour-hand/%03d.png',
|
||||
minutePattern: 'assets/minute-hand/%02d.png',
|
||||
refreshIntervalMinutes: 1,
|
||||
networkRequired: false,
|
||||
anchorMode: 'baked-into-patch',
|
||||
scaleWithClockFace: false,
|
||||
},
|
||||
};
|
||||
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
||||
console.log(`Wrote ${manifestPath}`);
|
||||
Reference in New Issue
Block a user