Export existed as a settings page and nothing else: format, quality, colour
space, five sizing modes, a filename template and a metadata switch, all
configurable in detail, and no way to produce a single file. dr-export is the
other half.
**It returns bytes and a name, and writes nothing.** An export has three
destinations with nothing in common — a path on Linux, a SAF document on
Android where there is no path at all (ARCH §6.9), and a PUT to a Nextcloud
folder — so a crate that opened the file itself would serve one of them and be
rewritten for the other two. The caller places the bytes.
Resize, then sharpen, then encode, in that order and for a reason: output
sharpening compensates for the softening the resample introduced, so its
strength scales with how much scaling actually happened, and sharpening before
shrinking would throw the result away. Lanczos-3, separable, with weights
computed once per output row — FR-EXP-4 asks for Lanczos or better because a
box filter turns a distant fence into moiré.
Collision handling takes the "is this name taken" test as a closure rather
than looking at a directory, because there is no directory it could look at
that works everywhere. That shape is not politeness toward Linux: Android's
createDocument renames on collision by itself and cannot overwrite at all, so
all three CollisionPolicy settings need the answer *before* anything is
created. Overwrite, Skip and Increment are each tested, and Increment gives up
after ten thousand rather than spinning against a destination that reports
everything as taken.
Three things are honest rather than done:
- **Colour space.** sRGB only. The shader encodes and clips to sRGB before
this crate sees a pixel, so tagging a file Display P3 would claim a gamut
it does not contain. Refused with a typed error instead of mislabelled;
honouring it is a pipeline change (FR-EXP-2).
- **AVIF and JPEG XL.** No encoder. libaom and libjxl are C, ravif is slow
enough to change what a batch feels like, and the settings page offers
both because FR-EXP-1 lists them — so asking for one says so rather than
writing a JPEG under a .avif name.
- **16-bit TIFF** is a real 16-bit file carrying eight bits of information,
because AdjustPass renders to Rgba8Unorm. Widened by *257, not <<8, so
white lands on 65535 rather than a quarter-percent grey. Making it mean
what it says needs the composer told what format to write.
Metadata is not written at all, which satisfies the half of FR-EXP-8 that
matters most: strip_location defaults to on, and a file with no EXIF block has
no GPS tag. Retaining camera and copyright when asked is not implemented and
cannot be faked by omission.
Also here:
- `AdjustPass::export_pixels`, ungated where `read_output` is behind a
feature. The two are the same transfer and opposites in intent: reading
pixels back to *display* them is what ARCH §6.1 forbids and AC-8 asserts
against, while reading them back to encode a JPEG is the only way a file
has ever been made. Separate methods so the instrumentation can count one
without counting the other.
- `ExportTarget`, so a destination can be a folder on the server. On Android
that is the only destination needing no platform work whatsoever — a PUT
against create_dir, already on the RemoteBackend trait, behaving
identically on both platforms. Switching target clears the destination,
since a path is not a remote folder and carrying one across would offer to
create a folder called `home` at the library root.
Verified end to end rather than by unit test alone: `cargo run -p dr-export
--example export` decodes a frame, runs the develop chain on the GPU at full
resolution, reads it back, and writes all five formats — 27 ms for a
full-size JPEG, 165 ms with a Lanczos reduction to 1200px. ImageMagick agrees
the 16-bit TIFF is 16-bit. dr-export cross-compiles clean for
aarch64-linux-android; all three encoders are pure Rust, which is why they
were chosen. 944 tests pass, clippy and fmt clean.
Not yet wired to a button. The develop view has no export action, so nothing
in the running app can reach any of this yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
317 lines
11 KiB
Rust
317 lines
11 KiB
Rust
//! TRACES: FR-EXP-6
|
|
//! Filename templates and what to do when the name is taken.
|
|
//!
|
|
//! # Why the caller supplies the "does this exist" test
|
|
//!
|
|
//! [`resolve_name`] takes a closure rather than looking at a directory,
|
|
//! because there is no directory it could look at that would work everywhere.
|
|
//! A destination is a path on Linux, a Storage Access Framework tree on
|
|
//! Android with no path at all (ARCH §6.9), or a folder on a Nextcloud
|
|
//! server reached by PROPFIND. All three can answer "is this name taken",
|
|
//! and none of them can be asked the same way.
|
|
//!
|
|
//! It matters most on Android, where the platform actively works against us:
|
|
//! `DocumentsContract.createDocument` renames on collision *by itself*,
|
|
//! appending ` (1)` and returning a URI with a name nobody asked for, and it
|
|
//! cannot overwrite at all. So every one of the three [`CollisionPolicy`]
|
|
//! settings requires knowing the answer before creating anything — which is
|
|
//! exactly what this function is shaped for.
|
|
|
|
use dr_types::{CollisionPolicy, ExportFormat};
|
|
|
|
/// What a template can refer to.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct NameContext<'a> {
|
|
/// The source image's name, without extension — `{name}`.
|
|
pub source_stem: &'a str,
|
|
/// Position in the batch, 1-based — `{seq}`.
|
|
pub sequence: u32,
|
|
/// Capture date as `YYYY-MM-DD` — `{date}`. Empty where unknown.
|
|
pub date: &'a str,
|
|
/// The export's pixel dimensions — `{dimensions}`.
|
|
pub width: u32,
|
|
pub height: u32,
|
|
/// The preset that produced this export — `{preset}`. Empty where none.
|
|
pub preset: &'a str,
|
|
}
|
|
|
|
/// Expand a template into a filename stem.
|
|
///
|
|
/// Unknown tokens are left verbatim rather than dropped. A user who typed
|
|
/// `{nmae}` should see it in the output and understand what happened; a
|
|
/// silently empty filename is a puzzle, and a template that quietly loses a
|
|
/// token produces a directory of files named the same thing.
|
|
pub fn expand(template: &str, ctx: &NameContext<'_>) -> String {
|
|
let seq = ctx.sequence.to_string();
|
|
let dimensions = format!("{}x{}", ctx.width, ctx.height);
|
|
|
|
let mut out = String::with_capacity(template.len() + 16);
|
|
let mut rest = template;
|
|
while let Some(open) = rest.find('{') {
|
|
out.push_str(&rest[..open]);
|
|
let Some(close) = rest[open..].find('}') else {
|
|
// An unclosed brace is literal text; there is nothing to expand
|
|
// and dropping the remainder would truncate the name. Consumed
|
|
// here rather than left for the tail append below, which has
|
|
// already had everything before the brace taken from it.
|
|
out.push_str(&rest[open..]);
|
|
rest = "";
|
|
break;
|
|
};
|
|
let token = &rest[open + 1..open + close];
|
|
match token {
|
|
"name" => out.push_str(ctx.source_stem),
|
|
"seq" => out.push_str(&seq),
|
|
"date" => out.push_str(ctx.date),
|
|
"dimensions" => out.push_str(&dimensions),
|
|
"preset" => out.push_str(ctx.preset),
|
|
_ => out.push_str(&rest[open..open + close + 1]),
|
|
}
|
|
rest = &rest[open + close + 1..];
|
|
}
|
|
out.push_str(rest);
|
|
|
|
let cleaned = sanitise(&out);
|
|
if cleaned.is_empty() {
|
|
// Every token was empty — a template of `{preset}` with no preset, on
|
|
// an image with no date. Falling back to the source name is the one
|
|
// answer that is always available and never collides more than the
|
|
// source files themselves do.
|
|
return sanitise(ctx.source_stem);
|
|
}
|
|
cleaned
|
|
}
|
|
|
|
/// Strip what no filesystem, SAF provider or WebDAV server will take.
|
|
///
|
|
/// The intersection of three sets of rules rather than any one of them: an
|
|
/// export written to a Nextcloud folder may later sync down to a Windows
|
|
/// client, and a name that was legal where it was created is not much comfort
|
|
/// on the machine that cannot open it.
|
|
fn sanitise(stem: &str) -> String {
|
|
let mut out: String = stem
|
|
.chars()
|
|
.map(|c| match c {
|
|
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
|
|
c if (c as u32) < 0x20 => '-',
|
|
c => c,
|
|
})
|
|
.collect();
|
|
// Trailing dots and spaces are legal on Linux and rejected by Windows,
|
|
// and a name ending in one is almost always an accident of a template
|
|
// whose last token expanded to nothing.
|
|
while out.ends_with('.') || out.ends_with(' ') {
|
|
out.pop();
|
|
}
|
|
out.trim_start().to_string()
|
|
}
|
|
|
|
/// The filename this export should be written under, honouring the collision
|
|
/// policy.
|
|
///
|
|
/// `taken` answers whether a name already exists in the destination. Returns
|
|
/// `None` for [`CollisionPolicy::Skip`] when the name is in use — the caller
|
|
/// writes nothing and moves on, which is the whole point of that setting.
|
|
pub fn resolve_name(
|
|
template: &str,
|
|
ctx: &NameContext<'_>,
|
|
format: ExportFormat,
|
|
collision: CollisionPolicy,
|
|
taken: &dyn Fn(&str) -> bool,
|
|
) -> Option<String> {
|
|
let stem = expand(template, ctx);
|
|
let ext = format.extension();
|
|
let first = format!("{stem}.{ext}");
|
|
|
|
if !taken(&first) {
|
|
return Some(first);
|
|
}
|
|
|
|
match collision {
|
|
CollisionPolicy::Overwrite => Some(first),
|
|
CollisionPolicy::Skip => None,
|
|
CollisionPolicy::Increment => {
|
|
// Bounded. An unbounded search would spin forever against a
|
|
// destination that reports everything as taken — a permission
|
|
// error misread as existence, say — and a batch that hangs is
|
|
// worse than one that reports a failure.
|
|
for n in 1..10_000 {
|
|
let candidate = format!("{stem}-{n}.{ext}");
|
|
if !taken(&candidate) {
|
|
return Some(candidate);
|
|
}
|
|
}
|
|
log::warn!("{stem}: ten thousand names taken; skipping");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn ctx() -> NameContext<'static> {
|
|
NameContext {
|
|
source_stem: "IMG_1234",
|
|
sequence: 7,
|
|
date: "2026-08-16",
|
|
width: 2048,
|
|
height: 1365,
|
|
preset: "Web",
|
|
}
|
|
}
|
|
|
|
fn free(_: &str) -> bool {
|
|
false
|
|
}
|
|
|
|
#[test]
|
|
fn the_default_template_is_the_source_name() {
|
|
assert_eq!(expand("{name}", &ctx()), "IMG_1234");
|
|
}
|
|
|
|
#[test]
|
|
fn every_documented_token_expands() {
|
|
// The settings page advertises these five in its hint; a token listed
|
|
// there and unhandled here would reach the filename verbatim.
|
|
assert_eq!(expand("{name}", &ctx()), "IMG_1234");
|
|
assert_eq!(expand("{seq}", &ctx()), "7");
|
|
assert_eq!(expand("{date}", &ctx()), "2026-08-16");
|
|
assert_eq!(expand("{dimensions}", &ctx()), "2048x1365");
|
|
assert_eq!(expand("{preset}", &ctx()), "Web");
|
|
}
|
|
|
|
#[test]
|
|
fn tokens_combine_with_literal_text() {
|
|
assert_eq!(
|
|
expand("{date}_{name}_{dimensions}", &ctx()),
|
|
"2026-08-16_IMG_1234_2048x1365"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_token_survives_verbatim() {
|
|
// A typo the user can see and fix, rather than a name that silently
|
|
// lost a component and now collides with every other export.
|
|
assert_eq!(expand("{nmae}-x", &ctx()), "{nmae}-x");
|
|
}
|
|
|
|
#[test]
|
|
fn an_unclosed_brace_is_literal_text() {
|
|
assert_eq!(expand("{name", &ctx()), "{name");
|
|
assert_eq!(expand("a{name}b{", &ctx()), "aIMG_1234b{");
|
|
}
|
|
|
|
#[test]
|
|
fn a_template_that_expands_to_nothing_falls_back_to_the_source_name() {
|
|
// `{preset}` with no preset selected. An empty filename is not a file.
|
|
let mut c = ctx();
|
|
c.preset = "";
|
|
assert_eq!(expand("{preset}", &c), "IMG_1234");
|
|
}
|
|
|
|
#[test]
|
|
fn path_separators_cannot_escape_the_destination() {
|
|
// `{name}` comes from a source filename, and a template is user text.
|
|
// Either could carry a slash, and an export must not write outside
|
|
// the folder that was chosen — nor create a subfolder on the server.
|
|
let mut c = ctx();
|
|
c.source_stem = "holiday/2026";
|
|
assert_eq!(expand("{name}", &c), "holiday-2026");
|
|
assert_eq!(expand("../../etc/passwd", &ctx()), "..-..-etc-passwd");
|
|
}
|
|
|
|
#[test]
|
|
fn characters_windows_rejects_are_replaced() {
|
|
// An export may sync down to a Windows client through Nextcloud, and
|
|
// a name that was legal where it was written is no comfort there.
|
|
assert_eq!(expand(r#"a:b*c?d"e<f>g|h\i"#, &ctx()), "a-b-c-d-e-f-g-h-i");
|
|
}
|
|
|
|
#[test]
|
|
fn trailing_dots_and_spaces_are_trimmed() {
|
|
let mut c = ctx();
|
|
c.preset = "";
|
|
assert_eq!(expand("{name}.{preset}", &c), "IMG_1234");
|
|
assert_eq!(expand("{name} ", &ctx()), "IMG_1234");
|
|
}
|
|
|
|
#[test]
|
|
fn a_free_name_is_used_as_is() {
|
|
let got = resolve_name(
|
|
"{name}",
|
|
&ctx(),
|
|
ExportFormat::Jpeg,
|
|
CollisionPolicy::Increment,
|
|
&free,
|
|
);
|
|
assert_eq!(got.as_deref(), Some("IMG_1234.jpg"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_extension_follows_the_format() {
|
|
for (format, ext) in [
|
|
(ExportFormat::Jpeg, "jpg"),
|
|
(ExportFormat::Png, "png"),
|
|
(ExportFormat::Tiff16, "tif"),
|
|
] {
|
|
let got = resolve_name("{name}", &ctx(), format, CollisionPolicy::Skip, &free);
|
|
assert_eq!(got.as_deref(), Some(&*format!("IMG_1234.{ext}")));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn increment_finds_the_first_free_suffix() {
|
|
let taken = |n: &str| matches!(n, "IMG_1234.jpg" | "IMG_1234-1.jpg" | "IMG_1234-2.jpg");
|
|
let got = resolve_name(
|
|
"{name}",
|
|
&ctx(),
|
|
ExportFormat::Jpeg,
|
|
CollisionPolicy::Increment,
|
|
&taken,
|
|
);
|
|
assert_eq!(got.as_deref(), Some("IMG_1234-3.jpg"));
|
|
}
|
|
|
|
#[test]
|
|
fn skip_returns_nothing_when_the_name_is_taken() {
|
|
// The caller writes no file at all — that is what Skip means, and it
|
|
// is why this returns an Option rather than always a name.
|
|
let got = resolve_name(
|
|
"{name}",
|
|
&ctx(),
|
|
ExportFormat::Jpeg,
|
|
CollisionPolicy::Skip,
|
|
&|_| true,
|
|
);
|
|
assert_eq!(got, None);
|
|
}
|
|
|
|
#[test]
|
|
fn overwrite_returns_the_taken_name() {
|
|
let got = resolve_name(
|
|
"{name}",
|
|
&ctx(),
|
|
ExportFormat::Jpeg,
|
|
CollisionPolicy::Overwrite,
|
|
&|_| true,
|
|
);
|
|
assert_eq!(got.as_deref(), Some("IMG_1234.jpg"));
|
|
}
|
|
|
|
#[test]
|
|
fn increment_gives_up_rather_than_spinning_forever() {
|
|
// A destination that reports every name as taken — a permission error
|
|
// misread as existence — must not hang the batch.
|
|
let got = resolve_name(
|
|
"{name}",
|
|
&ctx(),
|
|
ExportFormat::Jpeg,
|
|
CollisionPolicy::Increment,
|
|
&|_| true,
|
|
);
|
|
assert_eq!(got, None);
|
|
}
|
|
}
|