///|
/// A byte-size band used by the inventory summary.
pub(all) enum SizeBand {
Under256KiB
From256KiBTo1MiB
From1MiBTo8MiB
From8MiBTo32MiB
AtLeast32MiB
} derive(Eq)
///|
pub extend SizeBand with Eq::{not_equal, equal}
///|
pub fn SizeBand::label(self : SizeBand) -> String {
match self {
Under256KiB => "小于 256 KiB"
From256KiBTo1MiB => "256 KiB–1 MiB"
From1MiBTo8MiB => "1–8 MiB"
From8MiBTo32MiB => "8–32 MiB"
AtLeast32MiB => "32 MiB 及以上"
}
}
///|
pub(all) struct SizeBandStats {
band : SizeBand
count : Int32
bytes : Int64
}
///|
pub(all) struct ExtensionStats {
extension : String
count : Int32
readable : Int32
unreadable : Int32
bytes : Int64
}
///|
pub(all) struct InventorySummary {
total : Int32
readable : Int32
unreadable : Int32
bytes : Int64
minimum_size : Int32
maximum_size : Int32
average_size : Int64
landscape : Int32
portrait : Int32
square : Int32
unknown_dimensions : Int32
pixels : Int64
duplicate_file_groups : Int32
duplicate_files : Int32
}
///|
pub(all) struct PlanSummary {
total : Int32
exact : Int32
similar : Int32
conflict : Int32
new_count : Int32
unreadable : Int32
copyable : Int32
needs_review : Int32
selected_by_default : Int32
total_bytes : Int64
copyable_bytes : Int64
review_bytes : Int64
}
///|
pub(all) struct DigestGroup {
digest : String
paths : Array[String]
bytes : Int64
}
///|
fn extension_of(name : String) -> String {
match name.rev_split_once(".") {
Some((stem, extension)) if stem.length() > 0 && extension.length() > 0 =>
"." + extension.to_lower().to_owned()
_ => "(无扩展名)"
}
}
///|
/// Normalize path separators before extracting a final component. The
/// collection API otherwise treats paths as opaque labels.
pub fn normalized_extension(name : String) -> String {
let mut start = 0
for index = 0; index < name.length(); index = index + 1 {
if name[index] == '/' || name[index] == '\\' {
start = index + 1
}
}
extension_of(name.view(start_offset=start).to_owned())
}
///|
pub fn size_band(size : Int32) -> SizeBand {
if size < 256 * 1024 {
Under256KiB
} else if size < 1024 * 1024 {
From256KiBTo1MiB
} else if size < 8 * 1024 * 1024 {
From1MiBTo8MiB
} else if size < 32 * 1024 * 1024 {
From8MiBTo32MiB
} else {
AtLeast32MiB
}
}
///|
fn add_bytes(left : Int64, right : Int32) -> Int64 {
left + right.to_int64()
}
///|
fn empty_inventory() -> InventorySummary {
{
total: 0,
readable: 0,
unreadable: 0,
bytes: 0L,
minimum_size: 0,
maximum_size: 0,
average_size: 0L,
landscape: 0,
portrait: 0,
square: 0,
unknown_dimensions: 0,
pixels: 0L,
duplicate_file_groups: 0,
duplicate_files: 0,
}
}
///|
/// Return groups of byte-identical files. Group order follows first appearance
/// in the input, and path order follows the input order as well.
pub fn duplicate_digest_groups(photos : Array[Photo]) -> Array[DigestGroup] {
let digest_indices : Map[String, Int32] = Map([])
let groups : Array[DigestGroup] = []
for photo in photos {
match digest_indices.get(photo.digest) {
Some(group_index) => {
let previous = groups[group_index]
previous.paths.push(photo.path)
groups[group_index] = {
digest: previous.digest,
paths: previous.paths,
bytes: previous.bytes + photo.size.to_int64(),
}
}
None => {
digest_indices.set(photo.digest, groups.length())
groups.push({
digest: photo.digest,
paths: [photo.path],
bytes: photo.size.to_int64(),
})
}
}
}
let duplicates : Array[DigestGroup] = []
for group in groups {
if group.paths.length() > 1 {
duplicates.push(group)
}
}
duplicates
}
///|
/// Summarize a folder scan without changing the photos or their order.
pub fn inventory_summary(photos : Array[Photo]) -> InventorySummary {
if photos.length() == 0 {
return empty_inventory()
}
let mut bytes = 0L
let mut minimum = photos[0].size
let mut maximum = photos[0].size
let mut readable = 0
let mut unreadable = 0
let mut landscape = 0
let mut portrait = 0
let mut square = 0
let mut pixels = 0L
for photo in photos {
bytes = add_bytes(bytes, photo.size)
minimum = minimum.min(photo.size)
maximum = maximum.max(photo.size)
match photo.image_info {
Some(info) => {
readable = readable + 1
pixels = pixels + info.width.to_int64() * info.height.to_int64()
if info.width > info.height {
landscape = landscape + 1
} else if info.height > info.width {
portrait = portrait + 1
} else {
square = square + 1
}
}
None => unreadable = unreadable + 1
}
}
let duplicate_groups = duplicate_digest_groups(photos)
let mut duplicate_files = 0
for group in duplicate_groups {
duplicate_files = duplicate_files + group.paths.length() - 1
}
{
total: photos.length(),
readable,
unreadable,
bytes,
minimum_size: minimum,
maximum_size: maximum,
average_size: bytes / photos.length().to_int64(),
landscape,
portrait,
square,
unknown_dimensions: unreadable,
pixels,
duplicate_file_groups: duplicate_groups.length(),
duplicate_files,
}
}
///|
/// Aggregate photo count, decoded count and bytes by normalized extension.
/// Output is sorted alphabetically for reproducible reports.
pub fn extension_summary(photos : Array[Photo]) -> Array[ExtensionStats] {
let extension_indices : Map[String, Int32] = Map([])
let result : Array[ExtensionStats] = []
for photo in photos {
let extension = normalized_extension(photo.name)
match extension_indices.get(extension) {
Some(index) => {
let previous = result[index]
let readable = match photo.image_info {
Some(_) => 1
None => 0
}
let unreadable = 1 - readable
result[index] = {
extension,
count: previous.count + 1,
readable: previous.readable + readable,
unreadable: previous.unreadable + unreadable,
bytes: previous.bytes + photo.size.to_int64(),
}
}
None => {
let readable = match photo.image_info {
Some(_) => 1
None => 0
}
extension_indices.set(extension, result.length())
result.push({
extension,
count: 1,
readable,
unreadable: 1 - readable,
bytes: photo.size.to_int64(),
})
}
}
}
result.sort_by((left, right) => {
if left.extension == "(无扩展名)" && right.extension != "(无扩展名)" {
1
} else if right.extension == "(无扩展名)" &&
left.extension != "(无扩展名)" {
-1
} else if left.extension.lexical_compare(right.extension) < 0 {
-1
} else if left.extension.lexical_compare(right.extension) > 0 {
1
} else {
0
}
})
result
}
///|
fn empty_size_bands() -> Array[SizeBandStats] {
[
{ band: Under256KiB, count: 0, bytes: 0L, },
{ band: From256KiBTo1MiB, count: 0, bytes: 0L, },
{ band: From1MiBTo8MiB, count: 0, bytes: 0L, },
{ band: From8MiBTo32MiB, count: 0, bytes: 0L, },
{ band: AtLeast32MiB, count: 0, bytes: 0L, },
]
}
///|
/// Count files and total bytes in fixed, documented size ranges.
pub fn size_distribution(photos : Array[Photo]) -> Array[SizeBandStats] {
let result = empty_size_bands()
for photo in photos {
let index = match size_band(photo.size) {
Under256KiB => 0
From256KiBTo1MiB => 1
From1MiBTo8MiB => 2
From8MiBTo32MiB => 3
AtLeast32MiB => 4
}
let previous = result[index]
result[index] = {
band: previous.band,
count: previous.count + 1,
bytes: previous.bytes + photo.size.to_int64(),
}
}
result
}
///|
/// Counts and byte totals for the incoming review plan.
pub fn plan_summary(rows : Array[ReviewRow]) -> PlanSummary {
let mut exact = 0
let mut similar = 0
let mut conflict = 0
let mut new_count = 0
let mut unreadable = 0
let mut copyable = 0
let mut needs_review = 0
let mut selected_by_default = 0
let mut total_bytes = 0L
let mut copyable_bytes = 0L
let mut review_bytes = 0L
for row in rows {
total_bytes = total_bytes + row.size.to_int64()
match row.kind {
Exact => exact = exact + 1
Similar => {
similar = similar + 1
needs_review = needs_review + 1
review_bytes = review_bytes + row.size.to_int64()
}
Conflict => {
conflict = conflict + 1
needs_review = needs_review + 1
review_bytes = review_bytes + row.size.to_int64()
}
New => {
new_count = new_count + 1
selected_by_default = selected_by_default + 1
copyable = copyable + 1
copyable_bytes = copyable_bytes + row.size.to_int64()
}
Unreadable => unreadable = unreadable + 1
}
if row.destination is Some(_) && row.kind != New {
copyable = copyable + 1
copyable_bytes = copyable_bytes + row.size.to_int64()
}
}
{
total: rows.length(),
exact,
similar,
conflict,
new_count,
unreadable,
copyable,
needs_review,
selected_by_default,
total_bytes,
copyable_bytes,
review_bytes,
}
}
///|
/// Format a byte count using binary units. The output is intended for reports,
/// not as a machine-readable value.
pub fn format_bytes(bytes : Int64) -> String {
if bytes < 1024L {
bytes.to_string() + " B"
} else if bytes < 1048576L {
format_tenths(bytes, 1024L) + " KiB"
} else if bytes < 1073741824L {
format_tenths(bytes, 1048576L) + " MiB"
} else {
format_tenths(bytes, 1073741824L) + " GiB"
}
}
///|
fn format_tenths(value : Int64, divisor : Int64) -> String {
let tenths = (value * 10L + divisor / 2L) / divisor
(tenths / 10L).to_string() + "." + (tenths % 10L).to_string()
}
///|
/// Explain a few visible characteristics that can help a person compare a
/// questionable file. These are observations, not quality verdicts.
pub fn image_review_notes(info : ImageInfo) -> Array[String] {
let notes : Array[String] = []
if info.width < 640 || info.height < 640 {
notes.push("分辨率较小")
}
let shorter = info.width.min(info.height).max(1)
let longer = info.width.max(info.height)
if longer > shorter * 3 {
notes.push("长宽比例较大")
}
if info.luminance < 24 {
notes.push("整体偏暗")
} else if info.luminance > 232 {
notes.push("整体偏亮")
}
let max_color = info.red.max(info.green).max(info.blue)
let min_color = info.red.min(info.green).min(info.blue)
if max_color - min_color > 96 {
notes.push("平均颜色偏向单一色调")
}
notes
}
///|
/// Present decoded dimensions and approximate megapixels in one short label.
pub fn image_info_label(info : ImageInfo) -> String {
let megapixels = (info.width.to_int64() * info.height.to_int64() + 50000L) /
100000L
let megapixels_text = (megapixels / 10L).to_string() +
"." +
(megapixels % 10L).to_string()
info.width.to_string() +
" × " +
info.height.to_string() +
" · " +
megapixels_text +
" MP"
}