mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 03:27:14 -06:00
Watermark.inset becomes inset_x (from the left or right edge) and inset_y (from the top or bottom edge), like Lightroom Classic's watermark editor. The watermark keys are insetX and insetY; the old inset key still reads as both, so saved presets and existing MCP calls keep working. The Export dialog's Inset slider becomes Horizontal inset and Vertical inset, each greyed out when the position is centred along its axis. cargo fmt reflows WM_POSITIONS and its test from the previous commits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
382 lines
15 KiB
Rust
382 lines
15 KiB
Rust
//! Paginated contact sheets, rendered with the normal export pipeline and packaged as PDF.
|
||
use crate::export::{Anchor, ExportFormat, ExportOptions, Watermark, draw_watermark, encode_image};
|
||
use crate::{Session, media::RenderJob};
|
||
use lightcraft_pipeline::{OutputDepth, OutputSpace};
|
||
use lightcraft_raster::Rgba8;
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::Value;
|
||
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
|
||
pub struct Options {
|
||
pub paper: String,
|
||
pub landscape: bool,
|
||
pub columns: usize,
|
||
pub rows: usize,
|
||
pub captions: bool,
|
||
}
|
||
|
||
impl Default for Options {
|
||
fn default() -> Self {
|
||
Self { paper: "a4".into(), landscape: false, columns: 3, rows: 4, captions: true }
|
||
}
|
||
}
|
||
|
||
impl Options {
|
||
pub fn parse(p: &Value) -> Result<Self, String> {
|
||
let mut settings = p.as_object().cloned().ok_or("params must be an object")?;
|
||
settings.remove("path");
|
||
settings.remove("ids");
|
||
settings.remove("id");
|
||
let o: Self = serde_json::from_value(Value::Object(settings)).map_err(|e| e.to_string())?;
|
||
o.layout()?;
|
||
Ok(o)
|
||
}
|
||
|
||
fn layout(&self) -> Result<Layout, String> {
|
||
if !(1..=8).contains(&self.columns) || !(1..=10).contains(&self.rows) {
|
||
return Err("columns must be 1–8 and rows 1–10".into());
|
||
}
|
||
let (mut w, mut h) = match self.paper.as_str() {
|
||
"a4" => (595.28, 841.89),
|
||
"letter" => (612.0, 792.0),
|
||
_ => return Err("paper must be a4 or letter".into()),
|
||
};
|
||
if self.landscape {
|
||
std::mem::swap(&mut w, &mut h);
|
||
}
|
||
// Fixed 150 dpi, bounded paper sizes and grid dimensions keep allocations predictable.
|
||
let width = (w * 150.0 / 72.0_f64).round() as usize;
|
||
let height = (h * 150.0 / 72.0_f64).round() as usize;
|
||
Ok(Layout { points: (w, h), width, height, cell_w: (width - 120) / self.columns, cell_h: (height - 120) / self.rows })
|
||
}
|
||
}
|
||
|
||
struct Layout {
|
||
points: (f64, f64),
|
||
width: usize,
|
||
height: usize,
|
||
cell_w: usize,
|
||
cell_h: usize,
|
||
}
|
||
|
||
pub struct Prepared {
|
||
options: Options,
|
||
items: Vec<(String, RenderJob)>,
|
||
}
|
||
|
||
pub struct Document {
|
||
pub bytes: Vec<u8>,
|
||
pub pages: usize,
|
||
pub photos: usize,
|
||
}
|
||
|
||
pub fn prepare(session: &mut Session, p: &Value) -> Result<Prepared, String> {
|
||
let options = Options::parse(p)?;
|
||
if let Some(ids) = p.get("ids") {
|
||
let a = ids.as_array().ok_or("ids must be an array of photo ids")?;
|
||
if a.iter().any(|v| v.as_u64().is_none()) {
|
||
return Err("ids must contain only photo ids".into());
|
||
}
|
||
}
|
||
if p.get("id").is_some_and(|v| v.as_u64().is_none()) {
|
||
return Err("id must be a photo id".into());
|
||
}
|
||
let ids = session.targets(p);
|
||
if ids.is_empty() || ids.len() > 1000 {
|
||
return Err("select between 1 and 1000 photos".into());
|
||
}
|
||
let l = options.layout()?;
|
||
if ids.len().div_ceil(options.columns * options.rows) > 100 {
|
||
return Err("contact sheets are limited to 100 pages; increase the grid size or select fewer photos".into());
|
||
}
|
||
let mut items = Vec::with_capacity(ids.len());
|
||
for id in ids {
|
||
let name = session.catalog.photo(id).ok_or("no such photo")?.file_name.clone();
|
||
let job = session.export_job(id, l.cell_w - 16, l.cell_h - 48, OutputSpace::Srgb, OutputDepth::U8)?;
|
||
items.push((name, job));
|
||
}
|
||
Ok(Prepared { options, items })
|
||
}
|
||
|
||
impl Prepared {
|
||
pub fn len(&self) -> usize {
|
||
self.items.len()
|
||
}
|
||
|
||
pub fn is_empty(&self) -> bool {
|
||
self.items.is_empty()
|
||
}
|
||
|
||
/// Cancellation produces no partial document; callers write only after this succeeds.
|
||
pub fn run(self, progress: &mut dyn FnMut(usize, &str) -> bool) -> Result<Document, String> {
|
||
let l = self.options.layout()?;
|
||
let capacity = self.options.columns * self.options.rows;
|
||
let photos = self.items.len();
|
||
let pages = photos.div_ceil(capacity);
|
||
let mut pdf = Pdf::new(pages, l.points);
|
||
let mut page = Rgba8::filled(l.width, l.height, [255; 4]);
|
||
for (i, (name, job)) in self.items.into_iter().enumerate() {
|
||
if !progress(i, &name) {
|
||
return Err("Contact sheet cancelled".into());
|
||
}
|
||
let image = job.run().rendered?.image;
|
||
let slot = i % capacity;
|
||
let x = 60 + slot % self.options.columns * l.cell_w;
|
||
let y = 60 + slot / self.options.columns * l.cell_h;
|
||
// The renderer fits within the requested rectangle and applies crop/orientation.
|
||
if image.width > l.cell_w - 16 || image.height > l.cell_h - 48 {
|
||
return Err("render exceeded contact sheet cell".into());
|
||
}
|
||
blit(&mut page, &image, x + (l.cell_w - image.width) / 2, y + (l.cell_h - 40 - image.height) / 2)?;
|
||
if self.options.captions {
|
||
let mut caption = Rgba8::filled(l.cell_w - 16, 28, [255; 4]);
|
||
draw_watermark(
|
||
&mut caption,
|
||
&Watermark {
|
||
text: caption_text(&name, l.cell_w - 16),
|
||
size: 0.5,
|
||
opacity: 1.0,
|
||
anchor: Anchor::Center,
|
||
inset_x: 0.0,
|
||
inset_y: 0.0,
|
||
color: [32; 3],
|
||
shadow: false,
|
||
..Default::default()
|
||
},
|
||
);
|
||
blit(&mut page, &caption, x + 8, y + l.cell_h - 36)?;
|
||
}
|
||
if slot + 1 == capacity || i + 1 == photos {
|
||
let jpg = encode_image(&page, &ExportOptions { format: ExportFormat::Jpeg, quality: 92, ..Default::default() })?;
|
||
if pdf.bytes.len().saturating_add(jpg.len()) > 256 * 1024 * 1024 {
|
||
return Err("contact sheet exceeds 256 MiB; select fewer photos".into());
|
||
}
|
||
pdf.page(i / capacity, &jpg, l.width, l.height);
|
||
page.data.fill([255; 4]);
|
||
}
|
||
}
|
||
if !progress(photos, "PDF") {
|
||
return Err("Contact sheet cancelled".into());
|
||
}
|
||
Ok(Document { bytes: pdf.finish(), pages, photos })
|
||
}
|
||
}
|
||
|
||
fn caption_text(name: &str, width: usize) -> String {
|
||
use unicode_segmentation::UnicodeSegmentation;
|
||
let clean: String = name.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
|
||
let cells: Vec<_> = clean.graphemes(true).collect();
|
||
// A conservative one-em budget at 14 px also accommodates the optional CJK faces.
|
||
let limit = (width / 14).max(3);
|
||
if cells.len() <= limit {
|
||
return clean;
|
||
}
|
||
let left = limit / 2;
|
||
let right = limit - left - 1;
|
||
format!("{}…{}", cells.iter().take(left).copied().collect::<String>(), cells.iter().skip(cells.len() - right).copied().collect::<String>())
|
||
}
|
||
|
||
fn blit(dst: &mut Rgba8, src: &Rgba8, x: usize, y: usize) -> Result<(), String> {
|
||
if x.checked_add(src.width).is_none_or(|w| w > dst.width) || y.checked_add(src.height).is_none_or(|h| h > dst.height) {
|
||
return Err("contact sheet image outside page".into());
|
||
}
|
||
for row in 0..src.height {
|
||
let start = (y + row) * dst.width + x;
|
||
let a = dst.data.get_mut(start..start + src.width).ok_or("invalid page buffer")?;
|
||
let b = src.data.get(row * src.width..(row + 1) * src.width).ok_or("invalid render buffer")?;
|
||
for (dst, src) in a.iter_mut().zip(b) {
|
||
let alpha = u32::from(src[3]);
|
||
for channel in 0..3 {
|
||
dst[channel] = ((u32::from(src[channel]) * alpha + u32::from(dst[channel]) * (255 - alpha) + 127) / 255) as u8;
|
||
}
|
||
dst[3] = 255;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// Minimal PDF 1.4: one JPEG XObject per page, explicit byte lengths and classic xref offsets.
|
||
struct Pdf {
|
||
bytes: Vec<u8>,
|
||
offsets: Vec<usize>,
|
||
points: (f64, f64),
|
||
}
|
||
|
||
impl Pdf {
|
||
fn new(pages: usize, points: (f64, f64)) -> Self {
|
||
let mut p = Self { bytes: b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(), offsets: vec![0], points };
|
||
p.object(1, b"<< /Type /Catalog /Pages 2 0 R >>");
|
||
let kids = (0..pages).map(|i| format!("{} 0 R", 3 + i * 3)).collect::<Vec<_>>().join(" ");
|
||
p.object(2, format!("<< /Type /Pages /Count {pages} /Kids [{kids}] >>").as_bytes());
|
||
p
|
||
}
|
||
|
||
fn object(&mut self, id: usize, body: &[u8]) {
|
||
self.offsets.push(self.bytes.len());
|
||
self.bytes.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
|
||
self.bytes.extend_from_slice(body);
|
||
self.bytes.extend_from_slice(b"\nendobj\n");
|
||
}
|
||
|
||
fn stream(&mut self, id: usize, dictionary: &str, bytes: &[u8]) {
|
||
let mut body = format!("<< {dictionary} /Length {} >>\nstream\n", bytes.len()).into_bytes();
|
||
body.extend_from_slice(bytes);
|
||
body.extend_from_slice(b"\nendstream");
|
||
self.object(id, &body);
|
||
}
|
||
|
||
fn page(&mut self, index: usize, jpg: &[u8], width: usize, height: usize) {
|
||
let id = 3 + index * 3;
|
||
let (w, h) = self.points;
|
||
self.object(
|
||
id,
|
||
format!(
|
||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w:.2} {h:.2}] /Resources << /XObject << /Im {} 0 R >> >> /Contents {} 0 R >>",
|
||
id + 2,
|
||
id + 1
|
||
)
|
||
.as_bytes(),
|
||
);
|
||
self.stream(id + 1, "", format!("q {w:.2} 0 0 {h:.2} 0 0 cm /Im Do Q").as_bytes());
|
||
self.stream(
|
||
id + 2,
|
||
&format!("/Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode"),
|
||
jpg,
|
||
);
|
||
}
|
||
|
||
fn finish(mut self) -> Vec<u8> {
|
||
let start = self.bytes.len();
|
||
self.bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", self.offsets.len()).as_bytes());
|
||
for offset in self.offsets.iter().skip(1) {
|
||
self.bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||
}
|
||
self.bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", self.offsets.len()).as_bytes());
|
||
self.bytes
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use serde_json::json;
|
||
|
||
#[test]
|
||
fn validates_settings_and_selection() {
|
||
let mut s = Session::with_demo();
|
||
for p in [
|
||
json!({"columns": 0}),
|
||
json!({"rows": 11}),
|
||
json!({"paper": "poster"}),
|
||
json!({"landscape": "yes"}),
|
||
json!({"typo": 2}),
|
||
json!({"ids": ["bad"]}),
|
||
json!({"ids": []}),
|
||
] {
|
||
assert!(prepare(&mut s, &p).is_err(), "{p}");
|
||
}
|
||
let a = Options::default().layout().unwrap();
|
||
let b = Options { landscape: true, ..Default::default() }.layout().unwrap();
|
||
assert_eq!((a.width, a.height), (b.height, b.width));
|
||
let id = s.catalog.photos().next().unwrap().id.0;
|
||
assert!(prepare(&mut s, &json!({"ids": [id, u64::MAX]})).is_err());
|
||
assert!(prepare(&mut s, &json!({"ids": vec![id; 1001]})).is_err());
|
||
assert!(prepare(&mut s, &json!({"ids": vec![id; 101], "columns": 1, "rows": 1})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn transparent_pixels_composite_over_white() {
|
||
let mut dst = Rgba8::filled(3, 1, [255; 4]);
|
||
let src = Rgba8 { width: 3, height: 1, data: vec![[0, 0, 0, 0], [255, 0, 0, 128], [10, 20, 30, 255]] };
|
||
blit(&mut dst, &src, 0, 0).unwrap();
|
||
assert_eq!(dst.data, vec![[255; 4], [255, 127, 127, 255], [10, 20, 30, 255]]);
|
||
assert!(blit(&mut dst, &src, usize::MAX, 0).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn unicode_captions_are_rasterized() {
|
||
assert_eq!(caption_text("very-long-éééé-filename.jpg", 140), "very-….jpg");
|
||
assert_eq!(caption_text("café\n.jpg", 300), "café .jpg");
|
||
let mut img = Rgba8::filled(360, 28, [255; 4]);
|
||
draw_watermark(
|
||
&mut img,
|
||
&Watermark {
|
||
text: "Été – Straße café.jpg".into(),
|
||
size: 0.5,
|
||
opacity: 1.0,
|
||
anchor: Anchor::Center,
|
||
inset_x: 0.0,
|
||
inset_y: 0.0,
|
||
color: [32; 3],
|
||
shadow: false,
|
||
..Default::default()
|
||
},
|
||
);
|
||
assert!(img.data.iter().filter(|p| p[0] < 128).count() > 100);
|
||
let mut ascii = Rgba8::filled(360, 28, [255; 4]);
|
||
draw_watermark(
|
||
&mut ascii,
|
||
&Watermark {
|
||
text: "Ete - Strasse cafe.jpg".into(),
|
||
size: 0.5,
|
||
opacity: 1.0,
|
||
anchor: Anchor::Center,
|
||
inset_x: 0.0,
|
||
inset_y: 0.0,
|
||
color: [32; 3],
|
||
shadow: false,
|
||
..Default::default()
|
||
},
|
||
);
|
||
assert_ne!(img, ascii);
|
||
}
|
||
|
||
#[test]
|
||
fn pagination_xref_and_cancellation() {
|
||
let mut s = Session::with_demo();
|
||
let ids: Vec<_> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
|
||
let p = json!({"ids": ids, "columns": 1, "rows": 2, "captions": false});
|
||
let mut seen = Vec::new();
|
||
let doc = prepare(&mut s, &p)
|
||
.unwrap()
|
||
.run(&mut |i, _| {
|
||
seen.push(i);
|
||
true
|
||
})
|
||
.unwrap();
|
||
assert_eq!((doc.pages, doc.photos), (2, 3));
|
||
assert_eq!(seen, vec![0, 1, 2, 3]);
|
||
let text = String::from_utf8_lossy(&doc.bytes);
|
||
assert!(text.contains("/Count 2 /Kids [3 0 R 6 0 R]"));
|
||
let xref = text.rsplit("startxref\n").next().unwrap().lines().next().unwrap().parse::<usize>().unwrap();
|
||
assert!(doc.bytes[xref..].starts_with(b"xref\n0 9\n"));
|
||
let xref_text = std::str::from_utf8(&doc.bytes[xref..]).unwrap();
|
||
for (index, line) in xref_text.lines().skip(3).take(8).enumerate() {
|
||
let offset = line.split_whitespace().next().unwrap().parse::<usize>().unwrap();
|
||
assert!(doc.bytes[offset..].starts_with(format!("{} 0 obj\n", index + 1).as_bytes()));
|
||
}
|
||
assert!(prepare(&mut s, &p).unwrap().run(&mut |_, _| false).is_err());
|
||
}
|
||
|
||
fn first_page(bytes: &[u8]) -> Rgba8 {
|
||
let start = bytes.windows(2).position(|v| v == [0xff, 0xd8]).unwrap();
|
||
let end = bytes[start..].windows(2).position(|v| v == [0xff, 0xd9]).unwrap() + start + 2;
|
||
lightcraft_codecs::decode(&bytes[start..end], Default::default()).unwrap().to_srgb8()
|
||
}
|
||
|
||
#[test]
|
||
fn sheet_uses_current_edits_and_captions() {
|
||
let mut s = Session::with_demo();
|
||
let p = json!({"columns": 1, "rows": 2, "captions": false});
|
||
let plain = first_page(&prepare(&mut s, &p).unwrap().run(&mut |_, _| true).unwrap().bytes);
|
||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 2.0})).unwrap();
|
||
let bright = first_page(&prepare(&mut s, &p).unwrap().run(&mut |_, _| true).unwrap().bytes);
|
||
let sum = |img: &Rgba8| img.data.iter().map(|p| u64::from(p[0]) + u64::from(p[1]) + u64::from(p[2])).sum::<u64>();
|
||
assert!(sum(&bright) > sum(&plain), "sheet ignored develop settings");
|
||
let captions = first_page(&prepare(&mut s, &json!({"columns": 1, "rows": 2})).unwrap().run(&mut |_, _| true).unwrap().bytes);
|
||
assert!(sum(&captions) < sum(&bright), "captions absent from PDF pixels");
|
||
assert_eq!((plain.width, plain.height), (1240, 1754));
|
||
}
|
||
}
|