KasztelanKarta/Kasztelan/EAN13BarcodeView.swift
2026-10-16 23:37:49 +01:00

227 lines
8.7 KiB
Swift

import AVFoundation
import CoreImage
import CoreImage.CIFilterBuiltins
import SwiftUI
/// Kasztelan card numbers are arbitrary 13-digit account identifiers, so they
/// remain Code 128. EAN-13 would silently replace the last digit with a checksum.
struct CardBarcodeView: View {
let digits: String
@Environment(\.displayScale) private var displayScale
var body: some View {
GeometryReader { geometry in
if let image = barcodeImage(
targetWidth: geometry.size.width,
targetHeight: geometry.size.height,
displayScale: displayScale
) {
Image(decorative: image, scale: displayScale)
.interpolation(.none)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
}
.background(.white)
.accessibilityElement()
.accessibilityLabel("Barcode \(digits)")
}
private func barcodeImage(targetWidth: CGFloat, targetHeight: CGFloat, displayScale: CGFloat) -> CGImage? {
let clean = String(digits.filter(\.isNumber))
guard clean.count == 13, let data = clean.data(using: .ascii) else { return nil }
let generator = CIFilter.code128BarcodeGenerator()
generator.message = data
generator.quietSpace = 12
guard let output = generator.outputImage else { return nil }
let pixelWidth = floor(targetWidth * displayScale)
let pixelHeight = floor(targetHeight * displayScale)
let horizontalScale = max(1, floor(pixelWidth / output.extent.width))
let verticalScale = max(1, floor(pixelHeight / output.extent.height))
let scaled = output.transformed(
by: CGAffineTransform(scaleX: horizontalScale, y: verticalScale)
)
return CIContext(options: [.useSoftwareRenderer: false]).createCGImage(scaled, from: scaled.extent)
}
}
/// Recreates an imported loyalty-card code using the symbology reported by the
/// camera. The payload itself is never normalised or reformatted.
struct StoredBarcodeView: View {
let value: String
let symbology: String
var body: some View {
let type = AVMetadataObject.ObjectType(rawValue: symbology)
Group {
switch type {
case .ean13:
EANBarcodeView(value: value, kind: .ean13)
case .ean8:
EANBarcodeView(value: value, kind: .ean8)
case .qr:
MatrixBarcodeView(value: value, filterName: "CIQRCodeGenerator")
case .aztec:
MatrixBarcodeView(value: value, filterName: "CIAztecCodeGenerator")
case .pdf417:
MatrixBarcodeView(value: value, filterName: "CIPDF417BarcodeGenerator")
default:
Code128BarcodeView(value: value)
}
}
.accessibilityElement()
.accessibilityLabel("Barcode \(value)")
}
}
private struct Code128BarcodeView: View {
let value: String
@Environment(\.displayScale) private var displayScale
var body: some View {
GeometryReader { geometry in
if let image = barcodeImage(
targetWidth: geometry.size.width,
targetHeight: geometry.size.height
) {
Image(decorative: image, scale: displayScale)
.interpolation(.none)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.background(.white)
}
private func barcodeImage(targetWidth: CGFloat, targetHeight: CGFloat) -> CGImage? {
guard !value.isEmpty, let data = value.data(using: .isoLatin1) ?? value.data(using: .utf8) else {
return nil
}
let generator = CIFilter.code128BarcodeGenerator()
generator.message = data
generator.quietSpace = 12
guard let output = generator.outputImage else { return nil }
return pixelPerfectImage(output, targetWidth: targetWidth, targetHeight: targetHeight, displayScale: displayScale)
}
}
private struct MatrixBarcodeView: View {
let value: String
let filterName: String
@Environment(\.displayScale) private var displayScale
var body: some View {
GeometryReader { geometry in
if let image = barcodeImage(size: geometry.size) {
Image(decorative: image, scale: displayScale)
.interpolation(.none)
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.background(.white)
}
private func barcodeImage(size: CGSize) -> CGImage? {
guard let data = value.data(using: .isoLatin1) ?? value.data(using: .utf8),
let filter = CIFilter(name: filterName) else { return nil }
filter.setValue(data, forKey: "inputMessage")
if filterName == "CIQRCodeGenerator" { filter.setValue("M", forKey: "inputCorrectionLevel") }
guard let output = filter.outputImage else { return nil }
return pixelPerfectImage(output, targetWidth: size.width, targetHeight: size.height, displayScale: displayScale)
}
}
private func pixelPerfectImage(
_ image: CIImage,
targetWidth: CGFloat,
targetHeight: CGFloat,
displayScale: CGFloat
) -> CGImage? {
let pixelWidth = floor(targetWidth * displayScale)
let pixelHeight = floor(targetHeight * displayScale)
let scale = max(1, floor(min(pixelWidth / image.extent.width, pixelHeight / image.extent.height)))
let scaled = image.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
return CIContext(options: [.useSoftwareRenderer: false]).createCGImage(scaled, from: scaled.extent)
}
private struct EANBarcodeView: View {
enum Kind { case ean13, ean8 }
let value: String
let kind: Kind
private var modules: [Bool]? {
switch kind {
case .ean13: return Self.ean13(value)
case .ean8: return Self.ean8(value)
}
}
var body: some View {
GeometryReader { geometry in
if let modules {
Canvas(rendersAsynchronously: false) { context, size in
let quietZone = 12
let totalModules = modules.count + quietZone * 2
let moduleWidth = floor(size.width / CGFloat(totalModules))
let usedWidth = moduleWidth * CGFloat(totalModules)
let originX = floor((size.width - usedWidth) / 2) + CGFloat(quietZone) * moduleWidth
for (index, black) in modules.enumerated() where black {
context.fill(
Path(CGRect(x: originX + CGFloat(index) * moduleWidth, y: 0, width: moduleWidth, height: size.height)),
with: .color(.black)
)
}
}
}
}
.background(.white)
}
private static let leftOdd = [
"0001101", "0011001", "0010011", "0111101", "0100011",
"0110001", "0101111", "0111011", "0110111", "0001011"
]
private static let leftEven = [
"0100111", "0110011", "0011011", "0100001", "0011101",
"0111001", "0000101", "0010001", "0001001", "0010111"
]
private static let right = [
"1110010", "1100110", "1101100", "1000010", "1011100",
"1001110", "1010000", "1000100", "1001000", "1110100"
]
private static let parity = [
"OOOOOO", "OOEOEE", "OOEEOE", "OOEEEO", "OEOOEE",
"OEEOOE", "OEEEOO", "OEOEOE", "OEOEEO", "OEEOEO"
]
private static func bits(_ string: String) -> [Bool] { string.map { $0 == "1" } }
private static func ean13(_ value: String) -> [Bool]? {
guard value.count == 13, value.allSatisfy(\.isNumber),
let first = value.first?.wholeNumberValue else { return nil }
let digits = value.compactMap(\.wholeNumberValue)
var result = bits("101")
for index in 1...6 {
let encoding = Array(parity[first])[index - 1] == "O" ? leftOdd[digits[index]] : leftEven[digits[index]]
result += bits(encoding)
}
result += bits("01010")
for index in 7...12 { result += bits(right[digits[index]]) }
result += bits("101")
return result
}
private static func ean8(_ value: String) -> [Bool]? {
guard value.count == 8, value.allSatisfy(\.isNumber) else { return nil }
let digits = value.compactMap(\.wholeNumberValue)
var result = bits("101")
for index in 0...3 { result += bits(leftOdd[digits[index]]) }
result += bits("01010")
for index in 4...7 { result += bits(right[digits[index]]) }
result += bits("101")
return result
}
}