mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Merge PR #1903: LRC-PRINT-PAGE: Page Options print page numbers, page info and crop marks (gap 15)
# Conflicts: # docs/gaps.md # docs/parity-checklist.md
This commit is contained in:
@@ -324,7 +324,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
"{ids?} → {settings: {layout, image, page, guides, job}, papers, pageWidth, pageHeight, printable, pageCount, cellsPerPage, gridCells?, overlaps}; lengths are inches",
|
"{ids?} → {settings: {layout, image, page, guides, job}, papers, pageWidth, pageHeight, printable, pageCount, cellsPerPage, gridCells?, overlaps}; lengths are inches",
|
||||||
always, get),
|
always, get),
|
||||||
cmd!(query "print.set", "Set Print Settings", [], None,
|
cmd!(query "print.set", "Set Print Settings", [], None,
|
||||||
"{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b]}, guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get",
|
"{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b], pageNumbers, pageInfo, cropMarks} (Page Options: single image and contact sheet only), guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get",
|
||||||
always, set),
|
always, set),
|
||||||
cmd!(query "print.reset", "Reset Print Settings", [], None, "{} → print.get", always, reset),
|
cmd!(query "print.reset", "Reset Print Settings", [], None, "{} → print.get", always, reset),
|
||||||
cmd!(query "print.cell.add", "Add Print Cell", [], None,
|
cmd!(query "print.cell.add", "Add Print Cell", [], None,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Composes print pages: places each cell's photo (fit or Zoom to Fill, optionally Rotate to Fit),
|
//! Composes print pages: places each cell's photo (fit or Zoom to Fill, optionally Rotate to Fit),
|
||||||
//! draws Photo Border and Inner Stroke, fills the page background and returns [`PageRaster`]s.
|
//! draws Photo Border and Inner Stroke, fills the page background, adds the Page Options (crop
|
||||||
|
//! marks, page number, page info) and returns [`PageRaster`]s.
|
||||||
//!
|
//!
|
||||||
//! Work is split like an export: [`prepare`] reads the session (which photos go in which cell, one
|
//! Work is split like an export: [`prepare`] reads the session (which photos go in which cell, one
|
||||||
//! render job per cell) and [`Prepared::run`] does the heavy part without it, so it can run on a
|
//! render job per cell) and [`Prepared::run`] does the heavy part without it, so it can run on a
|
||||||
@@ -28,6 +29,13 @@ pub const MAX_BORDER_IN: f64 = 2.0;
|
|||||||
pub const MAX_STROKE_PT: f64 = 36.0;
|
pub const MAX_STROKE_PT: f64 = 36.0;
|
||||||
/// Renders kept for cells that print the same photo at the same size.
|
/// Renders kept for cells that print the same photo at the same size.
|
||||||
const RENDER_CACHE: usize = 4;
|
const RENDER_CACHE: usize = 4;
|
||||||
|
/// A crop mark's length and its gap from the photo's corner, inches.
|
||||||
|
pub const CROP_MARK_IN: f64 = 0.25;
|
||||||
|
pub const CROP_MARK_GAP_IN: f64 = 0.0625;
|
||||||
|
/// A crop mark's weight, points.
|
||||||
|
pub const CROP_MARK_PT: f64 = 0.5;
|
||||||
|
/// Size of the page number and page info text, points.
|
||||||
|
pub const PAGE_TEXT_PT: f64 = 9.0;
|
||||||
|
|
||||||
/// Lightroom's Image Settings panel.
|
/// Lightroom's Image Settings panel.
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -85,11 +93,18 @@ impl ImageSettings {
|
|||||||
pub struct PageStyle {
|
pub struct PageStyle {
|
||||||
/// Page background colour, sRGB.
|
/// Page background colour, sRGB.
|
||||||
pub background: [u8; 3],
|
pub background: [u8; 3],
|
||||||
|
/// Page Options (Single Image and Contact Sheet, as in Lightroom; the packages ignore them):
|
||||||
|
/// the page number at the bottom right of each page.
|
||||||
|
pub page_numbers: bool,
|
||||||
|
/// The print settings (sharpening, profile, resolution) at the bottom left of each page.
|
||||||
|
pub page_info: bool,
|
||||||
|
/// Marks at the corners of each photo to cut along.
|
||||||
|
pub crop_marks: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PageStyle {
|
impl Default for PageStyle {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self { background: [255; 3] }
|
Self { background: [255; 3], page_numbers: false, page_info: false, crop_marks: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +115,41 @@ pub fn rec2020_from_srgb8(c: [u8; 3]) -> [u16; 3] {
|
|||||||
lin.map(|v| (trc.encode(v) * 65535.0 + 0.5) as u16)
|
lin.map(|v| (trc.encode(v) * 65535.0 + 0.5) as u16)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a layout style prints the Page Options (page numbers, page info, crop marks):
|
||||||
|
/// Lightroom offers them for Single Image and Contact Sheet; its packages have Cut Guides instead.
|
||||||
|
pub fn has_page_options(style: Style) -> bool {
|
||||||
|
!style.is_package()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The colour of crop marks and page text on `background`: black, or white on a dark page.
|
||||||
|
pub fn mark_ink(background: [u8; 3]) -> [u8; 3] {
|
||||||
|
let [r, g, b] = background.map(f64::from);
|
||||||
|
if 0.2126 * r + 0.7152 * g + 0.0722 * b < 128.0 { [255; 3] } else { [0; 3] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Page Info line: print sharpening, profile and resolution (Lightroom names the printer
|
||||||
|
/// where the resolution is; this print goes to a PDF).
|
||||||
|
pub fn page_info_text(settings: &PrintSettings) -> String {
|
||||||
|
use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen};
|
||||||
|
let c = &settings.job.color;
|
||||||
|
let media = match c.media {
|
||||||
|
PrintMedia::Matte => "Matte",
|
||||||
|
PrintMedia::Glossy => "Glossy",
|
||||||
|
};
|
||||||
|
let sharpen = match c.sharpen {
|
||||||
|
PrintSharpen::Off => "Off".to_string(),
|
||||||
|
PrintSharpen::Low => format!("Low ({media})"),
|
||||||
|
PrintSharpen::Standard => format!("Standard ({media})"),
|
||||||
|
PrintSharpen::High => format!("High ({media})"),
|
||||||
|
};
|
||||||
|
let profile = match &c.profile {
|
||||||
|
PrintProfile::Managed { space: ManagedSpace::Srgb } => "Managed by Printer (sRGB)".to_string(),
|
||||||
|
PrintProfile::Managed { space: ManagedSpace::AdobeRgb } => "Managed by Printer (Adobe RGB)".to_string(),
|
||||||
|
PrintProfile::Icc { path } => path.file_stem().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()),
|
||||||
|
};
|
||||||
|
format!("Print Sharpening: {sharpen} · Profile: {profile} · {:.0} ppi", settings.job.effective_ppi())
|
||||||
|
}
|
||||||
|
|
||||||
// ---- placement ------------------------------------------------------------------------------
|
// ---- placement ------------------------------------------------------------------------------
|
||||||
|
|
||||||
/// How a photo of `iw` x `ih` goes into an `aw` x `ah` area.
|
/// How a photo of `iw` x `ih` goes into an `aw` x `ah` area.
|
||||||
@@ -355,8 +405,8 @@ impl CellStyle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Draws `photo` into `cell` of `page`: border frame, the photo fitted or filled (and turned when
|
/// Draws `photo` into `cell` of `page`: border frame, the photo fitted or filled (and turned when
|
||||||
/// asked), then the inner stroke on top of it.
|
/// asked), then the inner stroke on top of it. Returns where the photo landed.
|
||||||
pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<(), String> {
|
pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<PxRect, String> {
|
||||||
// The border may not eat the cell: at least one pixel of photo area stays.
|
// The border may not eat the cell: at least one pixel of photo area stays.
|
||||||
let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2);
|
let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2);
|
||||||
let (ax, ay) = (cell.x + border, cell.y + border);
|
let (ax, ay) = (cell.x + border, cell.y + border);
|
||||||
@@ -382,7 +432,105 @@ pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &Cell
|
|||||||
page.fill_rect(dx, dy + s, s, h - 2 * s, style.stroke);
|
page.fill_rect(dx, dy + s, s, h - 2 * s, style.stroke);
|
||||||
page.fill_rect(dx + w - s, dy + s, s, h - 2 * s, style.stroke);
|
page.fill_rect(dx + w - s, dy + s, s, h - 2 * s, style.stroke);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(PxRect { x: dx, y: dy, w, h })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the Page Options draw on every page, in page pixels.
|
||||||
|
struct Marks {
|
||||||
|
/// Crop mark length, gap from the corner and weight.
|
||||||
|
crop: Option<(usize, usize, usize)>,
|
||||||
|
numbers: bool,
|
||||||
|
info: Option<String>,
|
||||||
|
ink: [u16; 3],
|
||||||
|
text_px: f32,
|
||||||
|
/// The printable area: the text sits in the margin below it, aligned with its sides.
|
||||||
|
area: Option<PxRect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Marks {
|
||||||
|
fn of(settings: &PrintSettings, ppi: f64, pw: usize, ph: usize) -> Self {
|
||||||
|
let on = has_page_options(settings.layout.style);
|
||||||
|
let page = &settings.page;
|
||||||
|
let px = |inches: f64| (inches * ppi).round() as usize;
|
||||||
|
Marks {
|
||||||
|
crop: (on && page.crop_marks).then(|| (px(CROP_MARK_IN), px(CROP_MARK_GAP_IN), px(CROP_MARK_PT / 72.0).max(1))),
|
||||||
|
numbers: on && page.page_numbers,
|
||||||
|
info: (on && page.page_info).then(|| page_info_text(settings)),
|
||||||
|
ink: rec2020_from_srgb8(mark_ink(page.background)),
|
||||||
|
text_px: (PAGE_TEXT_PT / 72.0 * ppi) as f32,
|
||||||
|
area: settings.layout.printable().ok().and_then(|a| px_rect(&a, ppi, pw, ph)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws the marks of page `number` (one-based) around `photos`, the photos on it.
|
||||||
|
fn draw(&self, page: &mut Image16, photos: &[PxRect], number: usize) {
|
||||||
|
if let Some((len, gap, weight)) = self.crop {
|
||||||
|
for r in photos {
|
||||||
|
crop_marks(page, r, len, gap, weight, photos, self.ink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let area = self.area.unwrap_or(PxRect { x: 0, y: 0, w: page.w, h: page.h });
|
||||||
|
let (area_bottom, page_h) = ((area.y + area.h) as f32, page.h as f32);
|
||||||
|
// centred in the bottom margin, and on the paper when the margin is thinner than the text
|
||||||
|
let top = |h: f32| (area_bottom + (page_h - area_bottom - h) / 2.0).clamp(0.0, (page_h - h).max(0.0));
|
||||||
|
if self.numbers
|
||||||
|
&& let Some(t) = crate::text::TextLayout::new(&number.to_string(), self.text_px, false, crate::fonts::CRAFT_FONTS)
|
||||||
|
{
|
||||||
|
let x = ((area.x + area.w) as f32 - t.width).max(0.0);
|
||||||
|
draw_text(page, &t, x, top(t.height), self.ink);
|
||||||
|
}
|
||||||
|
if let Some(info) = &self.info
|
||||||
|
&& let Some(t) = crate::text::TextLayout::new(info, self.text_px, false, crate::fonts::CRAFT_FONTS)
|
||||||
|
{
|
||||||
|
draw_text(page, &t, area.x as f32, top(t.height), self.ink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crop marks for the photo at `r`: at each corner, a line along each of its edges that starts
|
||||||
|
/// `gap` past the corner and runs `len` outward, `weight` thick. Marks never cover a photo.
|
||||||
|
fn crop_marks(page: &mut Image16, r: &PxRect, len: usize, gap: usize, weight: usize, photos: &[PxRect], ink: [u16; 3]) {
|
||||||
|
let (x0, y0, x1, y1) = (r.x as i64, r.y as i64, (r.x + r.w) as i64, (r.y + r.h) as i64);
|
||||||
|
let (l, g, t) = (len as i64, gap as i64, weight.min(r.w).min(r.h) as i64);
|
||||||
|
let segments = [
|
||||||
|
(x0 - g - l, y0, l, t),
|
||||||
|
(x1 + g, y0, l, t),
|
||||||
|
(x0 - g - l, y1 - t, l, t),
|
||||||
|
(x1 + g, y1 - t, l, t),
|
||||||
|
(x0, y0 - g - l, t, l),
|
||||||
|
(x0, y1 + g, t, l),
|
||||||
|
(x1 - t, y0 - g - l, t, l),
|
||||||
|
(x1 - t, y1 + g, t, l),
|
||||||
|
];
|
||||||
|
let on_photo = |x: usize, y: usize| photos.iter().any(|p| x >= p.x && x < p.x + p.w && y >= p.y && y < p.y + p.h);
|
||||||
|
for (sx, sy, sw, sh) in segments {
|
||||||
|
for y in sy.max(0)..(sy + sh).min(page.h as i64) {
|
||||||
|
for x in sx.max(0)..(sx + sw).min(page.w as i64) {
|
||||||
|
let (x, y) = (x as usize, y as usize);
|
||||||
|
if !on_photo(x, y) {
|
||||||
|
page.fill_rect(x, y, 1, 1, ink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blends text in `ink` into `page` with its top-left corner at (`x`, `y`).
|
||||||
|
fn draw_text(page: &mut Image16, text: &crate::text::TextLayout, x: f32, y: f32, ink: [u16; 3]) {
|
||||||
|
let (w, h) = (page.w, page.h);
|
||||||
|
text.draw_in(x, y, w as f32, h as f32, |gx, gy, cov| {
|
||||||
|
let (Ok(gx), Ok(gy)) = (usize::try_from(gx), usize::try_from(gy)) else { return };
|
||||||
|
if gx >= w || gy >= h {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let i = (gy * w + gx) * 3;
|
||||||
|
if let Some(px) = page.rgb.get_mut(i..i + 3) {
|
||||||
|
let k = cov.clamp(0.0, 1.0);
|
||||||
|
for (p, c) in px.iter_mut().zip(ink) {
|
||||||
|
*p = (f32::from(*p) + (f32::from(c) - f32::from(*p)) * k).round().clamp(0.0, 65535.0) as u16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- the job ---------------------------------------------------------------------------------
|
// ---- the job ---------------------------------------------------------------------------------
|
||||||
@@ -402,6 +550,7 @@ pub struct Prepared {
|
|||||||
ppi: f32,
|
ppi: f32,
|
||||||
background: [u16; 3],
|
background: [u16; 3],
|
||||||
style: CellStyle,
|
style: CellStyle,
|
||||||
|
marks: Marks,
|
||||||
/// Print sharpening, applied to each photo once it's rendered at its print size.
|
/// Print sharpening, applied to each photo once it's rendered at its print size.
|
||||||
color: crate::print_color::PrintColorSettings,
|
color: crate::print_color::PrintColorSettings,
|
||||||
}
|
}
|
||||||
@@ -430,6 +579,7 @@ impl Prepared {
|
|||||||
let mut out = Vec::with_capacity(self.pages.len());
|
let mut out = Vec::with_capacity(self.pages.len());
|
||||||
for (index, &(w, h)) in self.pages.iter().enumerate() {
|
for (index, &(w, h)) in self.pages.iter().enumerate() {
|
||||||
let mut page = Image16::filled(w, h, self.background)?;
|
let mut page = Image16::filled(w, h, self.background)?;
|
||||||
|
let mut placed = Vec::new();
|
||||||
while let Some(item) = items.next_if(|i| i.page == index) {
|
while let Some(item) = items.next_if(|i| i.page == index) {
|
||||||
if !progress(done, &item.name) {
|
if !progress(done, &item.name) {
|
||||||
return Err("Print cancelled".into());
|
return Err("Print cancelled".into());
|
||||||
@@ -448,8 +598,9 @@ impl Prepared {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let photo = &cache.get(at).ok_or("render cache lost an entry")?.1;
|
let photo = &cache.get(at).ok_or("render cache lost an entry")?.1;
|
||||||
draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?;
|
placed.push(draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?);
|
||||||
}
|
}
|
||||||
|
self.marks.draw(&mut page, &placed, index + 1);
|
||||||
out.push(PageRaster { width: w as u32, height: h as u32, ppi: self.ppi, rgb: page.rgb });
|
out.push(PageRaster { width: w as u32, height: h as u32, ppi: self.ppi, rgb: page.rgb });
|
||||||
}
|
}
|
||||||
if !progress(done, "") {
|
if !progress(done, "") {
|
||||||
@@ -508,6 +659,7 @@ pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoI
|
|||||||
ppi: ppi as f32,
|
ppi: ppi as f32,
|
||||||
background: rec2020_from_srgb8(settings.page.background),
|
background: rec2020_from_srgb8(settings.page.background),
|
||||||
style,
|
style,
|
||||||
|
marks: Marks::of(&settings, ppi, pw, ph),
|
||||||
color: settings.job.color.clone(),
|
color: settings.job.color.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -825,6 +977,94 @@ mod tests {
|
|||||||
assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels");
|
assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pages for two photos, one per 4x6 page at 100 ppi, each filling its 0.5 in margins (so the
|
||||||
|
/// margins are bare background unless something draws there).
|
||||||
|
fn page_options_pages(s: &mut Session, style: Style, page: PageStyle) -> Vec<PageRaster> {
|
||||||
|
let ids = first_ids(s, 2);
|
||||||
|
let mut set = settings(style);
|
||||||
|
if style.is_package() {
|
||||||
|
set.layout.add_cell(3.0, 5.0).unwrap();
|
||||||
|
set.layout.new_page().unwrap();
|
||||||
|
let id = set.layout.add_cell(3.0, 5.0).unwrap();
|
||||||
|
set.layout.set_cell_page(id, 1).unwrap();
|
||||||
|
}
|
||||||
|
set.image.zoom_to_fill = true;
|
||||||
|
set.page = page;
|
||||||
|
prepare(s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rgb(p: &PageRaster, x: usize, y: usize) -> [u16; 3] {
|
||||||
|
let i = (y * p.width as usize + x) * 3;
|
||||||
|
[p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pixels of `x0..x1` x `y0..y1` that are not the white background.
|
||||||
|
fn inked(p: &PageRaster, x0: usize, x1: usize, y0: usize, y1: usize) -> usize {
|
||||||
|
(y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| rgb(p, x, y) != WHITE).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crop_marks_sit_outside_each_photo_corner() {
|
||||||
|
let mut s = Session::with_demo();
|
||||||
|
let plain = page_options_pages(&mut s, Style::SingleImage, PageStyle::default());
|
||||||
|
assert_eq!(inked(&plain[0], 0, 400, 0, 50), 0, "no marks unless asked");
|
||||||
|
let pages = page_options_pages(&mut s, Style::SingleImage, PageStyle { crop_marks: true, ..Default::default() });
|
||||||
|
// The photo fills 50..350 x 50..550; a mark is a line 0.25 in long, 1/16 in from the corner,
|
||||||
|
// along each edge of the photo.
|
||||||
|
for p in &pages {
|
||||||
|
for (x, y) in [(30, 50), (50, 30), (370, 50), (349, 30), (30, 549), (50, 570), (370, 549), (349, 570)] {
|
||||||
|
assert_eq!(rgb(p, x, y), [0; 3], "a mark at ({x}, {y})");
|
||||||
|
}
|
||||||
|
for (x, y) in [(47, 50), (50, 47), (30, 60), (60, 30), (10, 50), (50, 10)] {
|
||||||
|
assert_eq!(rgb(p, x, y), WHITE, "the gap, or past the mark, at ({x}, {y})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// a dark page gets white marks
|
||||||
|
let dark = page_options_pages(&mut s, Style::SingleImage, PageStyle { background: [20; 3], crop_marks: true, ..Default::default() });
|
||||||
|
assert_eq!(rgb(&dark[0], 30, 50), WHITE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_numbers_and_info_print_in_the_bottom_margin() {
|
||||||
|
let mut s = Session::with_demo();
|
||||||
|
let numbered = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_numbers: true, ..Default::default() });
|
||||||
|
// bottom right: the number, different on each page; nothing at the bottom left
|
||||||
|
let (right, left) = ((200, 400), (0, 200));
|
||||||
|
for p in &numbered {
|
||||||
|
assert!(inked(p, right.0, right.1, 550, 600) > 10, "a page number");
|
||||||
|
assert_eq!(inked(p, left.0, left.1, 550, 600), 0);
|
||||||
|
assert_eq!(inked(p, 0, 400, 0, 50), 0, "nothing at the top");
|
||||||
|
}
|
||||||
|
let region = |p: &PageRaster| (550..600).flat_map(|y| (right.0..right.1).map(move |x| (x, y))).map(|(x, y)| rgb(p, x, y)).collect::<Vec<_>>();
|
||||||
|
assert_ne!(region(&numbered[0]), region(&numbered[1]), "page 1 and page 2 are numbered differently");
|
||||||
|
let info = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_info: true, ..Default::default() });
|
||||||
|
for p in &info {
|
||||||
|
assert!(inked(p, left.0, left.1, 550, 600) > 10, "the print settings line");
|
||||||
|
}
|
||||||
|
// the packages have no Page Options in Lightroom: nothing is drawn
|
||||||
|
let all = PageStyle { page_numbers: true, page_info: true, crop_marks: true, ..Default::default() };
|
||||||
|
let package = page_options_pages(&mut s, Style::PicturePackage, all);
|
||||||
|
assert_eq!(package.len(), 2);
|
||||||
|
for p in &package {
|
||||||
|
assert_eq!(inked(p, 0, 400, 550, 600) + inked(p, 0, 400, 0, 50), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_info_names_the_sharpening_profile_and_resolution() {
|
||||||
|
use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen};
|
||||||
|
let mut set = PrintSettings::default();
|
||||||
|
let line = page_info_text(&set);
|
||||||
|
assert_eq!(line, "Print Sharpening: Standard (Glossy) · Profile: Managed by Printer (sRGB) · 240 ppi");
|
||||||
|
set.job.color.sharpen = PrintSharpen::Off;
|
||||||
|
set.job.color.media = PrintMedia::Matte;
|
||||||
|
set.job.color.profile = PrintProfile::Icc { path: "/profiles/Lustre Paper.icc".into() };
|
||||||
|
set.job.ppi = 300.0;
|
||||||
|
assert_eq!(page_info_text(&set), "Print Sharpening: Off · Profile: Lustre Paper · 300 ppi");
|
||||||
|
set.job.color.profile = PrintProfile::Managed { space: ManagedSpace::AdobeRgb };
|
||||||
|
assert!(page_info_text(&set).contains("Managed by Printer (Adobe RGB)"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn caps_and_hostile_settings_are_refused() {
|
fn caps_and_hostile_settings_are_refused() {
|
||||||
let mut s = Session::with_demo();
|
let mut s = Session::with_demo();
|
||||||
|
|||||||
@@ -341,3 +341,23 @@ fn every_print_command_is_registered_with_a_description() {
|
|||||||
assert!(c.params.len() > 3, "{id}");
|
assert!(c.params.len() > 3, "{id}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_options_are_print_settings_and_template_fields() {
|
||||||
|
let mut s = Session::with_demo();
|
||||||
|
let g = run(&mut s, "print.get", json!({}));
|
||||||
|
assert_eq!(g["settings"]["page"], json!({"background": [255, 255, 255], "pageNumbers": false, "pageInfo": false, "cropMarks": false}));
|
||||||
|
let g = run(&mut s, "print.set", json!({"page": {"pageNumbers": true, "cropMarks": true}}));
|
||||||
|
assert_eq!((g["settings"]["page"]["pageNumbers"].as_bool(), g["settings"]["page"]["pageInfo"].as_bool()), (Some(true), Some(false)));
|
||||||
|
assert_eq!(g["settings"]["page"]["background"], json!([255, 255, 255]), "a patch merges");
|
||||||
|
fails(&mut s, "print.set", json!({"page": {"pageInfo": "yes"}}));
|
||||||
|
fails(&mut s, "print.set", json!({"page": {"pageNumber": true}}));
|
||||||
|
// a template keeps them
|
||||||
|
run(&mut s, "print.template.save", json!({"name": "Marked"}));
|
||||||
|
run(&mut s, "print.reset", json!({}));
|
||||||
|
assert!(!s.print.page.crop_marks);
|
||||||
|
run(&mut s, "print.template.apply", json!({"name": "Marked"}));
|
||||||
|
assert!(s.print.page.page_numbers && s.print.page.crop_marks && !s.print.page.page_info);
|
||||||
|
let set = crate::find_command("print.set").unwrap();
|
||||||
|
assert!(set.params.contains("pageNumbers") && set.params.contains("pageInfo") && set.params.contains("cropMarks"), "{}", set.params);
|
||||||
|
}
|
||||||
|
|||||||
@@ -420,6 +420,19 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi,
|
|||||||
patch(app, &ctx, json!({"page": {"background": c}}));
|
patch(app, &ctx, json!({"page": {"background": c}}));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if compose::has_page_options(l.style) {
|
||||||
|
ui.label(egui::RichText::new(crate::i18n::tr("Page Options")).color(t.text_label));
|
||||||
|
let p = &st.page;
|
||||||
|
for (id, label, on, key) in [
|
||||||
|
("printPageNumbers", "Page Numbers", p.page_numbers, "pageNumbers"),
|
||||||
|
("printPageInfo", "Page Info", p.page_info, "pageInfo"),
|
||||||
|
("printCropMarks", "Crop Marks", p.crop_marks, "cropMarks"),
|
||||||
|
] {
|
||||||
|
if let Some(v) = check(ui, id, label, on) {
|
||||||
|
patch(app, &ctx, json!({"page": {(key): v}}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
heading(ui, t, "Print Job");
|
heading(ui, t, "Print Job");
|
||||||
row(ui, t, "Resolution", |ui| {
|
row(ui, t, "Resolution", |ui| {
|
||||||
@@ -607,11 +620,13 @@ fn preview(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, s
|
|||||||
Some(page) => page.iter().map(|(r, id)| (*r, Some(*id))).collect(),
|
Some(page) => page.iter().map(|(r, id)| (*r, Some(*id))).collect(),
|
||||||
None => st.layout.grid_rects().unwrap_or_default().into_iter().map(|r| (r, None)).collect(),
|
None => st.layout.grid_rects().unwrap_or_default().into_iter().map(|r| (r, None)).collect(),
|
||||||
};
|
};
|
||||||
|
let mut shown = Vec::new();
|
||||||
for (i, (r, photo)) in cells.iter().enumerate() {
|
for (i, (r, photo)) in cells.iter().enumerate() {
|
||||||
let rect = sheet.rect(r);
|
let rect = sheet.rect(r);
|
||||||
register(&ctx, format!("print:gridcell:{i}"), rect);
|
register(&ctx, format!("print:gridcell:{i}"), rect);
|
||||||
paint_cell(app, &painter, t, st, rect, *photo, &sheet, r);
|
shown.extend(paint_cell(app, &painter, t, st, rect, *photo, &sheet, r));
|
||||||
}
|
}
|
||||||
|
page_options(&painter, st, &sheet, &shown, pu.page + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// footer: page navigation and the selected cell
|
// footer: page navigation and the selected cell
|
||||||
@@ -782,7 +797,7 @@ fn paint_cell(
|
|||||||
photo: Option<PhotoId>,
|
photo: Option<PhotoId>,
|
||||||
sheet: &Sheet,
|
sheet: &Sheet,
|
||||||
inches: &layout::Rect,
|
inches: &layout::Rect,
|
||||||
) {
|
) -> Option<Rect> {
|
||||||
let im = &st.image;
|
let im = &st.image;
|
||||||
if st.guides.show_cells {
|
if st.guides.show_cells {
|
||||||
painter.rect_stroke(cell, 0.0, Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 150, 190, 150)), StrokeKind::Inside);
|
painter.rect_stroke(cell, 0.0, Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 150, 190, 150)), StrokeKind::Inside);
|
||||||
@@ -794,7 +809,7 @@ fn paint_cell(
|
|||||||
};
|
};
|
||||||
let avail = cell.shrink(border);
|
let avail = cell.shrink(border);
|
||||||
if avail.width() < 1.0 || avail.height() < 1.0 {
|
if avail.width() < 1.0 || avail.height() < 1.0 {
|
||||||
return;
|
return None;
|
||||||
}
|
}
|
||||||
// the photo's shape: its cropped size, or 3:2 for a placeholder
|
// the photo's shape: its cropped size, or 3:2 for a placeholder
|
||||||
let (nw, nh) = photo.and_then(|id| app.session.catalog.photo(id)).map_or((3.0, 2.0), |p| {
|
let (nw, nh) = photo.and_then(|id| app.session.catalog.photo(id)).map_or((3.0, 2.0), |p| {
|
||||||
@@ -804,7 +819,7 @@ fn paint_cell(
|
|||||||
let scale = 8.0; // sub-point resolution for the rounding in `place`
|
let scale = 8.0; // sub-point resolution for the rounding in `place`
|
||||||
let aw = (f64::from(avail.width()) * scale).round() as usize;
|
let aw = (f64::from(avail.width()) * scale).round() as usize;
|
||||||
let ah = (f64::from(avail.height()) * scale).round() as usize;
|
let ah = (f64::from(avail.height()) * scale).round() as usize;
|
||||||
let Some(p) = place(nw, nh, aw, ah, im.zoom_to_fill, im.rotate_to_fit) else { return };
|
let p = place(nw, nh, aw, ah, im.zoom_to_fill, im.rotate_to_fit)?;
|
||||||
let shown = Rect::from_center_size(avail.center(), vec2(p.w as f32, p.h as f32) / scale as f32);
|
let shown = Rect::from_center_size(avail.center(), vec2(p.w as f32, p.h as f32) / scale as f32);
|
||||||
let visible = shown.intersect(avail);
|
let visible = shown.intersect(avail);
|
||||||
if border > 0.0 {
|
if border > 0.0 {
|
||||||
@@ -856,6 +871,41 @@ fn paint_cell(
|
|||||||
painter.rect_filled(Rect::from_min_size(at, galley.size()).expand(2.0), 2.0, Color32::from_black_alpha(160));
|
painter.rect_filled(Rect::from_min_size(at, galley.size()).expand(2.0), 2.0, Color32::from_black_alpha(160));
|
||||||
painter.galley(at, galley, Color32::WHITE);
|
painter.galley(at, galley, Color32::WHITE);
|
||||||
}
|
}
|
||||||
|
Some(visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Page Options as the print draws them ([`compose`]): crop marks at the corners of each
|
||||||
|
/// photo, the page number at the bottom right and the print settings at the bottom left.
|
||||||
|
/// Packages draw none of them, as the print doesn't ([`compose::has_page_options`]).
|
||||||
|
fn page_options(painter: &egui::Painter, st: &PrintSettings, sheet: &Sheet, photos: &[Rect], number: usize) {
|
||||||
|
if !compose::has_page_options(st.layout.style) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let p = &st.page;
|
||||||
|
let ink = compose::mark_ink(p.background);
|
||||||
|
let ink = Color32::from_rgb(ink[0], ink[1], ink[2]);
|
||||||
|
if p.crop_marks {
|
||||||
|
let (len, gap) = (compose::CROP_MARK_IN as f32 * sheet.scale, compose::CROP_MARK_GAP_IN as f32 * sheet.scale);
|
||||||
|
let stroke = Stroke::new(1.0, ink);
|
||||||
|
for r in photos {
|
||||||
|
for (corner, dx, dy) in
|
||||||
|
[(r.left_top(), -1.0, -1.0), (r.right_top(), 1.0, -1.0), (r.left_bottom(), -1.0, 1.0), (r.right_bottom(), 1.0, 1.0)]
|
||||||
|
{
|
||||||
|
painter.line_segment([corner + vec2(dx * gap, 0.0), corner + vec2(dx * (gap + len), 0.0)], stroke);
|
||||||
|
painter.line_segment([corner + vec2(0.0, dy * gap), corner + vec2(0.0, dy * (gap + len))], stroke);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Ok(area) = st.layout.printable() else { return };
|
||||||
|
let area = sheet.rect(&area);
|
||||||
|
let font = egui::FontId::proportional((compose::PAGE_TEXT_PT as f32 / 72.0 * sheet.scale).max(4.0));
|
||||||
|
let y = (area.bottom() + sheet.page.bottom()) / 2.0;
|
||||||
|
if p.page_numbers {
|
||||||
|
painter.text(pos2(area.right(), y), Align2::RIGHT_CENTER, number.to_string(), font.clone(), ink);
|
||||||
|
}
|
||||||
|
if p.page_info {
|
||||||
|
painter.text(pos2(area.left(), y), Align2::LEFT_CENTER, compose::page_info_text(st), font, ink);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dashed(painter: &egui::Painter, r: Rect, stroke: Stroke) {
|
fn dashed(painter: &egui::Painter, r: Rect, stroke: Stroke) {
|
||||||
|
|||||||
@@ -378,3 +378,32 @@ fn the_print_preview_does_not_rebuild_thumbnails_every_frame() {
|
|||||||
}
|
}
|
||||||
assert!(h.app.renderer.thumb_jobs_built - before < 50, "{} jobs", h.app.renderer.thumb_jobs_built - before);
|
assert!(h.app.renderer.thumb_jobs_built - before < 50, "{} jobs", h.app.renderer.thumb_jobs_built - before);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_options_checkboxes_set_the_page_and_hide_for_packages() {
|
||||||
|
let mut h = demo(Services::default());
|
||||||
|
open_print(&mut h);
|
||||||
|
let scroll = |h: &mut Headless, dy: f32| {
|
||||||
|
let r = h.request("ui.hoverWidget", json!({"id": "combo:printPaper"}), T);
|
||||||
|
assert_eq!(r["ok"], true, "{r}");
|
||||||
|
for _ in 0..4 {
|
||||||
|
h.request("ui.scroll", json!({"dy": dy}), T);
|
||||||
|
h.settle(T);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// the Page panel is below Page Setup: scroll down to it
|
||||||
|
scroll(&mut h, -600.0);
|
||||||
|
for (id, on) in [("check:printPageNumbers", 0), ("check:printPageInfo", 1), ("check:printCropMarks", 2)] {
|
||||||
|
click(&mut h, id);
|
||||||
|
let p = &h.app.session.print.page;
|
||||||
|
assert_eq!([p.page_numbers, p.page_info, p.crop_marks].iter().filter(|v| **v).count(), on + 1, "{id}");
|
||||||
|
}
|
||||||
|
let p = &h.app.session.print.page;
|
||||||
|
assert!(p.page_numbers && p.page_info && p.crop_marks);
|
||||||
|
scroll(&mut h, 600.0);
|
||||||
|
click(&mut h, "button:printStyle-1");
|
||||||
|
assert!(has(&h, "check:printCropMarks"), "a contact sheet has Page Options");
|
||||||
|
// Lightroom's packages have no Page Options
|
||||||
|
click(&mut h, "button:printStyle-2");
|
||||||
|
assert!(!has(&h, "check:printPageNumbers") && !has(&h, "check:printCropMarks"));
|
||||||
|
}
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
# Gaps: where LightCraft falls short of Lightroom
|
# Gaps: where LightCraft falls short of Lightroom
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (gap 10: 515 of 1,446 models verified, ~84% of photographers on a verified body)
|
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (gap 15: Print Page Options — page numbers, page info, crop marks)
|
||||||
|
|
||||||
Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and
|
Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and
|
||||||
how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is
|
how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is
|
||||||
@@ -29,7 +29,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
|
|||||||
| 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) |
|
| 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) |
|
||||||
| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; offline record counts and destination are now shown in a lasting import result | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) |
|
| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; offline record counts and destination are now shown in a lasting import result | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) |
|
||||||
| 14 | **HDR display** (EDR / Windows HDR); the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read). HLG AVIF export done; the display's SDR/HDR decision is tested, blocked on eframe's 8-bit surface | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) |
|
| 14 | **HDR display** (EDR / Windows HDR); the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read). HLG AVIF export done; the display's SDR/HDR decision is tested, blocked on eframe's 8-bit surface | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) |
|
||||||
| 15 | **Print module**: identity plate / watermark / page numbers / crop marks / photo info overlays, Custom Package Auto Layout, printer-driver Page Setup and direct printing, JPEG file output (the Print dialog writes a colour-managed PDF) | LRC-PRINT rows | Classic users who print | 15–25 | [target-app-parity](target-app-parity.md#features) |
|
| 15 | **Print module**: identity plate / watermark / photo info overlays, Cut Guides for the packages, Custom Package Auto Layout, printer-driver Page Setup and direct printing, JPEG file output (the Print dialog writes a colour-managed PDF) | LRC-PRINT rows | Classic users who print | 15–25 | [target-app-parity](target-app-parity.md#features) |
|
||||||
| 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) |
|
| 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) |
|
||||||
| 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) |
|
| 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) |
|
||||||
| 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) |
|
| 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) |
|
||||||
@@ -78,6 +78,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
|
|||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 2026-10-18 | patch | Gap 1: six X-Trans CMOS 4 Fujifilm bodies (X-T30 …) take the X-T3's spectral matrices instead of the neutral matrix |
|
| 2026-10-18 | patch | Gap 1: six X-Trans CMOS 4 Fujifilm bodies (X-T30 …) take the X-T3's spectral matrices instead of the neutral matrix |
|
||||||
|
| 2026-10-15 | minor | Gap 15 shrinks: the Page panel's Page Options (Page Numbers, Page Info, Crop Marks) print on Single Image and Contact Sheet pages; identity plate, watermark, photo info, Cut Guides, direct printing and JPEG output remain |
|
||||||
| 2026-10-14 | minor | Gap 26: at 100k photos stack and album lookups are indexed, so an "In a Stack" or "Album" smart album counts in ~14 ms (was 0.6–2.7 s); open time unchanged |
|
| 2026-10-14 | minor | Gap 26: at 100k photos stack and album lookups are indexed, so an "In a Stack" or "Album" smart album counts in ~14 ms (was 0.6–2.7 s); open time unchanged |
|
||||||
| 2026-10-14 | minor | Gap 10: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277) |
|
| 2026-10-14 | minor | Gap 10: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277) |
|
||||||
| 2026-10-14 | minor | Gap 21: de, es, fr, ja and uk catalogs filled (97–99%); 55–90 h → 40–70 h |
|
| 2026-10-14 | minor | Gap 21: de, es, fr, ja and uk catalogs filled (97–99%); 55–90 h → 40–70 h |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Lightroom parity checklist
|
# Lightroom parity checklist
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (LRC-PRINT-PAGE: Print Page Options) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||||
|
|
||||||
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
|
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
|
||||||
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
|
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
|
||||||
@@ -762,7 +762,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
|||||||
| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:print.set`, `cmd:print.layout.autoLayout`, `cmd:print.layout.newPage`, `cmd:print.layout.clear`, `cmd:app.print`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | A3/A4/A5/Letter/Legal/Tabloid/4x6/5x7/8x10/13x19 and custom paper, portrait/landscape, per-side margins, grid with column/row spacing and square cells, 72–720 ppi, in a Print dialog with a scaled page preview; no printer-driver Page Setup, no cell size / alignment presets |
|
| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:print.set`, `cmd:print.layout.autoLayout`, `cmd:print.layout.newPage`, `cmd:print.layout.clear`, `cmd:app.print`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | A3/A4/A5/Letter/Legal/Tabloid/4x6/5x7/8x10/13x19 and custom paper, portrait/landscape, per-side margins, grid with column/row spacing and square cells, 72–720 ppi, in a Print dialog with a scaled page preview; no printer-driver Page Setup, no cell size / alignment presets |
|
||||||
| LRC-PRINT-GUIDES | Print guides | P2 | ✅ | `cmd:print.set`, `cmd:print.cell.move`, `crates/ui-egui/src/print_view.rs` | rulers in in/cm/mm/pt, snap off/cells/grid with a grid size, and Page Margins / Page Bleed (a fixed 0.125 in outline) / Page Grid / Image Cells / Dimensions guides drawn on the preview |
|
| LRC-PRINT-GUIDES | Print guides | P2 | ✅ | `cmd:print.set`, `cmd:print.cell.move`, `crates/ui-egui/src/print_view.rs` | rulers in in/cm/mm/pt, snap off/cells/grid with a grid size, and Page Margins / Page Bleed (a fixed 0.125 in outline) / Page Grid / Image Cells / Dimensions guides drawn on the preview |
|
||||||
| LRC-PRINT-CELLS | Picture-package cells | P2 | ✅ | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout`, `crates/ui-egui/src/print_view.rs` | add cells at standard or custom sizes, drag and resize them on the page preview (snap to cells, grid or off), Send to Back / Backward / Forward / Bring to Front, a photo per cell (Custom Package), Auto Layout packing, New Page, Clear Layout; no Auto Layout for Custom Package |
|
| LRC-PRINT-CELLS | Picture-package cells | P2 | ✅ | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout`, `crates/ui-egui/src/print_view.rs` | add cells at standard or custom sizes, drag and resize them on the page preview (snap to cells, grid or off), Send to Back / Backward / Forward / Bring to Front, a photo per cell (Custom Package), Auto Layout packing, New Page, Clear Layout; no Auto Layout for Custom Package |
|
||||||
| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | page background colour and margins in the Print dialog; filename captions in the contact-sheet PDF; no identity plate, watermark, page numbers, crop marks or photo info |
|
| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | page background colour and margins in the Print dialog; Page Options for Single Image and Contact Sheet (`print.set {page: {pageNumbers, pageInfo, cropMarks}}`, checkboxes in the Page section, kept in templates): the page number at the bottom right, the print settings line (sharpening, profile, resolution) at the bottom left, crop marks at each photo's corners, drawn in the preview and in the PDF (`print::compose::tests::crop_marks_sit_outside_each_photo_corner`); filename captions in the contact-sheet PDF; no identity plate, watermark, photo info or package Cut Guides |
|
||||||
| LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:print.set`, `cmd:print.exportPdf`, `crates/ui-egui/src/print_view.rs`, `crates/engine/src/print_color.rs`, `crates/codecs/src/print.rs`, `crates/engine/src/pdf.rs`, `cmd:export.contactSheet` | the Print dialog's Print Job panel and `print.set {job}`: print resolution 72–720 ppi; colour management: Managed by Printer (sRGB / Adobe RGB, profile embedded) or a printer / paper ICC profile (RGB, CMYK or grey) converted here and named as the PDF's output intent; perceptual / relative colorimetric with black point compensation; Print Adjustment brightness / contrast; print sharpening Low / Standard / High for Matte / Glossy; 16-bit (or 8-bit) ICC-tagged lossless page images; a bad profile fails before rendering (`tests_print::export_pdf_with_a_printer_profile`). The contact sheet keeps its fixed 150 dpi sRGB JPEG PDF. Missing: printing to a printer (OS print dialog), JPEG file output, Draft Mode |
|
| LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:print.set`, `cmd:print.exportPdf`, `crates/ui-egui/src/print_view.rs`, `crates/engine/src/print_color.rs`, `crates/codecs/src/print.rs`, `crates/engine/src/pdf.rs`, `cmd:export.contactSheet` | the Print dialog's Print Job panel and `print.set {job}`: print resolution 72–720 ppi; colour management: Managed by Printer (sRGB / Adobe RGB, profile embedded) or a printer / paper ICC profile (RGB, CMYK or grey) converted here and named as the PDF's output intent; perceptual / relative colorimetric with black point compensation; Print Adjustment brightness / contrast; print sharpening Low / Standard / High for Matte / Glossy; 16-bit (or 8-bit) ICC-tagged lossless page images; a bad profile fails before rendering (`tests_print::export_pdf_with_a_printer_profile`). The contact sheet keeps its fixed 150 dpi sRGB JPEG PDF. Missing: printing to a printer (OS print dialog), JPEG file output, Draft Mode |
|
||||||
| LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete`, `crates/ui-egui/src/print_view.rs` | save, apply and delete named templates (layout, image settings, page, job) at the top of the Print dialog, kept with the library; no built-in templates, no folders, no import / export of template files |
|
| LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete`, `crates/ui-egui/src/print_view.rs` | save, apply and delete named templates (layout, image settings, page, job) at the top of the Print dialog, kept with the library; no built-in templates, no folders, no import / export of template files |
|
||||||
| LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | |
|
| LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | |
|
||||||
@@ -798,6 +798,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
|||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 2026-10-14 | patch | LR-EXP-TYPE: opaque 8-bit PNG/TIFF/WebP use RGB; transparent exports retain alpha (#1670). Statuses and feature counts unchanged. |
|
| 2026-10-14 | patch | LR-EXP-TYPE: opaque 8-bit PNG/TIFF/WebP use RGB; transparent exports retain alpha (#1670). Statuses and feature counts unchanged. |
|
||||||
|
| 2026-10-15 | minor | LRC-PRINT-PAGE: Page Options (Page Numbers, Page Info, Crop Marks) on Single Image and Contact Sheet pages, in the Print dialog, `print.set` and templates; still 🟡 (no identity plate, watermark, photo info or Cut Guides) |
|
||||||
| 2026-10-12 | minor | LR-EDIT-OPTICS-PROFILE: Sony ARW shading, CA and distortion tables decoded with settings and provenance (raw IFD, `SR2SubIFD` fallback); applied corrections unchanged (ILCE-7RM4A and ILCE-7RM6 distortion); status unchanged (🟡) |
|
| 2026-10-12 | minor | LR-EDIT-OPTICS-PROFILE: Sony ARW shading, CA and distortion tables decoded with settings and provenance (raw IFD, `SR2SubIFD` fallback); applied corrections unchanged (ILCE-7RM4A and ILCE-7RM6 distortion); status unchanged (🟡) |
|
||||||
| 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); status unchanged (🟡) |
|
| 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); status unchanged (🟡) |
|
||||||
| 2026-10-13 | minor | LR-MERGE-PANO: a panorama's DNG takes its `AsShotNeutral` from the median of its frames' vendor white-balance multipliers (CR2, NEF, ARW…) instead of the first frame's tags, and frames now carry the multipliers (issue #1144); status unchanged (✅) |
|
| 2026-10-13 | minor | LR-MERGE-PANO: a panorama's DNG takes its `AsShotNeutral` from the median of its frames' vendor white-balance multipliers (CR2, NEF, ARW…) instead of the first frame's tags, and frames now carry the multipliers (issue #1144); status unchanged (✅) |
|
||||||
|
|||||||
Reference in New Issue
Block a user