Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Sources/Forge/CLI/Commands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand Down
26 changes: 22 additions & 4 deletions Sources/Forge/Models/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Sources/Forge/Models/ConfirmationLevel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
72 changes: 72 additions & 0 deletions Sources/Forge/Models/NameTokens.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
18 changes: 16 additions & 2 deletions Sources/Forge/Models/Operation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"
}
}

Expand All @@ -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
}
}

Expand All @@ -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"
}
}
}
Expand All @@ -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 {
Expand All @@ -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))
}
}

Expand Down Expand Up @@ -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)
}
}
}
Expand Down
54 changes: 54 additions & 0 deletions Sources/Forge/Models/PrivacyPolicy.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
121 changes: 121 additions & 0 deletions Sources/Forge/Models/Redaction.swift
Original file line number Diff line number Diff line change
@@ -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<UUID> = []
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 }
}
Loading
Loading