diff --git a/Tools/README.md b/Tools/README.md new file mode 100644 index 0000000..aca7b66 --- /dev/null +++ b/Tools/README.md @@ -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. diff --git a/Tools/make-app-icon.swift b/Tools/make-app-icon.swift new file mode 100644 index 0000000..9530e8b --- /dev/null +++ b/Tools/make-app-icon.swift @@ -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 "); 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..= 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..= 0 else { continue } + for x in 0.. 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))") diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..d4d8856 Binary files /dev/null and b/logo.png differ