first commit
This commit is contained in:
@@ -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