forked from fritob/Camper-Monitor
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:
@@ -0,0 +1,19 @@
|
||||
# Werkzeuge
|
||||
|
||||
## make-app-icon.swift
|
||||
|
||||
Bereitet `logo.png` zum App-Icon auf. Nötig, weil das Logo als fertige Kachel
|
||||
vorliegt: mit weißem Rand und eingebrannten runden Ecken. iOS maskiert die
|
||||
Ecken selbst und verbietet einen Alphakanal – unbearbeitet säße auf dem
|
||||
Homescreen ein weißer Rahmen um das Icon.
|
||||
|
||||
Das Skript schneidet den weißen Rand weg, macht den Ausschnitt quadratisch und
|
||||
versetzt ihn so weit nach innen, bis die vier Ecken im Hintergrund liegen.
|
||||
Anschließend skaliert es auf 1024×1024 ohne Alphakanal.
|
||||
|
||||
```bash
|
||||
swiftc -O -o /tmp/make-app-icon Tools/make-app-icon.swift
|
||||
/tmp/make-app-icon logo.png CamperMonitor/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
|
||||
```
|
||||
|
||||
Nur nötig, wenn `logo.png` sich ändert – das erzeugte Icon liegt im Repo.
|
||||
@@ -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))")
|
||||
Reference in New Issue
Block a user