update at 2026-03-17 10:37:27

This commit is contained in:
douboer@gmail.com
2026-03-17 10:37:27 +08:00
parent e5becf63cf
commit 192eb1b8d1
44 changed files with 5208 additions and 403 deletions

View File

@@ -5,7 +5,7 @@ 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"}
URL=${1:-"http://127.0.0.1:$PORT/?mode=background&theme=default&orientation=portrait"}
OUT_PNG=${2:-"$DIST_DIR/kindlebg.png"}
OUT_REGION=${3:-"$DIST_DIR/clock-region.json"}

View File

@@ -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)

View File

@@ -0,0 +1,175 @@
#!/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}
SWIFT_SCRIPT="$CALENDAR_DIR/scripts/export-kindle-background.swift"
THEMES_SOURCE="$CALENDAR_DIR/config/themes.json"
THEME_FILTER=""
ORIENTATION_FILTER=""
print_usage() {
cat <<'EOF'
用法:
sh scripts/export-theme-backgrounds.sh [选项]
选项:
--theme <theme-id> 只导出指定主题
--orientation <value> 只导出指定方向;必须和 --theme 一起使用
-h, --help 查看帮助
示例:
sh scripts/export-theme-backgrounds.sh
sh scripts/export-theme-backgrounds.sh --theme simple
sh scripts/export-theme-backgrounds.sh --theme simple --orientation portrait
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--theme)
shift
THEME_FILTER=${1:?"missing theme id"}
;;
--orientation)
shift
ORIENTATION_FILTER=${1:?"missing orientation"}
;;
-h|--help)
print_usage
exit 0
;;
*)
echo "未知参数: $1" >&2
echo >&2
print_usage >&2
exit 1
;;
esac
shift
done
if [ -n "$ORIENTATION_FILTER" ] && [ -z "$THEME_FILTER" ]; then
echo "--orientation 必须和 --theme 一起使用。" >&2
exit 1
fi
selection_output=$(
node --input-type=module -e "
import fs from 'node:fs';
const data = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
const requestedTheme = process.argv[2];
const requestedOrientation = process.argv[3];
const themes = data.themes ?? [];
const themeMap = new Map(themes.map((theme) => [theme.id, theme]));
if (requestedTheme && !themeMap.has(requestedTheme)) {
console.error(\`未知主题: \${requestedTheme}\`);
process.exit(1);
}
if (requestedOrientation && !requestedTheme) {
console.error('--orientation 必须和 --theme 一起使用。');
process.exit(1);
}
const filteredThemes = requestedTheme ? themes.filter((theme) => theme.id === requestedTheme) : themes;
const items = [];
for (const theme of filteredThemes) {
const orientations = Object.keys(theme.variants ?? {});
if (requestedOrientation) {
if (!orientations.includes(requestedOrientation)) {
console.error(
\`主题 \${theme.id} 不支持方向 \${requestedOrientation},可用方向: \${orientations.join(', ')}\`,
);
process.exit(1);
}
}
const filteredOrientations = requestedOrientation ? [requestedOrientation] : orientations;
for (const orientation of filteredOrientations) {
const variant = theme.variants[orientation];
items.push([theme.id, orientation, variant.backgroundPath].join('\t'));
}
}
if (items.length === 0) {
console.error('没有可导出的主题背景。');
process.exit(1);
}
console.log(\`DEFAULT_THEME_ID=\${data.defaultThemeId}\`);
console.log(\`DEFAULT_ORIENTATION=\${data.defaultOrientation}\`);
for (const item of items) {
console.log(\`ITEM=\${item}\`);
}
" "$THEMES_SOURCE" "$THEME_FILTER" "$ORIENTATION_FILTER"
)
DEFAULT_THEME_ID=""
DEFAULT_ORIENTATION=""
EXPORT_ITEMS=""
while IFS= read -r line; do
case "$line" in
DEFAULT_THEME_ID=*)
DEFAULT_THEME_ID=${line#DEFAULT_THEME_ID=}
;;
DEFAULT_ORIENTATION=*)
DEFAULT_ORIENTATION=${line#DEFAULT_ORIENTATION=}
;;
ITEM=*)
if [ -n "$EXPORT_ITEMS" ]; then
EXPORT_ITEMS="${EXPORT_ITEMS}
${line#ITEM=}"
else
EXPORT_ITEMS=${line#ITEM=}
fi
;;
esac
done <<EOF
$selection_output
EOF
if [ -z "$DEFAULT_THEME_ID" ] || [ -z "$DEFAULT_ORIENTATION" ] || [ -z "$EXPORT_ITEMS" ]; then
echo "无法解析导出目标。" >&2
exit 1
fi
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
printf '%s\n' "$EXPORT_ITEMS" | while IFS="$(printf '\t')" read -r theme_id orientation background_path; do
out_png="$DIST_DIR/$background_path"
out_region="${out_png%.png}.clock-region.json"
url="http://127.0.0.1:$PORT/?mode=background&theme=$theme_id&orientation=$orientation"
/usr/bin/swift "$SWIFT_SCRIPT" "$url" "$out_png" "$out_region" >/dev/null
# 根目录的 kindlebg.png / clock-region.json 只给默认主题兜底使用。
# 定向导出其它主题时不覆盖它,避免把默认主题的运行时入口意外改掉。
if [ "$theme_id" = "$DEFAULT_THEME_ID" ] && [ "$orientation" = "$DEFAULT_ORIENTATION" ]; then
cp "$out_png" "$DIST_DIR/kindlebg.png"
cp "$out_region" "$DIST_DIR/clock-region.json"
fi
printf 'Exported %s %s -> %s\n' "$theme_id" "$orientation" "$out_png"
done
node "$CALENDAR_DIR/scripts/generate-dashboard-manifest.mjs" >/dev/null
if [ -f "$DIST_DIR/clock-region.json" ]; then
printf 'Default region saved to %s\n' "$DIST_DIR/clock-region.json"
else
printf 'Skipped default region update (default theme not exported this run)\n'
fi

View File

@@ -6,13 +6,28 @@ 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 themesSourcePath = path.resolve(currentDir, '../config/themes.json');
const themesDistPath = path.join(distDir, 'themes.json');
const themesDir = path.join(distDir, 'themes');
const dashboardBaseUrl = 'https://shell.biboer.cn:20001';
const defaultClockRegion = {
x: 313,
y: 0,
width: 220,
height: 220,
};
const themesSource = JSON.parse(fs.readFileSync(themesSourcePath, 'utf8'));
const generatedAt = new Date().toISOString();
const defaultVariant = themesSource.themes.find((theme) => theme.id === themesSource.defaultThemeId)?.variants?.[themesSource.defaultOrientation];
const defaultDeviceClock = defaultVariant ? toDeviceClock(defaultVariant, themesSource.defaultOrientation) : null;
const defaultClockRegion = defaultVariant
? {
x: defaultDeviceClock.x,
y: defaultDeviceClock.y,
width: defaultDeviceClock.width,
height: defaultDeviceClock.height,
}
: {
x: 313,
y: 0,
width: 220,
height: 220,
};
const clockRegion = fs.existsSync(clockRegionPath)
? {
@@ -22,10 +37,15 @@ const clockRegion = fs.existsSync(clockRegionPath)
: defaultClockRegion;
const manifest = {
theme: {
id: themesSource.defaultThemeId,
orientation: themesSource.defaultOrientation,
themesUrl: `${dashboardBaseUrl}/themes.json`,
},
background: {
path: 'kindlebg.png',
url: 'https://shell.biboer.cn:20001/kindlebg.png',
updatedAt: new Date().toISOString(),
url: `${dashboardBaseUrl}/kindlebg.png`,
updatedAt: generatedAt,
refreshIntervalMinutes: 120,
},
clockRegion,
@@ -45,6 +65,67 @@ const manifest = {
},
};
const themesIndex = {
updatedAt: generatedAt,
defaultThemeId: themesSource.defaultThemeId,
defaultOrientation: themesSource.defaultOrientation,
themes: themesSource.themes.map((theme) => ({
id: theme.id,
label: theme.label,
configUrl: `${dashboardBaseUrl}/themes/${theme.id}.json`,
orientations: Object.keys(theme.variants),
})),
};
function toDeviceClock(variant, orientation) {
if (orientation !== 'landscape') {
return {
...variant.clock,
rotationDegrees: 0,
};
}
return {
...variant.clock,
x: variant.viewport.height - (variant.clock.y + variant.clock.height),
y: variant.clock.x,
width: variant.clock.height,
height: variant.clock.width,
rotationDegrees: 90,
};
}
function buildThemeConfig(theme) {
return {
id: theme.id,
label: theme.label,
updatedAt: generatedAt,
variants: Object.fromEntries(
Object.entries(theme.variants).map(([orientation, variant]) => [
orientation,
{
devicePlacement: variant.devicePlacement,
background: {
path: variant.backgroundPath,
url: `${dashboardBaseUrl}/${variant.backgroundPath}`,
refreshIntervalMinutes: 120,
},
clock: toDeviceClock(variant, orientation),
},
]),
),
};
}
fs.mkdirSync(distDir, { recursive: true });
fs.mkdirSync(themesDir, { recursive: true });
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
fs.writeFileSync(themesDistPath, `${JSON.stringify(themesIndex, null, 2)}\n`, 'utf8');
for (const theme of themesSource.themes) {
const themePath = path.join(themesDir, `${theme.id}.json`);
fs.writeFileSync(themePath, `${JSON.stringify(buildThemeConfig(theme), null, 2)}\n`, 'utf8');
}
console.log(`Wrote ${manifestPath}`);
console.log(`Wrote ${themesDistPath}`);