174 lines
6.8 KiB
Swift
174 lines
6.8 KiB
Swift
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))")
|