Give mixed selections something to do

A Drag of different kinds showed an empty wheel, because the catalog only
answers for a single category. Filee's answer is a mixed profile, so add
Zip (one archive, flat names) and Merge (non-PDFs are converted to PDF
first); a multi-file drag of one kind gets Zip too.
This commit is contained in:
2026-10-20 01:30:42 +08:00
parent 2d031e58a4
commit a01d5def89
8 changed files with 295 additions and 41 deletions
+16 -1
View File
@@ -9,7 +9,7 @@ drag machinery or the conversion catalog.
```bash ```bash
swift build # debug swift build # debug
swift build -c release swift build -c release
./.build/debug/Konverter --self-test # 41 checks, exits non-zero on failure ./.build/debug/Konverter --self-test # 46 checks, exits non-zero on failure
./.build/debug/Konverter --convert <files…> --to <ext> ./.build/debug/Konverter --convert <files…> --to <ext>
./.build/debug/Konverter --convert <file> --tool <toolID> --param k=v ./.build/debug/Konverter --convert <file> --tool <toolID> --param k=v
./build-app.sh <version> # → Konverter.app (signed) ./build-app.sh <version> # → Konverter.app (signed)
@@ -119,6 +119,21 @@ maths, update those expectations rather than deleting them.
- Optional engines must always degrade, never crash: absence of ffmpeg or - Optional engines must always degrade, never crash: absence of ffmpeg or
LibreOffice should surface as an unavailable wheel item with a reason, and LibreOffice should surface as an unavailable wheel item with a reason, and
`Toolchain.missingToolAdvice` supplies the `brew install …` line for the UI. `Toolchain.missingToolAdvice` supplies the `brew install …` line for the UI.
- **Mixed selections must never produce an empty wheel.** `WheelBuilder` falls
back to `ToolID.combineTools(for:)` (Zip, Merge) whenever a drag has no single
category, and appends Zip to every multi-file tools wheel. `mergePDF` converts
non-PDF inputs through `Converter.convert(_:to:.pdf)` into a temp folder, so
it accepts photos and documents, not just PDFs. If you add a tool that works
across kinds, add it to `combineTools` and cover it in the self-test.
## Reference material
Research on the apps this project is modelled on lives in an untracked
`.reference/` folder (`.reference/.gitignore` contains `*`, so nothing there is
ever committed). Currently: `tangerine.md` (the closed-source app we clone),
`filee.md` + a shallow clone of `KnifeLemon/Filee` (MIT, the open-source
sibling), and `README.md` indexing them. Read `filee.md` before designing a
feature that Filee already has — the "borrow next" list there is the roadmap.
## Build/bundle notes ## Build/bundle notes
+14 -3
View File
@@ -23,6 +23,9 @@ Everything runs locally — no uploads, no accounts, no analytics.
Dropping on the dead zone in the middle, pressing Escape, or dropping the Dropping on the dead zone in the middle, pressing Escape, or dropping the
file anywhere else cancels and lets Finder do its normal thing. file anywhere else cancels and lets Finder do its normal thing.
Mixed selections still work: **Zip** packs the selection into one archive and
**Merge** turns it into one PDF (non-PDFs are converted first).
The wheel also works without dragging: the menu bar item and the Finder Quick The wheel also works without dragging: the menu bar item and the Finder Quick
Action **Convert with Konverter…** open the same options as a menu. Action **Convert with Konverter…** open the same options as a menu.
@@ -55,7 +58,7 @@ finished, sound, use of external tools, and open at login. Stored in
| Subtitle | SRT, VTT, TXT | | Subtitle | SRT, VTT, TXT |
| Archive / folder | ZIP, TAR, GZIP | | Archive / folder | ZIP, TAR, GZIP |
Plus 38 tools: Plus 39 tools:
| Group | Tools | | Group | Tools |
| --- | --- | | --- | --- |
@@ -66,9 +69,17 @@ Plus 38 tools:
| Document | To PDF, Statistics | | Document | To PDF, Statistics |
| Spreadsheet | To CSV | | Spreadsheet | To CSV |
| Subtitle | Shift Time | | Subtitle | Shift Time |
| Archive | Extract, List Contents | | Archive | Extract, List Contents, Zip |
| Any | Info | | Any | Info |
### Mixed selections
Dragging files of different kinds still shows a wheel instead of nothing:
**Zip** packs the whole selection into one archive (flat names, like Finder's
*Compress*), and **Merge** turns everything into one PDF — photos, documents
and existing PDFs are converted to PDF first, then combined. A multi-file drag
of a single kind gets **Zip** in its tools wheel too.
### Engines and fallbacks ### Engines and fallbacks
| Layer | Used for | Ships with macOS | | Layer | Used for | Ships with macOS |
@@ -112,7 +123,7 @@ Requires Xcode (Swift 6 toolchain) and macOS 13+.
swift build # debug binary swift build # debug binary
swift build -c release swift build -c release
./.build/debug/Konverter --self-test # 41 checks over every engine ./.build/debug/Konverter --self-test # 46 checks over every engine
./.build/debug/Konverter --convert photo.heic --to png ./.build/debug/Konverter --convert photo.heic --to png
./.build/debug/Konverter --convert clip.mov --tool trimVideo \ ./.build/debug/Konverter --convert clip.mov --tool trimVideo \
--param start=0:02 --param end=0:05 --param start=0:02 --param end=0:05
@@ -58,6 +58,67 @@ enum ArchiveConverter {
} }
} }
/// Packs a whole selection into one ZIP, the way Finder's *Compress* does.
/// Inputs that live in the same folder keep their relative names; a
/// selection spanning folders is staged through symlinks so the archive
/// still has flat, readable entries instead of absolute paths.
static func createArchive(
from inputs: [URL],
at destination: URL,
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws {
guard !inputs.isEmpty else {
throw ConversionError.unsupported("There is nothing to archive.")
}
try cancellation?.check()
progress?(0.1)
let folders = Set(inputs.map { $0.deletingLastPathComponent().path })
if folders.count == 1, let folder = inputs.first?.deletingLastPathComponent() {
try Shell.run(
Toolchain.zip,
["-r", "-X", destination.path] + inputs.map { $0.lastPathComponent },
currentDirectory: folder,
cancellation: cancellation
)
progress?(1)
return
}
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("konverter-zip-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: staging) }
var names: [String] = []
for input in inputs {
var name = input.lastPathComponent
var index = 2
while names.contains(name) {
let ext = input.pathExtension
let base = input.deletingPathExtension().lastPathComponent
name = ext.isEmpty ? "\(base) \(index)" : "\(base) \(index).\(ext)"
index += 1
}
names.append(name)
try FileManager.default.createSymbolicLink(
at: staging.appendingPathComponent(name),
withDestinationURL: input
)
}
// `zip` follows symlinks unless asked for `-y`, so the archive holds
// the real files rather than links to them.
try Shell.run(
Toolchain.zip,
["-r", "-X", destination.path] + names,
currentDirectory: staging,
cancellation: cancellation
)
progress?(1)
}
/// Expands an archive into `directory`, returning the folder that callers /// Expands an archive into `directory`, returning the folder that callers
/// should reveal. /// should reveal.
@discardableResult @discardableResult
@@ -117,10 +178,10 @@ enum ArchiveConverter {
case "tar", "tgz": case "tar", "tgz":
return try Shell.run(Toolchain.tar, ["-tf", source.path]).stdout return try Shell.run(Toolchain.tar, ["-tf", source.path]).stdout
case "gz": case "gz":
let size = (try? FileManager.default.attributesOfItem(atPath: source.path)[.size] as? Int) ?? 0 let size = (try? FileManager.default.attributesOfItem(atPath: source.path))?[.size] as? Int ?? 0
return """ return """
GZIP stream GZIP stream
Compressed size: \(ByteCountFormatter.string(fromByteCount: Int64(size ?? 0), countStyle: .file)) Compressed size: \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))
Inner name: \(source.deletingPathExtension().lastPathComponent) Inner name: \(source.deletingPathExtension().lastPathComponent)
""" """
default: default:
+46 -1
View File
@@ -207,7 +207,37 @@ enum ToolRunner {
for: first.deletingLastPathComponent() for: first.deletingLastPathComponent()
.appendingPathComponent(first.deletingPathExtension().lastPathComponent + " merged.pdf") .appendingPathComponent(first.deletingPathExtension().lastPathComponent + " merged.pdf")
) )
try PDFConverter.merge(inputs, at: destination, cancellation: cancellation) // A mixed selection is merged by turning each non-PDF into a PDF
// first, so "3 photos + 2 PDFs" is one drop instead of two jobs.
var staged: [URL] = []
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("konverter-merge-\(UUID().uuidString)", isDirectory: true)
defer { if !staged.isEmpty { try? FileManager.default.removeItem(at: staging) } }
var sources: [URL] = []
for (index, input) in inputs.enumerated() {
try cancellation?.check()
if FileKind.category(of: input) == .pdf {
sources.append(input)
continue
}
if staged.isEmpty {
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
}
let name = String(format: "%03d", index) + "-"
+ input.deletingPathExtension().lastPathComponent + ".pdf"
let temp = staging.appendingPathComponent(name)
try Converter.convert(
source: input,
to: .pdf,
at: temp,
cancellation: cancellation,
progress: { fraction in progress?(fraction * 0.5) }
)
staged.append(temp)
sources.append(temp)
}
try PDFConverter.merge(sources, at: destination, cancellation: cancellation)
progress?(1) progress?(1)
return ToolOutcome(outputs: [destination]) return ToolOutcome(outputs: [destination])
@@ -377,6 +407,21 @@ enum ToolRunner {
results.append(MediaInfo.describe(source)) results.append(MediaInfo.describe(source))
} }
return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Info") return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Info")
case .createArchive:
let folder = inputs[0].deletingLastPathComponent()
let name = params.string("name", default: "Archive")
let base = name.hasSuffix(".zip") ? String(name.dropLast(4)) : name
let destination = OutputNaming.availableURL(
for: folder.appendingPathComponent(base).appendingPathExtension("zip")
)
try ArchiveConverter.createArchive(
from: inputs,
at: destination,
cancellation: cancellation,
progress: progress
)
return ToolOutcome(outputs: [destination])
} }
} }
+30 -3
View File
@@ -20,7 +20,7 @@ enum ToolID: String, CaseIterable, Identifiable, Codable {
// Subtitles // Subtitles
case shiftSubtitle case shiftSubtitle
// Archives // Archives
case extractArchive, listArchive case extractArchive, listArchive, createArchive
// Media metadata // Media metadata
case mediaInfo case mediaInfo
@@ -65,6 +65,7 @@ enum ToolID: String, CaseIterable, Identifiable, Codable {
case .shiftSubtitle: return "Shift Time" case .shiftSubtitle: return "Shift Time"
case .extractArchive: return "Extract" case .extractArchive: return "Extract"
case .listArchive: return "List Contents" case .listArchive: return "List Contents"
case .createArchive: return "Zip"
case .mediaInfo: return "Info" case .mediaInfo: return "Info"
} }
} }
@@ -100,6 +101,7 @@ enum ToolID: String, CaseIterable, Identifiable, Codable {
case .shiftSubtitle: return "clock.arrow.circlepath" case .shiftSubtitle: return "clock.arrow.circlepath"
case .extractArchive: return "archivebox" case .extractArchive: return "archivebox"
case .listArchive: return "list.bullet.rectangle" case .listArchive: return "list.bullet.rectangle"
case .createArchive: return "doc.zipper"
} }
} }
@@ -122,7 +124,7 @@ enum ToolID: String, CaseIterable, Identifiable, Codable {
return .spreadsheet return .spreadsheet
case .shiftSubtitle: case .shiftSubtitle:
return .subtitle return .subtitle
case .extractArchive, .listArchive: case .extractArchive, .listArchive, .createArchive:
return .archive return .archive
case .mediaInfo: case .mediaInfo:
return .video return .video
@@ -131,7 +133,10 @@ enum ToolID: String, CaseIterable, Identifiable, Codable {
/// Tools that only make sense for a multi-file selection. /// Tools that only make sense for a multi-file selection.
var requiresMultipleFiles: Bool { var requiresMultipleFiles: Bool {
self == .mergePDF || self == .imagesToPDF || self == .collageImages switch self {
case .mergePDF, .imagesToPDF, .collageImages, .createArchive: return true
default: return false
}
} }
var minimumFileCount: Int { requiresMultipleFiles ? 2 : 1 } var minimumFileCount: Int { requiresMultipleFiles ? 2 : 1 }
@@ -151,6 +156,26 @@ enum ToolID: String, CaseIterable, Identifiable, Codable {
static func tools(for category: FileCategory) -> [ToolID] { static func tools(for category: FileCategory) -> [ToolID] {
allCases.filter { $0.category == category } allCases.filter { $0.category == category }
} }
/// Tools that accept a drag of mixed kinds. When a selection cannot become
/// one format, there is still something useful to do with it: Filee calls
/// this a mixed profile, and it is why its donut is never empty.
static func combineTools(for urls: [URL]) -> [ToolID] {
guard urls.count >= 2 else { return [] }
var tools: [ToolID] = []
if urls.allSatisfy(canBecomePDF) { tools.append(.mergePDF) }
tools.append(.createArchive)
return tools
}
/// Kinds that `Converter` knows how to turn into a PDF, which is what
/// ``mergePDF`` needs from anything that is not a PDF already.
static func canBecomePDF(_ url: URL) -> Bool {
switch FileKind.category(of: url) {
case .image, .pdf, .document, .slides, .spreadsheet, .ebook: return true
default: return false
}
}
} }
/// One editable field in a tool's options form. /// One editable field in a tool's options form.
@@ -256,6 +281,8 @@ extension ToolID {
] ]
case .shiftSubtitle: case .shiftSubtitle:
return [ToolField("offset", "Shift by", .decimal(min: -600, max: 600), default: "1", unit: "s")] return [ToolField("offset", "Shift by", .decimal(min: -600, max: 600), default: "1", unit: "s")]
case .createArchive:
return [ToolField("name", "Archive name", .text, default: "Archive", help: "Saved beside the first file")]
case .listArchive, .extractArchive, .wordCount, .mediaInfo, .readQR, .stripMetadata, case .listArchive, .extractArchive, .wordCount, .mediaInfo, .readQR, .stripMetadata,
.grayscaleImage, .normalizeAudio, .extractPDFText, .documentToPDF, .sheetsToCSV, .grayscaleImage, .normalizeAudio, .extractPDFText, .documentToPDF, .sheetsToCSV,
.muteVideo, .mergePDF, .imagesToPDF: .muteVideo, .mergePDF, .imagesToPDF:
+40 -20
View File
@@ -59,7 +59,12 @@ enum WheelBuilder {
private static func conversionItems(urls: [URL]) -> [WheelItem] { private static func conversionItems(urls: [URL]) -> [WheelItem] {
let categories = urls.map { FileKind.category(of: $0) } let categories = urls.map { FileKind.category(of: $0) }
let formats = ConversionCatalog.outputs(for: categories) let formats = ConversionCatalog.outputs(for: categories)
guard let category = categories.first, !formats.isEmpty else { return [] } // A mixed drag has no single output format, but it can still become one
// file, so the convert wheel offers the combine tools instead of
// vanishing (this is Filee's mixed profile).
guard let category = categories.first, !formats.isEmpty else {
return combineItems(urls: urls)
}
return formats.compactMap { format in return formats.compactMap { format in
guard !ConversionCatalog.isRedundant(format, urls: urls) else { return nil } guard !ConversionCatalog.isRedundant(format, urls: urls) else { return nil }
@@ -77,26 +82,41 @@ enum WheelBuilder {
private static func toolItems(urls: [URL]) -> [WheelItem] { private static func toolItems(urls: [URL]) -> [WheelItem] {
let categories = Set(urls.map { FileKind.category(of: $0) }) let categories = Set(urls.map { FileKind.category(of: $0) })
guard categories.count == 1, let category = categories.first else { return [] } if categories.count == 1, let category = categories.first,
category != .folder, category != .unknown {
return ToolID.tools(for: category).compactMap { tool in var tools = ToolID.tools(for: category)
guard urls.count >= tool.minimumFileCount else { return nil } // Any multi-file selection can be packed into one archive.
var availability = Availability.ok if urls.count >= 2 { tools.append(.createArchive) }
if tool.producesArtifacts, urls.count > 1 { return tools.compactMap { item(for: $0, urls: urls) }
availability = .ok
}
if requiresFFmpeg(tool), !Toolchain.hasFFmpeg {
availability = .unavailable("Needs ffmpeg — brew install ffmpeg")
}
return WheelItem(
id: "tool-\(tool.rawValue)",
payload: .tool(tool),
title: tool.title,
subtitle: availability.isAvailable ? nil : availability.reason,
symbol: tool.symbol,
availability: availability
)
} }
return combineItems(urls: urls)
}
/// The slices a selection of mixed kinds can still act on.
private static func combineItems(urls: [URL]) -> [WheelItem] {
let subtitles: [ToolID: String] = [
.mergePDF: "One PDF",
.createArchive: "One ZIP"
]
return ToolID.combineTools(for: urls).compactMap { tool in
item(for: tool, urls: urls, subtitle: subtitles[tool])
}
}
private static func item(for tool: ToolID, urls: [URL], subtitle: String? = nil) -> WheelItem? {
guard urls.count >= tool.minimumFileCount else { return nil }
var availability = Availability.ok
if requiresFFmpeg(tool), !Toolchain.hasFFmpeg {
availability = .unavailable("Needs ffmpeg — brew install ffmpeg")
}
return WheelItem(
id: "tool-\(tool.rawValue)",
payload: .tool(tool),
title: tool.title,
subtitle: availability.isAvailable ? subtitle : availability.reason,
symbol: tool.symbol,
availability: availability
)
} }
private static func requiresFFmpeg(_ tool: ToolID) -> Bool { private static func requiresFFmpeg(_ tool: ToolID) -> Bool {
+74
View File
@@ -1,6 +1,7 @@
import AppKit import AppKit
import CoreGraphics import CoreGraphics
import Foundation import Foundation
import PDFKit
/// `--self-test` exercises the real conversion paths end to end and exits /// `--self-test` exercises the real conversion paths end to end and exits
/// non-zero on failure. Kept in the shipped binary so a build can be verified /// non-zero on failure. Kept in the shipped binary so a build can be verified
@@ -29,6 +30,7 @@ enum SelfTest {
testDocuments() testDocuments()
testAudio() testAudio()
testEbook() testEbook()
testCombining()
testGeometry() testGeometry()
print("") print("")
@@ -224,6 +226,25 @@ enum SelfTest {
try ArchiveConverter.convert(source: folder, to: .tar, at: tar) try ArchiveConverter.convert(source: folder, to: .tar, at: tar)
try requireFile(tar) try requireFile(tar)
} }
check("several files → one zip") {
let first = directory.appendingPathComponent("note.txt")
let second = directory.appendingPathComponent("spreadsheet.csv")
try? "one".write(to: first, atomically: true, encoding: .utf8)
try? "a,b\n1,2\n".write(to: second, atomically: true, encoding: .utf8)
let zip = directory.appendingPathComponent("selection.zip")
try ArchiveConverter.createArchive(from: [first, second], at: zip)
try requireFile(zip)
let target = directory.appendingPathComponent("selection-unpacked", isDirectory: true)
try ArchiveConverter.extract(source: zip, into: target)
for name in ["note.txt", "spreadsheet.csv"] {
let entry = target.appendingPathComponent(name)
guard FileManager.default.fileExists(atPath: entry.path) else {
throw TestFailure("\(name) missing from the archive")
}
}
}
} }
private static func testDocuments() { private static func testDocuments() {
@@ -325,6 +346,59 @@ enum SelfTest {
} }
} }
/// Filee's "mixed profile": a drag of different kinds still has something
/// useful to do, instead of an empty wheel.
private static func testCombining() {
section("Mixed selections")
let photo = directory.appendingPathComponent("sample.png")
let pdf = directory.appendingPathComponent("sample.pdf")
let note = directory.appendingPathComponent("note.txt")
check("a mixed drag still gets a wheel") {
let items = WheelBuilder.items(kind: .convert, urls: [photo, pdf])
let tools = items.compactMap { item -> ToolID? in
if case .tool(let tool) = item.payload { return tool }
return nil
}
guard !items.isEmpty else { throw TestFailure("the convert wheel was empty") }
guard tools.contains(.mergePDF), tools.contains(.createArchive) else {
throw TestFailure("expected Merge and Zip, got \(tools.map(\.rawValue))")
}
}
check("a multi-file wheel can always be zipped") {
let items = WheelBuilder.items(kind: .tools, urls: [photo, directory.appendingPathComponent("note.txt")])
let tools = items.compactMap { item -> ToolID? in
if case .tool(let tool) = item.payload { return tool }
return nil
}
guard tools.contains(.createArchive) else {
throw TestFailure("Zip is missing from a two-file image wheel")
}
}
check("photos and pdfs merge into one pdf") {
let outcome = try ToolRunner.run(tool: .mergePDF, inputs: [photo, pdf], params: ToolParams())
guard let merged = outcome.outputs.first,
let document = PDFDocument(url: merged) else {
throw TestFailure("no merged pdf was produced")
}
guard document.pageCount >= 2 else {
throw TestFailure("merged pdf has \(document.pageCount) page(s)")
}
}
check("any files → one zip") {
let outcome = try ToolRunner.run(tool: .createArchive, inputs: [note, photo], params: ToolParams())
guard let zip = outcome.outputs.first else { throw TestFailure("no archive was produced") }
try requireFile(zip)
let entries = try ArchiveConverter.list(zip)
for name in ["note.txt", "sample.png"] where !entries.contains(name) {
throw TestFailure("\(name) is missing from the archive: \(entries)")
}
}
}
private static func testGeometry() { private static func testGeometry() {
section("Wheel geometry") section("Wheel geometry")
let frame = CGRect(x: 0, y: 0, width: 400, height: 400) let frame = CGRect(x: 0, y: 0, width: 400, height: 400)
+12 -11
View File
@@ -38,24 +38,30 @@ enum QuickMenu {
let category = FileKind.category(of: urls[0]) let category = FileKind.category(of: urls[0])
if ConversionCatalog.outputs(for: urls.map { FileKind.category(of: $0) }).isEmpty { // The convert wheel also carries the combine tools, so it is empty only
// when there is genuinely nothing to offer.
let conversions = WheelBuilder.items(kind: .convert, urls: urls)
if conversions.isEmpty {
let item = NSMenuItem(title: "No conversions for this selection", action: nil, keyEquivalent: "") let item = NSMenuItem(title: "No conversions for this selection", action: nil, keyEquivalent: "")
item.isEnabled = false item.isEnabled = false
menu.addItem(item) menu.addItem(item)
} else { } else {
let convert = NSMenu() let convert = NSMenu()
for item in WheelBuilder.items(kind: .convert, urls: urls) { for item in conversions {
guard case .format(let target) = item.payload else { continue }
let entry = NSMenuItem(title: item.title, action: #selector(Choice.fire), keyEquivalent: "") let entry = NSMenuItem(title: item.title, action: #selector(Choice.fire), keyEquivalent: "")
entry.target = makeChoice { entry.target = makeChoice {
JobStore.shared.submit(urls: urls, target: .format(format(from: item))) JobStore.shared.submit(urls: urls, target: .format(target))
} }
entry.isEnabled = item.isEnabled entry.isEnabled = item.isEnabled
if let reason = item.availability.reason { entry.toolTip = reason } if let reason = item.availability.reason { entry.toolTip = reason }
convert.addItem(entry) convert.addItem(entry)
} }
let submenu = NSMenuItem(title: "Convert", action: nil, keyEquivalent: "") if !convert.items.isEmpty {
submenu.submenu = convert let submenu = NSMenuItem(title: "Convert", action: nil, keyEquivalent: "")
menu.addItem(submenu) submenu.submenu = convert
menu.addItem(submenu)
}
} }
let tools = WheelBuilder.items(kind: .tools, urls: urls) let tools = WheelBuilder.items(kind: .tools, urls: urls)
@@ -102,9 +108,4 @@ enum QuickMenu {
retained.append(choice) retained.append(choice)
return choice return choice
} }
private static func format(from item: WheelItem) -> Format {
if case .format(let format) = item.payload { return format }
return .png
}
} }