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>
This commit is contained in:
BiasF
2026-08-30 14:21:28 +02:00
co-authored by Claude Opus 5
parent daea73d649
commit c3d708ae09
3 changed files with 192 additions and 0 deletions
+173
View File
@@ -0,0 +1,173 @@
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))")