Files
Camper-Monitor/Tools/make-app-icon.swift
BiasFandClaude Opus 5 c3d708ae09 App-Icon aus dem Logo erzeugen
Das Logo liegt als fertige Kachel vor: weißer Rand, eingebrannte runde
Ecken. iOS maskiert die Ecken selbst und verbietet einen Alphakanal,
unbearbeitet säße also ein weißer Rahmen um das Icon. Tools/make-app-icon
schneidet den Rand weg, macht den Ausschnitt quadratisch und versetzt ihn
so weit nach innen, bis die Ecken im Hintergrund liegen, und skaliert
dann auf 1024x1024 ohne Alpha. Das erzeugte Icon liegt mit im Repo, das
Skript wird nur bei einem neuen Logo gebraucht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:21:28 +02:00

174 lines
6.8 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import CoreGraphics
import Foundation
// App-Icons dürfen keinen Alphakanal und keine eigenen runden Ecken haben:
// iOS maskiert selbst. Ein Logo mit weißem Rand und eingebrannter Rundung
// bekäme sonst einen weißen Rahmen. Also Rand abschneiden, quadratisch
// zuschneiden und die dann weißen Ecken mit der Randfarbe auffüllen.
let arguments = CommandLine.arguments
guard arguments.count >= 3 else { print("Aufruf: makeicon <quelle> <ziel>"); exit(1) }
guard let source = NSImage(contentsOfFile: arguments[1]),
let sourceImage = source.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
print("Quelle nicht lesbar"); exit(1)
}
let width = sourceImage.width, height = sourceImage.height
let bytesPerRow = width * 4
var pixels = [UInt8](repeating: 0, count: bytesPerRow * height)
guard let readContext = CGContext(
data: &pixels, width: width, height: height,
bitsPerComponent: 8, bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { print("Puffer nicht anlegbar"); exit(1) }
readContext.draw(sourceImage, in: CGRect(x: 0, y: 0, width: width, height: height))
@inline(__always) func offset(_ x: Int, _ y: Int) -> Int { y * bytesPerRow + x * 4 }
@inline(__always) func isWhite(_ x: Int, _ y: Int) -> Bool {
let i = offset(x, y)
return pixels[i] > 242 && pixels[i + 1] > 242 && pixels[i + 2] > 242
}
// Eine Reihe gilt als leer, wenn fast nichts darin farbig ist die
// abgerundete Ecke liefert sonst falsche Treffer.
func rowIsBlank(_ y: Int) -> Bool {
var coloured = 0
for x in stride(from: 0, to: width, by: 3) where !isWhite(x, y) {
coloured += 1
if coloured >= 3 { return false }
}
return true
}
func columnIsBlank(_ x: Int) -> Bool {
var coloured = 0
for y in stride(from: 0, to: height, by: 3) where !isWhite(x, y) {
coloured += 1
if coloured >= 3 { return false }
}
return true
}
var top = 0, bottom = height - 1, left = 0, right = width - 1
while top < bottom, rowIsBlank(top) { top += 1 }
while bottom > top, rowIsBlank(bottom) { bottom -= 1 }
while left < right, columnIsBlank(left) { left += 1 }
while right > left, columnIsBlank(right) { right -= 1 }
print("Quelle \(width)x\(height) → Inhalt (\(left),\(top)) \(right - left + 1)x\(bottom - top + 1)")
// Auf ein Quadrat erweitern, sonst verzerrt das Skalieren.
let side = min(max(right - left + 1, bottom - top + 1), min(width, height))
let centreX = (left + right) / 2, centreY = (top + bottom) / 2
let cropX = max(0, min(width - side, centreX - side / 2))
let cropY = max(0, min(height - side, centreY - side / 2))
print("Quadrat: (\(cropX),\(cropY)) \(side)x\(side)")
// Reparieren lohnt nicht: der weiche Übergang an der eingebrannten Rundung
// lässt sich nicht sauber nachfärben. Stattdessen so weit hineinschneiden,
// bis die vier Ecken im Hintergrund liegen. Das kostet ein paar Prozent
// Bildfläche, dafür bleibt kein heller Rand stehen.
@inline(__always) func isPale(_ x: Int, _ y: Int) -> Bool {
let i = offset(x, y)
return pixels[i] > 150 && pixels[i + 1] > 150 && pixels[i + 2] > 150
}
var inset = 0
while inset < side / 5 {
let x0 = cropX + inset, y0 = cropY + inset
let x1 = cropX + side - 1 - inset, y1 = cropY + side - 1 - inset
if !isPale(x0, y0) && !isPale(x1, y0) && !isPale(x0, y1) && !isPale(x1, y1) { break }
inset += 2
}
inset += 8 // Sicherheitsabstand gegen weiche Kanten
let innerX = cropX + inset, innerY = cropY + inset
let innerSide = side - 2 * inset
print("Nach innen versetzt um \(inset) → (\(innerX),\(innerY)) \(innerSide)x\(innerSide)")
// Den hellen Aussenbereich vom Bildrand her zusammenhängend markieren.
// Über die Helligkeit allein ginge es nicht: das Wohnmobil und die Schrift
// im Logo sind ebenfalls weiss, liegen aber innen und werden so nicht
// erfasst. Der weiche Übergang an der Rundung gehört mit dazu, sonst
// bleiben helle Ränder stehen.
@inline(__always) func isBright(_ x: Int, _ y: Int) -> Bool {
let i = offset(x, y)
return pixels[i] > 200 && pixels[i + 1] > 200 && pixels[i + 2] > 200
}
var isOutside = [Bool](repeating: false, count: width * height)
var queue: [Int] = []
for x in 0..<width {
for y in [0, height - 1] where isBright(x, y) && !isOutside[y * width + x] {
isOutside[y * width + x] = true
queue.append(y * width + x)
}
}
for y in 0..<height {
for x in [0, width - 1] where isBright(x, y) && !isOutside[y * width + x] {
isOutside[y * width + x] = true
queue.append(y * width + x)
}
}
var head = 0
while head < queue.count {
let index = queue[head]; head += 1
let x = index % width, y = index / width
for (dx, dy) in [(1, 0), (-1, 0), (0, 1), (0, -1)] {
let nx = x + dx, ny = y + dy
guard nx >= 0, nx < width, ny >= 0, ny < height else { continue }
let neighbour = ny * width + nx
guard !isOutside[neighbour], isBright(nx, ny) else { continue }
isOutside[neighbour] = true
queue.append(neighbour)
}
}
// Markierte Pixel durch die nächste erhaltene Stelle derselben Zeile
// ersetzen, damit der Farbverlauf des Hintergrunds weiterläuft.
var repaired = 0
for y in 0..<height {
var firstKept = -1, lastKept = -1
for x in 0..<width where !isOutside[y * width + x] {
if firstKept < 0 { firstKept = x }
lastKept = x
}
guard firstKept >= 0 else { continue }
for x in 0..<width where isOutside[y * width + x] {
let sourceX = x < firstKept ? firstKept : (x > lastKept ? lastKept : -1)
guard sourceX >= 0 else { continue }
let from = offset(sourceX, y), to = offset(x, y)
pixels[to] = pixels[from]
pixels[to + 1] = pixels[from + 1]
pixels[to + 2] = pixels[from + 2]
repaired += 1
}
}
print("Aussenbereich ersetzt: \(repaired) Pixel")
guard let repairedImage = readContext.makeImage(),
let cropped = repairedImage.cropping(
to: CGRect(x: innerX, y: innerY, width: innerSide, height: innerSide))
else { print("Zuschnitt fehlgeschlagen"); exit(1) }
// Auf 1024x1024 bringen, ohne Alphakanal.
let target = 1024
guard let outputContext = CGContext(
data: nil, width: target, height: target,
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue
) else { print("Ausgabe nicht anlegbar"); exit(1) }
outputContext.interpolationQuality = .high
outputContext.draw(cropped, in: CGRect(x: 0, y: 0, width: target, height: target))
guard let finalImage = outputContext.makeImage() else { print("Bild leer"); exit(1) }
let rep = NSBitmapImageRep(cgImage: finalImage)
guard let png = rep.representation(using: .png, properties: [:]) else {
print("PNG nicht erzeugbar"); exit(1)
}
try png.write(to: URL(fileURLWithPath: arguments[2]))
print("Geschrieben: \(arguments[2]) (\(target)x\(target))")