diff --git a/Sources/Forge/CLI/Commands.swift b/Sources/Forge/CLI/Commands.swift index afbdc10..cfcfaee 100644 --- a/Sources/Forge/CLI/Commands.swift +++ b/Sources/Forge/CLI/Commands.swift @@ -123,6 +123,8 @@ struct Presets: AsyncParsableCommand { return codec.title case .limitSize(let bytes): return "under \(Int64(bytes).formatted(.byteCount(style: .file)))" + case .stripMetadata(let policy): + return policy == .stripLocation ? "no location" : "no metadata" } } } diff --git a/Sources/Forge/Models/AppSettings.swift b/Sources/Forge/Models/AppSettings.swift index 90b40a4..d85097d 100644 --- a/Sources/Forge/Models/AppSettings.swift +++ b/Sources/Forge/Models/AppSettings.swift @@ -29,6 +29,14 @@ struct AppSettings: Codable, Sendable, Equatable { /// The quality used when a preset names none. 1-100. var defaultQuality: Int = 80 + /// How much of what a file says about its maker survives a conversion, when + /// nothing more specific says otherwise. + /// + /// Defaults to keeping everything: a conversion that quietly drops what the + /// original said is a conversion that lost something, and this is a choice + /// rather than a default anybody should have made for them. + var privacy: PrivacyPolicy = .keepAll + /// How an output file is named. `{name}` is the original's name without its /// extension; a preset's parameters are available by their own keys, so a /// preset asking for a size ceiling under the key `maxsize` can be named @@ -44,12 +52,22 @@ struct AppSettings: Codable, Sendable, Equatable { /// Lossless refuses a bitrate outright — a general preference must not turn /// a working conversion into a failure. func applyingDefaults(to operations: [Operation], writing target: UTType?) -> [Operation] { - guard let target, FormatCatalog.isWritableImage(target) else { return operations } + var chain = operations + + // The privacy level applies to everything with metadata in it, which is + // every kind of file here - unlike quality, which is only meaningful where + // something is re-encoded. + let saysPrivacy = chain.contains { if case .stripMetadata = $0 { return true } else { return false } } + if !saysPrivacy, privacy.removesSomething { + chain.append(.stripMetadata(policy: privacy)) + } + + guard let target, FormatCatalog.isWritableImage(target) else { return chain } - let saysQuality = operations.contains { if case .quality = $0 { return true } else { return false } } - guard !saysQuality else { return operations } + let saysQuality = chain.contains { if case .quality = $0 { return true } else { return false } } + guard !saysQuality else { return chain } - return operations + [.quality(level: defaultQuality)] + return chain + [.quality(level: defaultQuality)] } // MARK: - Persistence diff --git a/Sources/Forge/Models/ConfirmationLevel.swift b/Sources/Forge/Models/ConfirmationLevel.swift index c2d8c8b..58cd222 100644 --- a/Sources/Forge/Models/ConfirmationLevel.swift +++ b/Sources/Forge/Models/ConfirmationLevel.swift @@ -105,7 +105,7 @@ extension ConversionPlan { /// in the filename included, because a plan that disagrees with the run is /// worse than no plan at all. init(file: ProcessableFile, preset: RulePreset?, destinationMode: DestinationMode) { - let operations = preset.map { SizeInName.applying(to: $0.toOperations(), from: file.fileName) } ?? [] + let operations = preset.map { NameTokens.applying(to: $0.toOperations(), from: file.fileName) } ?? [] let ceiling = SizeInName.ceiling(in: file.fileName) let source = ConvertKind(fileType: file.fileType) diff --git a/Sources/Forge/Models/NameTokens.swift b/Sources/Forge/Models/NameTokens.swift new file mode 100644 index 0000000..7b3117d --- /dev/null +++ b/Sources/Forge/Models/NameTokens.swift @@ -0,0 +1,72 @@ +import Foundation + +/// The instructions somebody wrote into a file's own name. +/// +/// `holiday_10MB.jpg` says come out under ten megabytes. `contract_privacy.pdf` +/// says take the author details out. `holiday_10MB_privacy.jpg` says both, and +/// that is the reason this exists: reading only the last piece of the name +/// meant the second instruction hid the first. +/// +/// Tokens are read from the end and stop at the first piece that is not one. +/// `10MB_notes.jpg` is a file about ten megabytes, not a file to be squeezed +/// into ten - the size has to be the instruction, and an instruction comes at +/// the end. +enum NameTokens { + + struct Read: Equatable { + /// A ceiling on the finished file, in bytes. + var ceiling: Int? + /// How much metadata to leave behind. + var privacy: PrivacyPolicy? + + var isEmpty: Bool { ceiling == nil && privacy == nil } + } + + /// What the name asks for. + static func read(_ fileName: String) -> Read { + let stem = (fileName as NSString).deletingPathExtension + let pieces = stem.split(separator: "_").map(String.init) + // No underscore means no instruction: the whole name is the name. + guard pieces.count > 1 else { return Read() } + + var read = Read() + // Never the first piece: a name made only of instructions is a name, and + // `10MB.jpg` is a file about ten megabytes. + for piece in pieces.reversed().dropLast() { + let token = piece.lowercased() + if token == "privacy" { + // Typed by hand, about this one file: the most thorough level, since + // "privacy" is not a request for half of it. + read.privacy = .stripAll + } else if let bytes = SizeInName.bytes(in: token) { + read.ceiling = bytes + } else { + // Anything else ends the run. Without this, a date or a word in the + // middle of a name would be stepped over and whatever came before it + // read as an instruction. + break + } + } + return read + } + + /// The chain with the name's instructions in it. + /// + /// They beat what the preset says, because they were typed onto that one file + /// for this one conversion. + static func applying(to operations: [Operation], from fileName: String) -> [Operation] { + let read = read(fileName) + guard !read.isEmpty else { return operations } + + var result = operations + if let ceiling = read.ceiling { + result.removeAll { if case .limitSize = $0 { return true } else { return false } } + result.append(.limitSize(bytes: ceiling)) + } + if let privacy = read.privacy { + result.removeAll { if case .stripMetadata = $0 { return true } else { return false } } + result.append(.stripMetadata(policy: privacy)) + } + return result + } +} diff --git a/Sources/Forge/Models/Operation.swift b/Sources/Forge/Models/Operation.swift index e4ceed1..560ab55 100644 --- a/Sources/Forge/Models/Operation.swift +++ b/Sources/Forge/Models/Operation.swift @@ -19,6 +19,12 @@ enum Operation: Codable, Hashable, Identifiable, Sendable { /// handed to the encoder: the file is written, measured, and written again /// lower until it fits. case limitSize(bytes: Int) + /// Leave out what the file says about the person, the device and the moment. + /// + /// A step in the chain like any other, so it can come from Settings, from a + /// preset, from a batch, or from the filename - and so history records that + /// it happened. + case stripMetadata(policy: PrivacyPolicy) /// A short name for the action, as the editor lists it. var title: String { @@ -30,6 +36,7 @@ enum Operation: Codable, Hashable, Identifiable, Sendable { case .recognizeText: return "Read text" case .encode: return "Choose codec" case .limitSize: return "Fit within a size" + case .stripMetadata: return "Remove metadata" } } @@ -42,6 +49,7 @@ enum Operation: Codable, Hashable, Identifiable, Sendable { case .recognizeText: return "text.viewfinder" case .encode: return "cpu" case .limitSize: return "arrow.down.right.and.arrow.up.left" + case .stripMetadata(let policy): return policy.symbol } } @@ -54,6 +62,7 @@ enum Operation: Codable, Hashable, Identifiable, Sendable { case .recognizeText: return "ocr" case .encode: return "codec" case .limitSize: return "limitSize" + case .stripMetadata: return "privacy" } } } @@ -62,11 +71,11 @@ enum Operation: Codable, Hashable, Identifiable, Sendable { extension Operation { private enum CodingKeys: String, CodingKey { - case kind, format, width, height, fitMode, level, filter, languages, codec, bytes + case kind, format, width, height, fitMode, level, filter, languages, codec, bytes, privacy } private enum Kind: String, Codable { - case convertFormat, resize, quality, filter, recognizeText, encode, limitSize + case convertFormat, resize, quality, filter, recognizeText, encode, limitSize, stripMetadata } init(from decoder: Decoder) throws { @@ -90,6 +99,8 @@ extension Operation { self = .encode(codec: try container.decode(Codec.self, forKey: .codec)) case .limitSize: self = .limitSize(bytes: try container.decode(Int.self, forKey: .bytes)) + case .stripMetadata: + self = .stripMetadata(policy: try container.decode(PrivacyPolicy.self, forKey: .privacy)) } } @@ -119,6 +130,9 @@ extension Operation { case .limitSize(let bytes): try container.encode(Kind.limitSize, forKey: .kind) try container.encode(bytes, forKey: .bytes) + case .stripMetadata(let policy): + try container.encode(Kind.stripMetadata, forKey: .kind) + try container.encode(policy, forKey: .privacy) } } } diff --git a/Sources/Forge/Models/PrivacyPolicy.swift b/Sources/Forge/Models/PrivacyPolicy.swift new file mode 100644 index 0000000..b943104 --- /dev/null +++ b/Sources/Forge/Models/PrivacyPolicy.swift @@ -0,0 +1,54 @@ +import Foundation + +/// How much of what a file says about the person who made it survives a +/// conversion. +/// +/// A photograph carries where it was taken, which camera took it, which lens, +/// and often the serial number of both. A PDF carries who wrote it and what +/// wrote it. None of that is the picture or the document, and all of it travels +/// when the file is sent to somebody. +/// +/// What is never dropped, at any level: the colour profile and the orientation. +/// Those are not information about a person, they are what makes the file look +/// like itself - drop the profile and the colours shift, drop the orientation +/// and the photograph arrives on its side. +enum PrivacyPolicy: String, Codable, Sendable, Hashable, CaseIterable { + /// Carry everything across, which is what a conversion should do when + /// nobody asked otherwise. + case keepAll + /// Take out where it was made, and nothing else. + case stripLocation + /// Take out everything that identifies a person, a device or a moment. + case stripAll + + var title: String { + switch self { + case .keepAll: return "Keep all metadata" + case .stripLocation: return "Remove the location" + case .stripAll: return "Remove identifying metadata" + } + } + + var summary: String { + switch self { + case .keepAll: + return "Everything the original says about itself is carried across." + case .stripLocation: + return "Where the file was made is removed. Everything else stays." + case .stripAll: + return "Location, device, serial numbers, author and timestamps are removed. " + + "The colour profile and the orientation stay: without them the file no longer looks like itself." + } + } + + var symbol: String { + switch self { + case .keepAll: return "tag" + case .stripLocation: return "location.slash" + case .stripAll: return "eye.slash" + } + } + + /// Whether this removes anything at all. + var removesSomething: Bool { self != .keepAll } +} diff --git a/Sources/Forge/Models/Redaction.swift b/Sources/Forge/Models/Redaction.swift new file mode 100644 index 0000000..bd25933 --- /dev/null +++ b/Sources/Forge/Models/Redaction.swift @@ -0,0 +1,121 @@ +import CoreGraphics +import Foundation + +/// Something in a picture that might want covering up, and the fact that a +/// person has not yet said so. +/// +/// This is assistance, not anonymisation. Vision finds faces it recognises as +/// faces and text it can read; a face turned away, a name in handwriting, a +/// reflection in a window, a plate at an angle - all of those are missed, +/// routinely. Nothing here is ever applied on its own, and no part of the app +/// may describe it as making a file anonymous. +struct RedactionCandidate: Identifiable, Hashable, Sendable { + let id: UUID + /// Where it is, in the image's own coordinates: 0-1 from the bottom left, + /// which is what Vision reports and what Core Image draws in. + let rect: CGRect + let kind: Kind + /// How sure the detector was, as it reported it. + let confidence: Float + /// What was read there, when something was. + let text: String? + + init(id: UUID = UUID(), rect: CGRect, kind: Kind, confidence: Float = 0, text: String? = nil) { + self.id = id + self.rect = rect + self.kind = kind + self.confidence = confidence + self.text = text + } + + enum Kind: Hashable, Sendable { + case face + /// Text that was read, with nothing said about what it means. + case text + /// Text that Natural Language read as a name, a place or an organisation. + case name(String) + + var title: String { + switch self { + case .face: return "Face" + case .text: return "Text" + case .name(let what): return what + } + } + + var symbol: String { + switch self { + case .face: return "person.crop.square" + case .text: return "textformat" + case .name: return "person.text.rectangle" + } + } + } +} + +/// How a confirmed region is covered. +enum RedactionStyle: String, CaseIterable, Sendable, Hashable { + /// A solid block. Nothing survives it, which is the point. + case blackout + /// Coarse blocks. Reads as deliberate rather than as damage, and is just as + /// irreversible in the written file. + case pixellate + + var title: String { + switch self { + case .blackout: return "Black out" + case .pixellate: return "Pixellate" + } + } +} + +/// One review: what was found, and what a person has actually agreed to cover. +/// +/// The distinction is the whole design. `candidates` is what a detector +/// suggested; `confirmed` is what somebody looked at and said yes to. Only the +/// second ever reaches a file, and a session with nothing confirmed produces +/// nothing at all. +struct RedactionSession: Sendable { + let source: URL + var candidates: [RedactionCandidate] + /// The ones a person has ticked. Deliberately starting empty: a detector's + /// opinion is not consent, and pre-ticking every box would turn a review into + /// a formality. + var confirmed: Set = [] + var style: RedactionStyle = .blackout + + init(source: URL, candidates: [RedactionCandidate]) { + self.source = source + self.candidates = candidates + } + + func isConfirmed(_ candidate: RedactionCandidate) -> Bool { confirmed.contains(candidate.id) } + + mutating func toggle(_ candidate: RedactionCandidate) { + if confirmed.contains(candidate.id) { + confirmed.remove(candidate.id) + } else { + confirmed.insert(candidate.id) + } + } + + /// A region somebody drew themselves, which is confirmed by the act of + /// drawing it. + mutating func add(_ rect: CGRect) { + let candidate = RedactionCandidate(rect: rect, kind: .text, confidence: 1) + candidates.append(candidate) + confirmed.insert(candidate.id) + } + + mutating func remove(_ candidate: RedactionCandidate) { + candidates.removeAll { $0.id == candidate.id } + confirmed.remove(candidate.id) + } + + /// The only thing that ever reaches the file. + var confirmedRegions: [CGRect] { + candidates.filter { confirmed.contains($0.id) }.map(\.rect) + } + + var hasSomethingToDo: Bool { !confirmedRegions.isEmpty } +} diff --git a/Sources/Forge/Models/SizeInName.swift b/Sources/Forge/Models/SizeInName.swift index e1a8d30..245bab4 100644 --- a/Sources/Forge/Models/SizeInName.swift +++ b/Sources/Forge/Models/SizeInName.swift @@ -22,16 +22,19 @@ enum SizeInName { /// The ceiling a filename asks for, or nil if it asks for nothing. /// - /// Only the last underscore-separated piece is read, so `holiday_2024_5MB` - /// works and `10MB_notes` — where the size is the subject rather than the - /// instruction — does not. + /// Read from the end of the name, so `holiday_2024_5MB` works and + /// `10MB_notes` — where the size is the subject rather than the instruction — + /// does not. `NameTokens` reads the rest of what a name can say. static func ceiling(in fileName: String) -> Int? { - let stem = (fileName as NSString).deletingPathExtension - guard let piece = stem.split(separator: "_").last.map(String.init), piece != stem else { - return nil - } + NameTokens.read(fileName).ceiling + } - let token = piece.lowercased().replacingOccurrences(of: ",", with: ".") + /// The size one piece of a name asks for, or nil if that piece is not a size. + /// + /// The piece rather than the name: a name can carry more than one + /// instruction, and which pieces to look at is `NameTokens`' business. + static func bytes(in token: String) -> Int? { + let token = token.lowercased().replacingOccurrences(of: ",", with: ".") for unit in units where token.hasSuffix(unit.suffix) { let number = String(token.dropLast(unit.suffix.count)) guard !number.isEmpty, let value = Double(number), value > 0 else { continue } @@ -42,12 +45,8 @@ enum SizeInName { return nil } - /// The chain with that ceiling in it, if the name asked for one. A ceiling - /// already in the chain is replaced: the name is the more specific answer. + /// The chain with that ceiling in it, if the name asked for one. static func applying(to operations: [Operation], from fileName: String) -> [Operation] { - guard let bytes = ceiling(in: fileName) else { return operations } - var result = operations.filter { if case .limitSize = $0 { return false } else { return true } } - result.append(.limitSize(bytes: bytes)) - return result + NameTokens.applying(to: operations, from: fileName) } } diff --git a/Sources/Forge/Processors/ImageProcessor.swift b/Sources/Forge/Processors/ImageProcessor.swift index 36851ad..5f557d1 100644 --- a/Sources/Forge/Processors/ImageProcessor.swift +++ b/Sources/Forge/Processors/ImageProcessor.swift @@ -232,6 +232,9 @@ final class ImageProcessor: FileProcessor, @unchecked Sendable { if isLossy(uti) { options[kCGImageDestinationLossyCompressionQuality] = quality(from: operations) } + // Whatever the chain says to leave behind, minus the two things that are + // never left behind: the orientation and the colour profile. + options = PrivacyFilter.filter(options, to: PrivacyFilter.policy(in: operations)) return options } @@ -355,7 +358,7 @@ final class ImageProcessor: FileProcessor, @unchecked Sendable { private func applyOperation(_ operation: Operation, to image: CIImage) throws -> CIImage { switch operation { - case .convertFormat, .quality, .recognizeText, .encode, .limitSize: + case .convertFormat, .quality, .recognizeText, .encode, .limitSize, .stripMetadata: return image // settled when the file is written case .resize(let width, let height, let mode): return applyResize(image, targetWidth: width, targetHeight: height, mode: mode) diff --git a/Sources/Forge/Processors/MediaProcessor.swift b/Sources/Forge/Processors/MediaProcessor.swift index 340d836..17ef843 100644 --- a/Sources/Forge/Processors/MediaProcessor.swift +++ b/Sources/Forge/Processors/MediaProcessor.swift @@ -271,6 +271,21 @@ final class MediaProcessor: FileProcessor, @unchecked Sendable { session.outputURL = output session.outputFileType = fileType session.shouldOptimizeForNetworkUse = true + + // Where the film was shot travels inside it. An export with no metadata of + // its own carries the source's across, so leaving it out is done here: the + // session's own filter for the thorough level, since AVFoundation knows + // what each key means, and the location keys alone otherwise. + let privacy = PrivacyFilter.policy(in: operations) + if privacy.removesSomething { + if let filter = PrivacyFilter.filter(for: privacy) { + session.metadataItemFilter = filter + } else { + session.metadata = PrivacyFilter.metadata( + try await asset.load(.metadata), to: privacy + ) + } + } // Metadata is deliberately not set: an export session with none of its own // carries the source's across, and setting it to the common key space // would *narrow* that to the handful of tags every container shares. @@ -453,7 +468,9 @@ final class MediaProcessor: FileProcessor, @unchecked Sendable { progress(totalFrames > 0 ? Double(position) / Double(totalFrames) : 0) } - try await Self.carryTags(from: input, to: output, type: outputType) + try await Self.carryTags( + from: input, to: output, type: outputType, privacy: PrivacyFilter.policy(in: operations) + ) progress(1.0) return ProcessingResult( @@ -474,8 +491,15 @@ final class MediaProcessor: FileProcessor, @unchecked Sendable { /// Which containers can hold tags is asked rather than listed: an export /// session says what it can write, and a FLAC or a WAV is simply left /// untagged instead of being promised something AVFoundation cannot do. - private static func carryTags(from input: URL, to output: URL, type: UTType) async throws { - let tags = try await AVURLAsset(url: input).load(.metadata) + private static func carryTags( + from input: URL, + to output: URL, + type: UTType, + privacy: PrivacyPolicy = .keepAll + ) async throws { + let tags = PrivacyFilter.metadata( + try await AVURLAsset(url: input).load(.metadata), to: privacy + ) guard !tags.isEmpty else { return } guard let session = AVAssetExportSession( diff --git a/Sources/Forge/Processors/PrivacyFilter.swift b/Sources/Forge/Processors/PrivacyFilter.swift new file mode 100644 index 0000000..af14521 --- /dev/null +++ b/Sources/Forge/Processors/PrivacyFilter.swift @@ -0,0 +1,208 @@ +import AVFoundation +import Foundation +import ImageIO +import PDFKit +import UniformTypeIdentifiers + +/// Takes the identifying parts out of a file, and leaves the parts that make it +/// look like itself. +/// +/// Everything here is a list rather than a chain of `if`s about formats: what +/// counts as a location, what counts as identifying, and what is never dropped. +/// A tag that is on no list is carried across, which is the safe direction - a +/// conversion that quietly loses something is worse than one that keeps too +/// much, and the level is what the user chose. +enum PrivacyFilter { + + /// What the chain asks for. The most thorough thing said wins: a preset + /// asking to remove the location and a filename asking to remove everything + /// means everything. + static func policy(in operations: [Operation]) -> PrivacyPolicy { + operations + .compactMap { if case .stripMetadata(let policy) = $0 { return policy } else { return nil } } + .max { $0.thoroughness < $1.thoroughness } ?? .keepAll + } + + // MARK: - Images + + /// Never dropped, at any level. + /// + /// Orientation says which way up the photograph is; the profile says what its + /// colours mean. A file without them is not a more private version of itself, + /// it is a broken version of itself. + static let alwaysKept: Set = [ + kCGImagePropertyOrientation as String, + kCGImagePropertyProfileName as String, + kCGImagePropertyColorModel as String, + kCGImagePropertyDepth as String, + kCGImagePropertyHasAlpha as String, + kCGImagePropertyDPIWidth as String, + kCGImagePropertyDPIHeight as String, + kCGImagePropertyPixelWidth as String, + kCGImagePropertyPixelHeight as String, + ] + + /// Where the picture was taken. + private static let locationBlocks: Set = [ + kCGImagePropertyGPSDictionary as String, + ] + + /// Whole blocks that exist to say who, what and when. + private static let identifyingBlocks: Set = [ + kCGImagePropertyIPTCDictionary as String, + kCGImagePropertyExifAuxDictionary as String, + ] + + /// Tags inside the EXIF block that identify a device, a person or a moment. + private static let identifyingExif: Set = [ + kCGImagePropertyExifDateTimeOriginal as String, + kCGImagePropertyExifDateTimeDigitized as String, + kCGImagePropertyExifSubsecTime as String, + kCGImagePropertyExifSubsecTimeOriginal as String, + kCGImagePropertyExifSubsecTimeDigitized as String, + kCGImagePropertyExifBodySerialNumber as String, + kCGImagePropertyExifLensSerialNumber as String, + kCGImagePropertyExifLensMake as String, + kCGImagePropertyExifLensModel as String, + kCGImagePropertyExifCameraOwnerName as String, + kCGImagePropertyExifUserComment as String, + kCGImagePropertyExifMakerNote as String, + kCGImagePropertyExifImageUniqueID as String, + ] + + /// The same, in the TIFF block, which is where a camera writes its name. + private static let identifyingTIFF: Set = [ + kCGImagePropertyTIFFMake as String, + kCGImagePropertyTIFFModel as String, + kCGImagePropertyTIFFSoftware as String, + kCGImagePropertyTIFFArtist as String, + kCGImagePropertyTIFFCopyright as String, + kCGImagePropertyTIFFDateTime as String, + kCGImagePropertyTIFFHostComputer as String, + ] + + /// The properties to write, given the ones that were read. + static func filter( + _ properties: [CFString: Any], + to policy: PrivacyPolicy + ) -> [CFString: Any] { + guard policy.removesSomething else { return properties } + + var kept: [CFString: Any] = [:] + for (key, value) in properties { + let name = key as String + if alwaysKept.contains(name) { + kept[key] = value + continue + } + if locationBlocks.contains(name) { continue } + if policy == .stripLocation { + kept[key] = value + continue + } + if identifyingBlocks.contains(name) { continue } + // A maker note is whatever the camera felt like writing, under a key + // named after the camera. Listing the cameras would be a list nobody can + // finish; the shape of the key is the rule. + if name.hasPrefix("{Maker") { continue } + + if name == kCGImagePropertyExifDictionary as String { + kept[key] = strip(identifyingExif, from: value) + } else if name == kCGImagePropertyTIFFDictionary as String { + kept[key] = strip(identifyingTIFF, from: value) + } else { + kept[key] = value + } + } + return kept + } + + private static func strip(_ tags: Set, from block: Any) -> Any { + guard var dictionary = block as? [CFString: Any] else { return block } + for key in dictionary.keys where tags.contains(key as String) { + dictionary.removeValue(forKey: key) + } + return dictionary + } + + // MARK: - PDF + + /// Clear what a PDF says about who made it. + /// + /// Only at the thorough level: a PDF has no location to remove, so taking the + /// author out when the user asked about their whereabouts would be doing + /// something they did not ask for. + /// + /// This runs on the scratch file, before it is moved into place, so nothing + /// the user already has is touched. + static func strip(pdfAt url: URL, to policy: PrivacyPolicy) throws { + guard policy == .stripAll, let pdf = PDFDocument(url: url) else { return } + + // The title is kept: it is what the document is called, not who wrote it. + var attributes: [AnyHashable: Any] = [:] + if let title = pdf.documentAttributes?[PDFDocumentAttribute.titleAttribute] { + attributes[PDFDocumentAttribute.titleAttribute] = title + } + pdf.documentAttributes = attributes + + guard pdf.write(to: url) else { + throw ProcessingError.conversionFailed( + reason: "Cannot rewrite \(url.lastPathComponent) without its author details" + ) + } + } + + /// Apply what can only be done once a file exists, to files that are still + /// scratch. A no-op for everything handled while it was written. + static func applyAfterWriting(_ policy: PrivacyPolicy, to outputs: [URL]) throws { + guard policy.removesSomething else { return } + for output in outputs where output.pathExtension.lowercased() == "pdf" { + try strip(pdfAt: output, to: policy) + } + } + + // MARK: - Audio and video + + /// The metadata an export should carry. + static func metadata(_ items: [AVMetadataItem], to policy: PrivacyPolicy) -> [AVMetadataItem] { + switch policy { + case .keepAll: + return items + case .stripLocation: + return items.filter { !isLocation($0) } + case .stripAll: + // Deliberately narrow rather than empty: a title and an artist are what a + // music file is, and removing those is not privacy, it is loss. + return items.filter { !isLocation($0) && !isIdentifying($0) } + } + } + + /// What an export session should be told, where the session filters for + /// itself. AVFoundation's own idea of what is safe to share is maintained by + /// the people who know what each key means. + static func filter(for policy: PrivacyPolicy) -> AVMetadataItemFilter? { + policy == .stripAll ? .forSharing() : nil + } + + private static func isLocation(_ item: AVMetadataItem) -> Bool { + guard let identifier = item.identifier?.rawValue.lowercased() else { return false } + return identifier.contains("location") + } + + private static func isIdentifying(_ item: AVMetadataItem) -> Bool { + guard let identifier = item.identifier?.rawValue.lowercased() else { return false } + return ["make", "model", "software", "creationdate", "author", "owner", "device"] + .contains { identifier.contains($0) } + } +} + +private extension PrivacyPolicy { + /// How much each level removes, for deciding which of two wins. + var thoroughness: Int { + switch self { + case .keepAll: return 0 + case .stripLocation: return 1 + case .stripAll: return 2 + } + } +} diff --git a/Sources/Forge/Processors/RedactionScout.swift b/Sources/Forge/Processors/RedactionScout.swift new file mode 100644 index 0000000..cdbec2d --- /dev/null +++ b/Sources/Forge/Processors/RedactionScout.swift @@ -0,0 +1,200 @@ +import CoreImage +import Foundation +import ImageIO +import NaturalLanguage +import UniformTypeIdentifiers +import Vision + +/// Looks for what somebody might want covered, and claims nothing about having +/// found all of it. +/// +/// On device, with the frameworks already here: Vision reads faces and text, +/// Natural Language reads that text for names, places and organisations. +/// Nothing leaves the machine and no model is downloaded. +actor RedactionScout { + + /// What might be worth covering in this image. + /// + /// Ordered the way a person reads down a list: faces first, then text that + /// looks like somebody's name, then the rest of the text. + func candidates(in url: URL) async throws -> [RedactionCandidate] { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { + throw ProcessingError.conversionFailed(reason: "Cannot read \(url.lastPathComponent)") + } + + let handler = VNImageRequestHandler(cgImage: image, options: [:]) + let faces = VNDetectFaceRectanglesRequest() + let words = VNRecognizeTextRequest() + words.recognitionLevel = .accurate + words.usesLanguageCorrection = true + + try handler.perform([faces, words]) + + var found: [RedactionCandidate] = (faces.results ?? []).map { + RedactionCandidate(rect: $0.boundingBox, kind: .face, confidence: $0.confidence) + } + + var named: [RedactionCandidate] = [] + var plain: [RedactionCandidate] = [] + for observation in words.results ?? [] { + guard let reading = observation.topCandidates(1).first else { continue } + let candidate = RedactionCandidate( + rect: observation.boundingBox, + kind: Self.entity(in: reading.string).map { RedactionCandidate.Kind.name($0) } ?? .text, + confidence: reading.confidence, + text: reading.string + ) + if case .name = candidate.kind { named.append(candidate) } else { plain.append(candidate) } + } + + found.append(contentsOf: named) + found.append(contentsOf: plain) + return found + } + + /// What Natural Language makes of a line of text, if it makes anything of it. + /// + /// A name, a place or an organisation is worth putting near the top of the + /// list. Everything else is text, which is worth listing and not worth + /// calling private. + nonisolated static func entity(in text: String) -> String? { + let tagger = NLTagger(tagSchemes: [.nameType]) + tagger.string = text + + var found: String? + tagger.enumerateTags( + in: text.startIndex.. CGImage? { + let scale = max(region.width, region.height) / 12 + let filter = CIFilter(name: "CIPixellate", parameters: [ + kCIInputImageKey: image, + kCIInputScaleKey: max(scale, 4), + kCIInputCenterKey: CIVector(x: region.midX, y: region.midY), + ]) + guard let output = filter?.outputImage else { return nil } + return ci.createCGImage(output, from: region) + } +} diff --git a/Sources/Forge/Processors/SimpleDocProcessor.swift b/Sources/Forge/Processors/SimpleDocProcessor.swift index 150ce1f..8919956 100644 --- a/Sources/Forge/Processors/SimpleDocProcessor.swift +++ b/Sources/Forge/Processors/SimpleDocProcessor.swift @@ -330,7 +330,7 @@ final class SimpleDocProcessor: FileProcessor, @unchecked Sendable { private static func apply(_ operation: Operation, to image: CIImage) -> CIImage { switch operation { - case .convertFormat, .quality, .recognizeText, .encode, .limitSize: + case .convertFormat, .quality, .recognizeText, .encode, .limitSize, .stripMetadata: return image case .resize(let width, let height, _): let target = CGSize( diff --git a/Sources/Forge/Services/ProcessingCoordinator.swift b/Sources/Forge/Services/ProcessingCoordinator.swift index 5e09b8c..c27fcef 100644 --- a/Sources/Forge/Services/ProcessingCoordinator.swift +++ b/Sources/Forge/Services/ProcessingCoordinator.swift @@ -263,7 +263,7 @@ actor ProcessingCoordinator { // written into the file's own name beats both, because somebody typed it // onto that file for this conversion. let operations = settings.applyingDefaults( - to: SizeInName.applying(to: preset.toOperations(), from: file.fileName), + to: NameTokens.applying(to: preset.toOperations(), from: file.fileName), writing: preset.targetFormat ?? file.fileType ) @@ -302,6 +302,14 @@ actor ProcessingCoordinator { try Task.checkCancellation() + // A PDF's author details can only be taken out of a PDF that exists, so + // this happens here rather than in a processor - on the scratch file, + // before anything is moved into place. + try PrivacyFilter.applyAfterWriting( + PrivacyFilter.policy(in: operations), + to: [plan.workURL] + result.additionalOutputs + ) + if plan.replacesSource, settings.createBackupBeforeOverwrite { try backUp(file.url) } diff --git a/Sources/Forge/Views/BatchProcessingView.swift b/Sources/Forge/Views/BatchProcessingView.swift index ab2db55..7e007b9 100644 --- a/Sources/Forge/Views/BatchProcessingView.swift +++ b/Sources/Forge/Views/BatchProcessingView.swift @@ -18,6 +18,10 @@ struct BatchProcessingView: View { /// be looked up by id, and it stands until the user opens the sheet and says /// something else. @State private var adhoc: RulePreset? + /// The file being looked through for things to cover up. Redaction is not + /// part of a conversion: it is irreversible, it needs a person to agree to + /// every region, and it writes a copy of its own. + @State private var reviewing: ProcessableFile? /// The kinds of file in the list. What the sheet offers is decided from this, /// so a PDF is asked different questions than a video. @@ -98,6 +102,7 @@ struct BatchProcessingView: View { fileCount: vm.files.count, totalSize: totalSize, presets: model.usablePresets, + defaultPrivacy: model.settings.privacy, choice: $choice, onConvert: { // Overwrite and Move both change what is already on disk, so they are @@ -106,6 +111,12 @@ struct BatchProcessingView: View { } ) } + .sheet(item: $reviewing) { file in + RedactionReviewSheet(file: file) { written in + model.remember(written) + vm.lastSaving = "Covered copy written to \(written.lastPathComponent)." + } + } .sheet(isPresented: Binding(get: { !asking.isEmpty }, set: { if !$0 { asking = [] } })) { ConfirmationSheet( groups: asking, @@ -148,7 +159,18 @@ struct BatchProcessingView: View { private var fileTable: some View { Table(vm.files) { - TableColumn("File") { f in Text(f.fileName).lineLimit(1).truncationMode(.middle) } + TableColumn("File") { f in + Text(f.fileName) + .lineLimit(1) + .truncationMode(.middle) + .contextMenu { + // Images only: Vision reads pictures, and offering this for a CSV + // would be a menu item that finds nothing every time. + if FormatCatalog.isReadableImage(f.fileType) { + Button("Review for Redaction…") { reviewing = f } + } + } + } TableColumn("Type") { f in Text(f.fileType.localizedDescription ?? f.fileType.preferredFilenameExtension ?? "—") .foregroundStyle(.secondary) diff --git a/Sources/Forge/Views/ConvertOptionsSheet.swift b/Sources/Forge/Views/ConvertOptionsSheet.swift index 713272c..5890559 100644 --- a/Sources/Forge/Views/ConvertOptionsSheet.swift +++ b/Sources/Forge/Views/ConvertOptionsSheet.swift @@ -21,6 +21,9 @@ struct ConvertChoice: Equatable { var fitMode: ResizeFitMode = .proportional /// A ceiling on the finished file, in bytes. var maxBytes: Int? + /// How much of what these files say about their maker is carried across. + /// Nil means whatever Settings says, which is the general preference. + var privacy: PrivacyPolicy? /// Answers to the chosen preset's questions, by the parameter's key. var answers: [String: Double] = [:] /// The language for OCR and transcription. `nil` means let the system decide. @@ -30,6 +33,41 @@ struct ConvertChoice: Equatable { var wantsText: Bool { format.type?.conforms(to: .plainText) == true } + /// The same choice, filled in from a preset. + /// + /// Every field the sheet can show is cleared first. A preset that says + /// nothing about a size ceiling or about metadata is a preset that says + /// nothing, and leaving the last preset's answer in the field shows a promise + /// this one never made - which then gets applied for real the moment any + /// other field is touched and the preset lets go. + func adopting(_ preset: RulePreset) -> ConvertChoice { + var updated = self + updated.format = .keep + updated.width = nil + updated.quality = nil + updated.filter = nil + updated.codec = nil + updated.language = nil + updated.maxBytes = nil + updated.privacy = nil + + for action in preset.actions { + switch action { + case .convertFormat(let to): updated.format = OutputFormat(type: to) + case .resize(let width, _, _): updated.width = width + case .quality(let level): updated.quality = level + case .filter(let type): updated.filter = type + case .encode(let codec): updated.codec = codec + case .recognizeText(let languages): updated.language = languages.first + case .limitSize(let bytes): updated.maxBytes = bytes + case .stripMetadata(let policy): updated.privacy = policy + } + } + + updated.presetID = preset.id + return updated + } + var needsDestination: Bool { destinationMode != .overwrite } /// The actions to run when no preset is in charge, in the order they run. @@ -41,6 +79,10 @@ struct ConvertChoice: Equatable { if let filter { actions.append(.filter(type: filter)) } if let codec { actions.append(.encode(codec: codec)) } if let maxBytes { actions.append(.limitSize(bytes: maxBytes)) } + // Included even when it is "keep everything": saying so for this batch has + // to be able to overrule the general preference, and a chain that says + // nothing is a chain the preference fills in. + if let privacy { actions.append(.stripMetadata(policy: privacy)) } if wantsText { actions.append(.recognizeText(languages: language.map { [$0] } ?? [])) } return actions } @@ -82,6 +124,9 @@ struct ConvertOptionsSheet: View { let fileCount: Int let totalSize: Int64 let presets: [RulePreset] + /// What Settings says about metadata, so the batch control can show what it + /// is starting from rather than an empty choice. + let defaultPrivacy: PrivacyPolicy @Binding var choice: ConvertChoice let onConvert: () -> Void @@ -158,8 +203,10 @@ struct ConvertOptionsSheet: View { if kind.supportsCodec, !choice.wantsText { codecField(for: kind) } if kind.supportsFilter, !choice.wantsText { filterField } if kind.supportsTextExtraction, choice.wantsText { languageField } + privacyField } else { mixedNote + privacyField } destinationField @@ -293,6 +340,21 @@ struct ConvertOptionsSheet: View { } } + /// What these files should stop saying about whoever made them, for this + /// batch. Starts from the general preference in Settings and can disagree + /// with it, which is what makes that a preference rather than a law. + private var privacyField: some View { + field("Metadata") { + chips { + ForEach(PrivacyPolicy.allCases, id: \.self) { policy in + chip(policy.title, selected: (choice.privacy ?? defaultPrivacy) == policy) { + edit { $0.privacy = policy } + } + } + } + } + } + /// A promise about the finished file rather than a setting for the encoder. private var ceilingField: some View { field("Fit within a size") { @@ -482,28 +544,7 @@ struct ConvertOptionsSheet: View { /// Fill the fields with what a preset says, so it reads as a starting point /// that can be adjusted rather than a black box. private func load(_ preset: RulePreset) { - var updated = choice - updated.format = .keep - updated.width = nil - updated.quality = nil - updated.filter = nil - updated.codec = nil - updated.language = nil - - for action in preset.actions { - switch action { - case .convertFormat(let to): updated.format = OutputFormat(type: to) - case .resize(let width, _, _): updated.width = width - case .quality(let level): updated.quality = level - case .filter(let type): updated.filter = type - case .encode(let codec): updated.codec = codec - case .recognizeText(let languages): updated.language = languages.first - case .limitSize(let bytes): updated.maxBytes = bytes - } - } - - updated.presetID = preset.id - choice = updated + choice = choice.adopting(preset) } private func field(_ title: String, @ViewBuilder content: () -> Content) -> some View { diff --git a/Sources/Forge/Views/PresetEditorView.swift b/Sources/Forge/Views/PresetEditorView.swift index f7cae06..e6dae3a 100644 --- a/Sources/Forge/Views/PresetEditorView.swift +++ b/Sources/Forge/Views/PresetEditorView.swift @@ -317,7 +317,7 @@ struct Action: Identifiable, Hashable { /// top, because it is the one thing every preset answers and the one thing that /// can be answered more than once. enum ActionKind: String, CaseIterable, Identifiable { - case crop, resize, quality, limitSize, filter, recognizeText, encode + case crop, resize, quality, limitSize, filter, recognizeText, encode, privacy var id: String { rawValue } var title: String { @@ -329,6 +329,7 @@ enum ActionKind: String, CaseIterable, Identifiable { case .filter: return "Filter" case .recognizeText: return "Read the text" case .encode: return "Codec" + case .privacy: return "Remove metadata" } } @@ -341,6 +342,7 @@ enum ActionKind: String, CaseIterable, Identifiable { case .filter: return "camera.filters" case .recognizeText: return "text.viewfinder" case .encode: return "cpu" + case .privacy: return "eye.slash" } } @@ -357,6 +359,7 @@ enum ActionKind: String, CaseIterable, Identifiable { case .encode: let codecs = category == .audio ? Codec.audioCodecs : Codec.videoCodecs return .encode(codec: codecs.first ?? .h264) + case .privacy: return .stripMetadata(policy: .stripAll) } } @@ -375,6 +378,9 @@ enum ActionKind: String, CaseIterable, Identifiable { return category != .audio || category == .custom case .encode: return [.video, .audio, .custom].contains(category) + case .privacy: + // Every kind of file carries something about who made it. + return true } } } @@ -461,6 +467,18 @@ private struct ActionRow: View { .labelsHidden() .frame(maxWidth: 200, alignment: .leading) + case .stripMetadata(let policy): + Picker("", selection: Binding( + get: { policy }, + set: { action.operation = .stripMetadata(policy: $0) } + )) { + ForEach(PrivacyPolicy.allCases.filter(\.removesSomething), id: \.self) { + Text($0.title).tag($0) + } + } + .labelsHidden() + .frame(maxWidth: 260, alignment: .leading) + case .limitSize(let bytes): HStack(spacing: 6) { TextField("Megabytes", text: Binding( diff --git a/Sources/Forge/Views/PresetsView.swift b/Sources/Forge/Views/PresetsView.swift index 0d70c27..be337df 100644 --- a/Sources/Forge/Views/PresetsView.swift +++ b/Sources/Forge/Views/PresetsView.swift @@ -173,6 +173,8 @@ struct PresetCard: View { case let (nil, height?): return "\(height) tall" case (nil, nil): return "resize" } + case .stripMetadata(let policy): + return policy == .stripLocation ? "no location" : "no metadata" case .quality(let level): return "Q\(level)" case .filter(let type): diff --git a/Sources/Forge/Views/RedactionReviewSheet.swift b/Sources/Forge/Views/RedactionReviewSheet.swift new file mode 100644 index 0000000..fa45704 --- /dev/null +++ b/Sources/Forge/Views/RedactionReviewSheet.swift @@ -0,0 +1,241 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +/// The review a redaction cannot happen without. +/// +/// What a detector found is shown as boxes over the picture, all of them off. +/// Ticking one is the only thing that puts it in the file. The sentence saying +/// so stays on the screen, because the difference between "Forge found these" +/// and "this file is anonymous" is the whole of the honesty here. +struct RedactionReviewSheet: View { + let file: ProcessableFile + let onFinish: (URL) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var session: RedactionSession? + @State private var failure: String? + @State private var working = true + @State private var applying = false + + private let scout = RedactionScout() + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + content + Divider() + footer + } + .frame(width: 720, height: 620) + .task { await look() } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Review before covering").font(.title3.weight(.semibold)) + Text( + "Forge found what it could in \(file.fileName). It misses things — a face turned away, " + + "handwriting, a name in a reflection — so this is a starting point, not a guarantee " + + "that the file is anonymous. Nothing is covered until you tick it." + ) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var content: some View { + if working { + centred { ProgressView("Looking through the picture…") } + } else if let failure { + centred { + Text(failure).font(.callout).foregroundStyle(.orange).multilineTextAlignment(.center) + } + } else if let session { + HStack(spacing: 0) { + picture(session) + Divider() + list(session) + } + } + } + + private func picture(_ session: RedactionSession) -> some View { + GeometryReader { geometry in + ZStack(alignment: .topLeading) { + if let preview = PreviewRun.thumbnail(of: file.url, maximumPixels: 900) { + Image(nsImage: preview) + .resizable() + .scaledToFit() + .frame(width: geometry.size.width, height: geometry.size.height) + } + boxes(session, in: drawnFrame(in: geometry.size)) + } + } + .frame(minWidth: 380) + .padding(12) + } + + /// Where the picture actually ends up inside the space it was given: the + /// boxes have to sit on the picture, not on the view. + private func drawnFrame(in space: CGSize) -> CGRect { + guard let size = file.dimensions, size.width > 0, size.height > 0 else { + return CGRect(origin: .zero, size: space) + } + let ratio = CGFloat(size.width) / CGFloat(size.height) + var drawn = CGSize(width: space.width, height: space.width / ratio) + if drawn.height > space.height { + drawn = CGSize(width: space.height * ratio, height: space.height) + } + return CGRect( + x: (space.width - drawn.width) / 2, + y: (space.height - drawn.height) / 2, + width: drawn.width, + height: drawn.height + ) + } + + private func boxes(_ session: RedactionSession, in frame: CGRect) -> some View { + ForEach(session.candidates) { candidate in + let on = session.isConfirmed(candidate) + // Vision measures from the bottom left; the screen measures from the top. + Rectangle() + .fill(on ? Color.black.opacity(0.82) : Color.accentColor.opacity(0.12)) + .overlay(Rectangle().strokeBorder(on ? Color.black : Color.accentColor, lineWidth: 1.5)) + .frame( + width: candidate.rect.width * frame.width, + height: candidate.rect.height * frame.height + ) + .offset( + x: frame.minX + candidate.rect.minX * frame.width, + y: frame.minY + (1 - candidate.rect.maxY) * frame.height + ) + .onTapGesture { toggle(candidate) } + } + } + + private func list(_ session: RedactionSession) -> some View { + VStack(alignment: .leading, spacing: 0) { + if session.candidates.isEmpty { + centred { + Text("Forge found nothing it recognises here. That is not the same as there being nothing.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(12) + } + } else { + List { + ForEach(session.candidates) { candidate in + Toggle(isOn: Binding( + get: { session.isConfirmed(candidate) }, + set: { _ in toggle(candidate) } + )) { + HStack(spacing: 8) { + Image(systemName: candidate.kind.symbol).foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 1) { + Text(candidate.kind.title).font(.callout) + if let text = candidate.text { + Text(text) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + } + } + } + .listStyle(.inset) + } + } + .frame(width: 260) + } + + private var footer: some View { + HStack(spacing: 12) { + if let session { + Picker("", selection: Binding( + get: { session.style }, + set: { style in self.session?.style = style } + )) { + ForEach(RedactionStyle.allCases, id: \.self) { Text($0.title).tag($0) } + } + .labelsHidden() + .fixedSize() + + Text(count(session)).font(.callout).foregroundStyle(.secondary) + } + + Spacer() + + Button("Cancel", role: .cancel) { dismiss() } + .keyboardShortcut(.cancelAction) + Button(applying ? "Writing…" : "Cover and Save a Copy…") { save() } + .buttonStyle(.borderedProminent) + .disabled(session?.hasSomethingToDo != true || applying) + } + .padding(16) + } + + private func count(_ session: RedactionSession) -> String { + let confirmed = session.confirmedRegions.count + guard confirmed > 0 else { return "Nothing ticked, so nothing will be covered." } + return confirmed == 1 + ? "1 region will be covered, permanently, in the copy." + : "\(confirmed) regions will be covered, permanently, in the copy." + } + + private func centred(@ViewBuilder _ content: () -> Content) -> some View { + content().frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func toggle(_ candidate: RedactionCandidate) { + session?.toggle(candidate) + } + + private func look() async { + do { + let found = try await scout.candidates(in: file.url) + session = RedactionSession(source: file.url, candidates: found) + } catch { + failure = error.localizedDescription + } + working = false + } + + /// A copy, always, and to a place the user chose. Burning a redaction into + /// the original would be the one edit in Forge that cannot be undone. + private func save() { + guard let session, session.hasSomethingToDo else { return } + + let panel = NSSavePanel() + panel.nameFieldStringValue = (file.fileName as NSString).deletingPathExtension + "-covered.png" + panel.allowedContentTypes = [.png, .jpeg] + panel.message = "Where should the covered copy go?" + guard panel.runModal() == .OK, let destination = panel.url else { return } + + applying = true + Task { + defer { applying = false } + do { + try Redactor.apply( + session.confirmedRegions, + style: session.style, + to: session.source, + writing: destination + ) + onFinish(destination) + dismiss() + } catch { + failure = error.localizedDescription + } + } + } +} diff --git a/Sources/Forge/Views/SettingsView.swift b/Sources/Forge/Views/SettingsView.swift index 05bfc65..901f0a4 100644 --- a/Sources/Forge/Views/SettingsView.swift +++ b/Sources/Forge/Views/SettingsView.swift @@ -23,6 +23,12 @@ struct SettingsView: View { Stepper(value: $model.settings.defaultQuality, in: 1...100) { LabeledContent("Quality when a preset says none", value: "\(model.settings.defaultQuality)") } + Picker("Metadata", selection: $model.settings.privacy) { + ForEach(PrivacyPolicy.allCases, id: \.self) { Text($0.title).tag($0) } + } + Text(model.settings.privacy.summary + " A preset, a batch, or a file named _privacy can ask for more.") + .font(.caption) + .foregroundStyle(.secondary) TextField("Name new files", text: $model.settings.nameTemplate) Text("{name} is the original's name. A preset that asks a question adds its own token, so {name}_{maxsize} writes holiday_10MB.jpg. Any preset can override all of this.") .font(.caption) diff --git a/Tests/ForgeTests/Processors/test_Privacy.swift b/Tests/ForgeTests/Processors/test_Privacy.swift new file mode 100644 index 0000000..afb3297 --- /dev/null +++ b/Tests/ForgeTests/Processors/test_Privacy.swift @@ -0,0 +1,294 @@ +import XCTest +import ImageIO +import PDFKit +import UniformTypeIdentifiers +@testable import Forge + +/// What a converted file stops saying about the person who made it, and what it +/// must go on saying about itself. +/// +/// The second half is the one that breaks things: an image without its +/// orientation arrives on its side, and one without its colour profile arrives +/// the wrong colour. Both are asserted on every strip. +final class PrivacyTests: BaseTestCase { + + // MARK: - Images + + func test_stripLocation_takesTheGPSAndLeavesTheRest() async throws { + let source = try photograph(at: path("holiday.jpg")) + let output = try await convert(source, policy: .stripLocation) + + let properties = try Self.properties(of: output) + XCTAssertNil(properties[kCGImagePropertyGPSDictionary], "where it was taken is gone") + + let tiff = try XCTUnwrap(properties[kCGImagePropertyTIFFDictionary] as? [CFString: Any]) + XCTAssertEqual(tiff[kCGImagePropertyTIFFMake] as? String, "Fake", "and nothing else was touched") + } + + func test_stripAll_takesTheDeviceAndTheMomentTo() async throws { + let source = try photograph(at: path("holiday.jpg")) + let output = try await convert(source, policy: .stripAll) + + let properties = try Self.properties(of: output) + XCTAssertNil(properties[kCGImagePropertyGPSDictionary]) + + let tiff = properties[kCGImagePropertyTIFFDictionary] as? [CFString: Any] ?? [:] + XCTAssertNil(tiff[kCGImagePropertyTIFFMake], "which camera took it is not the picture") + XCTAssertNil(tiff[kCGImagePropertyTIFFModel]) + + let exif = properties[kCGImagePropertyExifDictionary] as? [CFString: Any] ?? [:] + XCTAssertNil(exif[kCGImagePropertyExifDateTimeOriginal], "when it was taken is not the picture either") + XCTAssertNil(exif[kCGImagePropertyExifBodySerialNumber]) + } + + /// The critical one. A file that has been made anonymous and unreadable is + /// not a success. + func test_stripAll_keepsTheOrientationAndTheColourProfile() async throws { + let source = try photograph(at: path("holiday.jpg")) + let output = try await convert(source, policy: .stripAll) + + let properties = try Self.properties(of: output) + XCTAssertEqual( + properties[kCGImagePropertyOrientation] as? Int, 6, + "without this the photograph arrives on its side" + ) + + let image = try XCTUnwrap(Self.image(at: output)) + XCTAssertNotNil(image.colorSpace, "without a profile the colours are a guess") + } + + /// Keeping everything is what a conversion does when nobody asked otherwise. + func test_keepAll_carriesTheLocationAcross() async throws { + let source = try photograph(at: path("holiday.jpg")) + let output = try await convert(source, policy: nil) + + let properties = try Self.properties(of: output) + XCTAssertNotNil(properties[kCGImagePropertyGPSDictionary]) + } + + /// The filter is a pure function over the properties, so the rule can be read + /// without writing a file at all. + func test_filter_isDecidedByLists_notByFormat() { + let properties: [CFString: Any] = [ + kCGImagePropertyGPSDictionary: ["Latitude": 45.44], + kCGImagePropertyOrientation: 6, + kCGImagePropertyProfileName: "sRGB IEC61966-2.1", + "{MakerApple}" as CFString: ["3": 1], + kCGImagePropertyIPTCDictionary: ["Byline": "Somebody"], + ] + + let located = PrivacyFilter.filter(properties, to: .stripLocation) + XCTAssertNil(located[kCGImagePropertyGPSDictionary]) + XCTAssertNotNil(located["{MakerApple}" as CFString], "the location, and only the location") + + let stripped = PrivacyFilter.filter(properties, to: .stripAll) + XCTAssertNil( + stripped["{MakerApple}" as CFString], + "a maker note is named after its camera; the shape of the key is the rule" + ) + XCTAssertNil(stripped[kCGImagePropertyIPTCDictionary]) + XCTAssertEqual(stripped[kCGImagePropertyOrientation] as? Int, 6) + XCTAssertNotNil(stripped[kCGImagePropertyProfileName]) + } + + // MARK: - PDF + + func test_pdf_losesItsAuthorButKeepsItsPages() throws { + let source = try Self.pdf(at: path("contract.pdf"), author: "Somebody Real", title: "Contract") + let before = try XCTUnwrap(PDFDocument(url: source)) + XCTAssertEqual( + before.documentAttributes?[PDFDocumentAttribute.authorAttribute] as? String, "Somebody Real" + ) + + try PrivacyFilter.strip(pdfAt: source, to: .stripAll) + + let after = try XCTUnwrap(PDFDocument(url: source)) + XCTAssertNil(after.documentAttributes?[PDFDocumentAttribute.authorAttribute]) + XCTAssertNil(after.documentAttributes?[PDFDocumentAttribute.creatorAttribute]) + XCTAssertEqual(after.pageCount, before.pageCount, "the document is still the document") + XCTAssertEqual( + after.documentAttributes?[PDFDocumentAttribute.titleAttribute] as? String, "Contract", + "what it is called is not who wrote it" + ) + } + + /// A PDF has no location, so asking for the location to go changes nothing. + func test_pdf_isLeftAloneWhenOnlyTheLocationWasAskedAbout() throws { + let source = try Self.pdf(at: path("contract.pdf"), author: "Somebody Real", title: "Contract") + try PrivacyFilter.strip(pdfAt: source, to: .stripLocation) + + let after = try XCTUnwrap(PDFDocument(url: source)) + XCTAssertEqual( + after.documentAttributes?[PDFDocumentAttribute.authorAttribute] as? String, "Somebody Real" + ) + } + + // MARK: - The name + + func test_nameTokens_readBothInstructions() { + XCTAssertEqual( + NameTokens.read("holiday_10MB_privacy.jpg"), + NameTokens.Read(ceiling: 10_000_000, privacy: .stripAll), + "one name, two instructions - reading only the last piece hid the size" + ) + XCTAssertEqual( + NameTokens.read("contract_privacy.pdf"), + NameTokens.Read(ceiling: nil, privacy: .stripAll) + ) + XCTAssertEqual(NameTokens.read("holiday_10MB.jpg"), NameTokens.Read(ceiling: 10_000_000, privacy: nil)) + XCTAssertEqual(NameTokens.read("holiday.jpg"), NameTokens.Read()) + } + + /// The old rules still hold: an instruction is at the end of the name, and a + /// size that is the subject of the name is not an instruction. + func test_nameTokens_stopAtTheFirstPieceThatIsNotAnInstruction() { + XCTAssertNil(NameTokens.read("10MB_notes.jpg").ceiling) + XCTAssertNil(NameTokens.read("privacy.pdf").privacy, "a file called privacy is a file called privacy") + XCTAssertEqual(NameTokens.read("holiday_2024_5MB.jpg").ceiling, 5_000_000) + XCTAssertNil(NameTokens.read("report_final.pdf").ceiling) + } + + func test_nameTokens_putBothStepsInTheChain() { + let chain = NameTokens.applying(to: [.convertFormat(to: .jpeg)], from: "holiday_10MB_privacy.jpg") + + XCTAssertTrue(chain.contains(.limitSize(bytes: 10_000_000))) + XCTAssertTrue(chain.contains(.stripMetadata(policy: .stripAll))) + XCTAssertTrue(chain.contains(.convertFormat(to: .jpeg)), "and what was already asked for stays") + } + + /// Typed by the user, so it is their instruction, not a surprise to confirm + /// back at them. + func test_privacyToken_isExplicitSoNothingIsAsked() throws { + let file = try ProcessableFile(url: try Fixture.image(at: path("holiday_privacy.png"))) + let plan = ConversionPlan( + file: file, + preset: .make(format: .jpeg, category: .image), + destinationMode: .copyTo + ) + XCTAssertEqual(plan.confirmationLevel, .silent) + + let both = try ProcessableFile(url: try Fixture.image(at: path("holiday_10MB_privacy.png"))) + let bothPlan = ConversionPlan( + file: both, + preset: .make(format: .jpeg, category: .image), + destinationMode: .copyTo + ) + XCTAssertTrue(bothPlan.magicWasExplicit, "the size is still read when a second token follows it") + XCTAssertEqual(bothPlan.confirmationLevel, .silent) + } + + // MARK: - Who wins + + /// General, then specific, then this once, then the name. + func test_precedence_settingsThenPresetThenName() { + var settings = AppSettings() + settings.privacy = .stripLocation + + // Settings alone: filled in wherever the chain says nothing. + let fromSettings = settings.applyingDefaults(to: [.convertFormat(to: .jpeg)], writing: .jpeg) + XCTAssertEqual(PrivacyFilter.policy(in: fromSettings), .stripLocation) + + // A preset - or a batch, which builds the same chain - is not overruled by + // the preference, in either direction. + let fromPreset = settings.applyingDefaults( + to: [.convertFormat(to: .jpeg), .stripMetadata(policy: .keepAll)], writing: .jpeg + ) + XCTAssertEqual(PrivacyFilter.policy(in: fromPreset), .keepAll, "a preset can also ask for less") + + // And the name beats all of it. + let fromName = NameTokens.applying(to: fromPreset, from: "holiday_privacy.jpg") + XCTAssertEqual(PrivacyFilter.policy(in: fromName), .stripAll) + XCTAssertEqual( + fromName.filter { if case .stripMetadata = $0 { return true } else { return false } }.count, 1, + "one answer, not two" + ) + } + + // MARK: - What the sheet shows + + /// Picking a preset fills the fields in from it - and clears the ones it says + /// nothing about. + /// + /// Leaving the last preset's answer behind is not only a wrong-looking chip: + /// touching any other field lets the preset go, and then that stale answer is + /// what actually runs. + func test_choosingAPreset_clearsWhatTheLastOneSaid() { + var thorough = RulePreset.make(format: .jpeg, category: .image) + thorough.actions.append(.stripMetadata(policy: .stripAll)) + thorough.actions.append(.limitSize(bytes: 5_000_000)) + + let choice = ConvertChoice().adopting(thorough) + XCTAssertEqual(choice.privacy, .stripAll) + XCTAssertEqual(choice.maxBytes, 5_000_000) + + let plain = RulePreset.make(format: .png, category: .image) + let after = choice.adopting(plain) + + XCTAssertNil(after.privacy, "this preset says nothing about metadata, so neither does the sheet") + XCTAssertNil(after.maxBytes, "and nothing about a size ceiling either") + XCTAssertEqual(after.presetID, plain.id) + } + + // MARK: - Fixtures + + /// A JPEG with the things a camera writes: where, what, when, and which way + /// up. + private func photograph(at url: URL) throws -> URL { + try Fixture.image(at: url, metadata: [ + kCGImagePropertyOrientation: 6, + kCGImagePropertyGPSDictionary: [ + kCGImagePropertyGPSLatitude: 45.4408, + kCGImagePropertyGPSLongitude: 12.3155, + ] as [CFString: Any], + kCGImagePropertyTIFFDictionary: [ + kCGImagePropertyTIFFMake: "Fake", + kCGImagePropertyTIFFModel: "Camera One", + ] as [CFString: Any], + kCGImagePropertyExifDictionary: [ + kCGImagePropertyExifDateTimeOriginal: "2026:09:05 12:00:00", + kCGImagePropertyExifBodySerialNumber: "SN-000000", + ] as [CFString: Any], + ]) + } + + /// Convert it the way the app would, with the policy in the chain. + private func convert(_ source: URL, policy: PrivacyPolicy?) async throws -> URL { + var preset = RulePreset.make(format: .jpeg, quality: 90, category: .image) + if let policy { preset.actions.append(.stripMetadata(policy: policy)) } + + let entry = try await coordinator().processFile( + try ProcessableFile(url: source), + with: preset, + destinationMode: .copyTo, + destinationURL: try folder("out") + ) { _ in } + + XCTAssertEqual(entry.status, .completed) + return try XCTUnwrap(entry.outputURL) + } + + private static func properties(of url: URL) throws -> [CFString: Any] { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] else { + throw Failure("Cannot read \(url.lastPathComponent)") + } + return properties + } + + private static func image(at url: URL) -> CGImage? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } + return CGImageSourceCreateImageAtIndex(source, 0, nil) + } + + private static func pdf(at url: URL, author: String, title: String) throws -> URL { + let document = PDFDocument() + document.insert(PDFPage(), at: 0) + document.documentAttributes = [ + PDFDocumentAttribute.authorAttribute: author, + PDFDocumentAttribute.creatorAttribute: "Something That Writes PDFs", + PDFDocumentAttribute.titleAttribute: title, + ] + guard document.write(to: url) else { throw Failure("Cannot write the PDF fixture") } + return url + } +} diff --git a/Tests/ForgeTests/Processors/test_Redaction.swift b/Tests/ForgeTests/Processors/test_Redaction.swift new file mode 100644 index 0000000..2013838 --- /dev/null +++ b/Tests/ForgeTests/Processors/test_Redaction.swift @@ -0,0 +1,202 @@ +import XCTest +import CoreGraphics +import CoreText +import ImageIO +import UniformTypeIdentifiers +@testable import Forge + +/// Assisted redaction: what was suggested, what a person confirmed, and the gap +/// between the two. +/// +/// The gap is the point. A detector's opinion never reaches a file on its own, +/// and most of what is here is that rule holding. +final class RedactionTests: BaseTestCase { + + // MARK: - Nothing happens without a person + + func test_session_startsWithNothingConfirmed() { + let session = RedactionSession(source: path("scan.png"), candidates: [ + RedactionCandidate(rect: CGRect(x: 0.1, y: 0.1, width: 0.2, height: 0.2), kind: .face), + RedactionCandidate(rect: CGRect(x: 0.5, y: 0.5, width: 0.2, height: 0.1), kind: .text), + ]) + + XCTAssertEqual(session.candidates.count, 2) + XCTAssertTrue(session.confirmedRegions.isEmpty, "finding something is not agreeing to cover it") + XCTAssertFalse(session.hasSomethingToDo) + } + + func test_session_confirmsOneAtATime() { + var session = RedactionSession(source: path("scan.png"), candidates: [ + RedactionCandidate(rect: CGRect(x: 0.1, y: 0.1, width: 0.2, height: 0.2), kind: .face), + RedactionCandidate(rect: CGRect(x: 0.5, y: 0.5, width: 0.2, height: 0.1), kind: .text), + ]) + + session.toggle(session.candidates[0]) + XCTAssertEqual(session.confirmedRegions.count, 1) + XCTAssertEqual(session.confirmedRegions.first?.origin.x, 0.1) + + session.toggle(session.candidates[0]) + XCTAssertTrue(session.confirmedRegions.isEmpty, "and it can be taken back") + } + + /// A region somebody drew is confirmed by the drawing of it. + func test_session_takesARegionDrawnByHand() { + var session = RedactionSession(source: path("scan.png"), candidates: []) + session.add(CGRect(x: 0.2, y: 0.2, width: 0.3, height: 0.3)) + + XCTAssertEqual(session.candidates.count, 1) + XCTAssertEqual(session.confirmedRegions.count, 1) + } + + /// The hard rule, at the only place that could break it. + func test_redactor_refusesToWriteAnythingWithNothingConfirmed() throws { + let source = try Fixture.image(at: path("scan.png")) + let output = path("covered.png") + + XCTAssertThrowsError( + try Redactor.apply([], style: .blackout, to: source, writing: output), + "an empty confirmation must produce nothing at all" + ) + XCTAssertFalse(exists(output)) + } + + // MARK: - What actually reaches the file + + func test_redactor_coversTheConfirmedRegionAndNothingElse() throws { + let source = try Fixture.image(at: path("scan.png"), width: 200, height: 200) + let output = path("covered.png") + + // The bottom left quarter, in Vision's coordinates. + try Redactor.apply( + [CGRect(x: 0, y: 0, width: 0.5, height: 0.5)], + style: .blackout, + to: source, + writing: output + ) + + XCTAssertTrue(exists(output)) + // A CGImage is measured from the top, so the bottom left quarter is what + // anybody looking at the picture would call the bottom left. + XCTAssertEqual(try Self.pixel(of: output, x: 40, y: 160), Self.black, "the confirmed region is covered") + XCTAssertNotEqual(try Self.pixel(of: output, x: 160, y: 40), Self.black, "and the rest of it is not") + } + + func test_redactor_neverTouchesTheOriginal() throws { + let source = try Fixture.image(at: path("scan.png"), width: 120, height: 120) + let before = size(of: source) + let untouched = try Self.pixel(of: source, x: 10, y: 110) + + try Redactor.apply( + [CGRect(x: 0, y: 0, width: 1, height: 1)], + style: .blackout, + to: source, + writing: path("covered.png") + ) + + XCTAssertEqual(size(of: source), before) + XCTAssertEqual(try Self.pixel(of: source, x: 10, y: 110), untouched) + } + + func test_redactor_pixellatesTheRegionAndLeavesTheRest() throws { + let source = try Fixture.image(at: path("scan.png"), width: 200, height: 200) + let output = path("covered.png") + + try Redactor.apply( + [CGRect(x: 0, y: 0, width: 0.5, height: 0.5)], + style: .pixellate, + to: source, + writing: output + ) + + XCTAssertTrue(exists(output)) + XCTAssertNotEqual( + try Self.pixel(of: output, x: 20, y: 180), + try Self.pixel(of: source, x: 20, y: 180), + "the covered corner is not the corner that was there" + ) + } + + // MARK: - What gets suggested + + /// Natural Language reading a line for what it is about, which is what turns + /// "text" into "somebody's name". + func test_entities_areReadOutOfRecognisedText() { + XCTAssertEqual(RedactionScout.entity(in: "Eugenio Zamengo"), "Name") + XCTAssertNil(RedactionScout.entity(in: "1024 x 768"), "a number is not a person") + } + + /// Vision on a picture with words in it. Skipped rather than failed where the + /// machine's text recognition does not answer: what is under test is what + /// Forge does with what it gets. + func test_scout_findsTheTextItCanRead() async throws { + let source = try Self.page(at: path("letter.png"), saying: "Eugenio Zamengo") + let found = try await RedactionScout().candidates(in: source) + + try XCTSkipIf(found.isEmpty, "text recognition found nothing on this machine") + XCTAssertTrue( + found.allSatisfy { $0.rect.width > 0 && $0.rect.height > 0 }, + "a candidate with no area is a box nobody can tick" + ) + XCTAssertTrue(found.contains { $0.text?.isEmpty == false }) + } + + // MARK: - Fixtures + + private static let black: [UInt8] = [0, 0, 0] + + /// A white page with a line of text drawn on it. + private static func page(at url: URL, saying words: String) throws -> URL { + let width = 600 + let height = 200 + guard let context = CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + throw Failure("Cannot make a page") + } + + context.setFillColor(CGColor(red: 1, green: 1, blue: 1, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + + let line = CTLineCreateWithAttributedString(NSAttributedString( + string: words, + attributes: [ + .font: CTFontCreateWithName("Helvetica" as CFString, 48, nil), + .foregroundColor: CGColor(red: 0, green: 0, blue: 0, alpha: 1), + ] + )) + context.textPosition = CGPoint(x: 40, y: 80) + CTLineDraw(line, context) + + guard let image = context.makeImage(), + let destination = CGImageDestinationCreateWithURL( + url as CFURL, UTType.png.identifier as CFString, 1, nil + ) else { + throw Failure("Cannot write the page") + } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { throw Failure("Cannot finish the page") } + return url + } + + /// One pixel's red, green and blue, measured from the top left. + private static func pixel(of url: URL, x: Int, y: Int) throws -> [UInt8] { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { + throw Failure("Cannot read \(url.lastPathComponent)") + } + + var pixel = [UInt8](repeating: 0, count: 4) + guard let context = CGContext( + data: &pixel, width: 1, height: 1, bitsPerComponent: 8, bytesPerRow: 4, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + throw Failure("Cannot sample \(url.lastPathComponent)") + } + context.draw( + image, + in: CGRect(x: -x, y: y - image.height + 1, width: image.width, height: image.height) + ) + return Array(pixel.prefix(3)) + } +}