// Copyright (c) 2023-2024 Sophie Herold // Copyright (c) 2023 Julian Hofer // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // // SPDX-License-Identifier: GPL-3.0-or-later //! The print layout widget that also coordinates the print process use std::cell::{Cell, OnceCell, RefCell}; use std::rc::Rc; use adw::prelude::*; use adw::subclass::prelude::*; use glib::Properties; use gtk::CompositeTemplate; use crate::decoder::{self}; use crate::deps::*; use crate::util::gettext::*; use crate::widgets::{LpImage, LpPrintPreview}; #[derive(Default, Clone, Copy)] struct PageAlignment { horizontal: HAlignment, vertical: VAlignment, } #[derive(Default, Clone, Copy)] enum HAlignment { Left, #[default] Center, Right, } #[derive(Default, Clone, Copy)] enum VAlignment { Top, #[default] Middle, Bottom, } /// Scope guard for non user ui changes /// /// Creates a context in which other signals know that changes are not user /// input. This avoids things like loops between value change signals. #[derive(Default, Clone, Debug)] struct UiUpdates { disabled: Rc>, } impl UiUpdates { pub fn disable(&self) -> Self { self.disabled.set(self.disabled.get().saturating_add(1)); self.clone() } } impl Drop for UiUpdates { fn drop(&mut self) { self.disabled.set(self.disabled.get().saturating_sub(1)); } } impl From<&str> for PageAlignment { fn from(s: &str) -> Self { let alignment = match s { "top" => (HAlignment::Center, VAlignment::Top), "center" => (HAlignment::Center, VAlignment::Middle), "bottom" => (HAlignment::Center, VAlignment::Bottom), "left" => (HAlignment::Left, VAlignment::Middle), "right" => (HAlignment::Right, VAlignment::Middle), pos => { log::error!("Unknown alignment '{pos}'"); (HAlignment::default(), VAlignment::default()) } }; Self::from(alignment) } } impl From<(HAlignment, VAlignment)> for PageAlignment { fn from((horizontal, vertical): (HAlignment, VAlignment)) -> Self { Self { horizontal, vertical, } } } impl std::fmt::Display for PageAlignment { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { let s = match (self.horizontal, self.vertical) { (HAlignment::Center, VAlignment::Top) => gettext("Top"), (HAlignment::Center, VAlignment::Middle) => gettext("Center"), (HAlignment::Center, VAlignment::Bottom) => gettext("Bottom"), (HAlignment::Left, VAlignment::Middle) => gettext("Left"), (HAlignment::Right, VAlignment::Middle) => gettext("Right"), _ => String::from("Unsupported (Error)"), }; f.write_str(&s) } } #[derive(Default, Clone, Copy)] enum Unit { Centimeter, Inch, #[default] Pixel, Percent, } impl> From for Unit { fn from(s: T) -> Self { match s.as_ref() { "cm" => Self::Centimeter, "in" => Self::Inch, "px" => Self::Pixel, "%" => Self::Percent, unit => { log::error!("Unknown unit '{unit}'"); Self::default() } } } } impl std::fmt::Display for Unit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { let s = match self { // Translators: Shorthand for centimeter Self::Centimeter => gettext("cm"), // Translators: Shorthand for inch Self::Inch => gettext("in"), // Translators: Shorthand for pixel Self::Pixel => gettext("px"), Self::Percent => String::from("%"), }; f.write_str(&s) } } impl Unit { fn factor(&self, dpi: f64, total_size: f64) -> f64 { match self { Self::Centimeter => 2.54 / dpi, Self::Inch => 1. / dpi, Self::Pixel => 1., Self::Percent => 100. / total_size, } } fn digits(&self) -> u32 { match self { Self::Centimeter => 2, Self::Inch => 2, Self::Pixel => 0, Self::Percent => 2, } } fn ceil(&self, num: f64) -> f64 { let f = 10_f64.powi(self.digits() as i32); (num * f).ceil() / f } fn floor(&self, num: f64) -> f64 { let f = 10_f64.powi(self.digits() as i32); (num * f).floor() / f } fn round(&self, num: f64) -> f64 { let f = 10_f64.powi(self.digits() as i32); (num * f).round() / f } fn expression() -> gtk::ClosureExpression { gtk::ClosureExpression::new::( [] as [gtk::Expression; 0], glib::closure!(|s: gtk::StringObject| Self::from(s.string().as_str()).to_string()), ) } } mod imp { use super::*; #[derive(Debug, Default, CompositeTemplate, Properties)] #[template(file = "print.ui")] #[properties(wrapper_type = super::LpPrint)] pub struct LpPrint { #[template_child] pub(super) title: TemplateChild, #[template_child] pub(super) preview: TemplateChild, // ListBox entries #[template_child] pub(super) alignment: TemplateChild, #[template_child] pub(super) margin_unit: TemplateChild, #[property(get, set)] pub(super) margin_unit_factor: Cell, #[template_child] pub(super) margin_horizontal: TemplateChild, #[template_child] pub(super) margin_vertical: TemplateChild, #[template_child] pub(super) size_unit: TemplateChild, #[property(get, set)] pub(super) width_unit_factor: Cell, #[property(get, set)] pub(super) height_unit_factor: Cell, #[template_child] pub(super) fill_space: TemplateChild, #[template_child] pub(super) width: TemplateChild, #[template_child] pub(super) height: TemplateChild, #[property(get, set, construct_only)] pub(super) image: OnceCell, #[property(get, set, construct_only)] pub(super) preview_image: OnceCell, #[property(get, set, construct_only)] pub(super) parent_window: OnceCell, #[property(get, set, construct_only)] pub(super) print_dialog: OnceCell, #[property(get, set, construct_only)] pub(super) print_setup: OnceCell, #[property(get, set=Self::set_orientation)] pub(super) orientation: RefCell, pub(super) ui_updates: UiUpdates, } #[glib::object_subclass] impl ObjectSubclass for LpPrint { const NAME: &'static str = "LpPrint"; type Type = super::LpPrint; type ParentType = adw::Window; fn class_init(klass: &mut Self::Class) { Self::bind_template(klass); klass.install_action("print.print", None, |print, _, _| { print.print(); }); klass.install_action("print.back", None, |print, _, _| { print.back(); }); klass.install_property_action("print.orientation", "orientation"); } fn instance_init(obj: &glib::subclass::InitializingObject) { obj.init_template(); } } impl ObjectImpl for LpPrint { fn constructed(&self) { self.parent_constructed(); let obj = self.obj(); obj.set_width_unit_factor(1.); obj.set_height_unit_factor(1.); obj.set_margin_unit_factor(1.); obj.set_transient_for(Some(&obj.parent_window())); obj.set_modal(true); // Page alignment (center, left, ...) self.alignment .connect_selected_notify(glib::clone!(@weak obj => move |_| obj.draw_preview())); self.alignment .set_expression(Some(gtk::ClosureExpression::new::( [] as [gtk::Expression; 0], glib::closure!( |s: gtk::StringObject| PageAlignment::from(s.string().as_str()).to_string() ), ))); // Margin signals self.margin_unit.connect_selected_notify( glib::clone!(@weak obj => move |_| obj.on_margin_unit_changed()), ); self.margin_unit.set_expression(Some(Unit::expression())); self.margin_horizontal .connect_value_notify(glib::clone!(@weak obj => move |_| obj.on_margin_changed())); self.margin_vertical .connect_value_notify(glib::clone!(@weak obj => move |_| obj.on_margin_changed())); // Size signals self.size_unit.connect_selected_notify( glib::clone!(@weak obj => move |_| obj.on_size_unit_changed()), ); self.size_unit.set_expression(Some(Unit::expression())); self.fill_space.connect_active_notify( glib::clone!(@weak obj => move |_| obj.on_fill_space_changed()), ); self.width .connect_value_notify(glib::clone!(@weak obj => move |_| obj.on_width_changed())); self.height .connect_value_notify(glib::clone!(@weak obj => move |_| obj.on_height_changed())); let imp = self; let basename = obj .file() .basename() .map(|x| x.display().to_string()) .unwrap_or_default(); imp.title.set_title(&gettext_f( // Translators: {} is a placeholder for the filename "Print “{}”", [basename], )); if let Some(printer) = obj.print_settings().printer() { imp.title.set_subtitle(&printer); } imp.margin_horizontal.adjustment().set_step_increment(0.5); imp.margin_horizontal.adjustment().set_page_increment(2.5); imp.margin_vertical.adjustment().set_step_increment(0.5); imp.margin_vertical.adjustment().set_page_increment(2.5); imp.width.adjustment().set_step_increment(1.); imp.width.adjustment().set_page_increment(5.); imp.height.adjustment().set_step_increment(1.); imp.height.adjustment().set_page_increment(5.); let ui_updates_disabled = obj.disable_ui_updates(); obj.set_ranges(); obj.on_margin_unit_changed(); obj.on_size_unit_changed(); let size_unit = obj.size_unit(); imp.width .set_value(size_unit.round(obj.original_size().0 * obj.width_unit_factor())); // Default to inch for USA and Liberia if let Some(unit_locale) = getlocale(gettextrs::LocaleCategory::LcMeasurement) { if let Some(locale) = unit_locale .split(|x| *x == b'_') .nth(1) .and_then(|x| x.get(0..2)) { if locale == b"US" || locale == b"LR" { imp.margin_unit.set_selected(Unit::Inch as u32); imp.size_unit.set_selected(Unit::Inch as u32); } } } let orientation = match obj.page_setup().orientation() { gtk::PageOrientation::Portrait => "portrait", gtk::PageOrientation::Landscape => "landscape", _ => "other", }; obj.set_orientation(orientation); drop(ui_updates_disabled); obj.present(); } fn properties() -> &'static [glib::ParamSpec] { Self::derived_properties() } fn set_property(&self, id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { self.derived_set_property(id, value, pspec) } fn property(&self, id: usize, pspec: &glib::ParamSpec) -> glib::Value { self.derived_property(id, pspec) } } impl WidgetImpl for LpPrint {} impl WindowImpl for LpPrint {} impl AdwWindowImpl for LpPrint {} impl LpPrint { pub fn set_orientation(&self, orientation: String) { let obj = self.obj(); let _ui_updates_disabled = obj.disable_ui_updates(); self.orientation.replace(orientation); let orientation = if obj.orientation() == "landscape" { gtk::PageOrientation::Landscape } else { gtk::PageOrientation::Portrait }; obj.page_setup().set_orientation(orientation); obj.set_ranges(); // If fill space is enabeld, recalculated to fill space obj.on_fill_space_changed(); obj.draw_preview(); } } } glib::wrapper! { pub struct LpPrint(ObjectSubclass) @extends gtk::Widget, gtk::Window, adw::Window, @implements gtk::Buildable, gtk::ConstraintTarget; } impl LpPrint { pub fn show_print_dialog( image: LpImage, parent: gtk::Window, print_setup: Option, ) { let Some(file) = image.file() else { log::error!("Tried to print LpImage without GFile"); return; }; let print_dialog = gtk::PrintDialog::builder() .accept_label(gettext("Continue…")) .title(gettext("Print Image")) .build(); let print_settings = if let Some(print_setup) = print_setup { print_dialog.set_page_setup(&print_setup.page_setup().unwrap()); print_setup.print_settings().unwrap() } else { gtk::PrintSettings::new() }; print_dialog.set_print_settings(&print_settings); let uri = file.uri(); print_settings.set(gtk::PRINT_SETTINGS_OUTPUT_URI, Some(&format!("{uri}.pdf"))); glib::spawn_future_local(async move { let print_setup = match print_dialog.setup_future(Some(&parent)).await { Err(err) => { // TODO: show error log::warn!("Failed to print: {err}"); return; } Ok(print_setup) => print_setup.unwrap(), }; Self::new(image, parent, print_dialog, print_setup); }); } pub fn new( image: LpImage, parent: gtk::Window, print_dialog: gtk::PrintDialog, print_setup: gtk::PrintSetup, ) -> Self { let preview_image = LpImage::new_still(); preview_image.set_fixed_background_color(Some(gdk::RGBA::new(0.94, 0.94, 0.94, 1.))); if let Some(file) = image.file() { let preview_image_ = preview_image.clone(); glib::spawn_future_local(async move { preview_image_.load(&file).await }); } else { log::error!("Trying to print image without file"); } let obj: Self = glib::Object::builder() .property("image", image) .property("preview-image", preview_image) .property("parent-window", parent) .property("print-dialog", print_dialog) .property("print-setup", print_setup) .build(); obj } pub fn original_size(&self) -> (f64, f64) { let image = self.image(); let metadata = image.metadata(); if let Some((w, h)) = metadata.dimensions_inch() { let dpi = self.dpi(); ((w * dpi).round(), (h * dpi).round()) } else { let (w, h) = self.image().image_size(); (w as f64, h as f64) } } pub fn print_settings(&self) -> gtk::PrintSettings { self.print_setup().print_settings().unwrap() } pub fn file(&self) -> gio::File { self.image().file().unwrap() } /// Returns `true` if the current update is caused by user input /// /// If ui input is changed via code, this is set to `false` /// via `disable_ui_updates` before. fn ui_updates(&self) -> bool { self.imp().ui_updates.disabled.get() == 0 } /// Creates scope guard that allows ui changes via code afterwards fn disable_ui_updates(&self) -> UiUpdates { self.imp().ui_updates.disable() } fn size_unit(&self) -> Unit { Unit::from( self.imp() .size_unit .selected_item() .and_downcast::() .map(|x| x.string()) .unwrap_or_default(), ) } fn margin_unit(&self) -> Unit { Unit::from( self.imp() .margin_unit .selected_item() .and_downcast::() .map(|x| x.string()) .unwrap_or_default(), ) } pub fn dpi(&self) -> f64 { self.print_settings().resolution() as f64 } pub fn on_width_changed(&self) { if self.ui_updates() { let _ui_updates_disabled = self.disable_ui_updates(); // Disable 'fill space' when manually changing size self.imp().fill_space.set_active(false); self.update_height(); self.draw_preview(); } } pub fn on_height_changed(&self) { if self.ui_updates() { let imp = self.imp(); let _ui_updates_disabled = self.disable_ui_updates(); // Disable 'fill space' when manually changing size imp.fill_space.set_active(false); let (orig_width, orig_height) = self.original_size(); let width = self .size_unit() .round(imp.height.value() * orig_width / orig_height); imp.width.set_value(width); self.draw_preview(); } } fn user_alignment(&self) -> PageAlignment { let s = self .imp() .alignment .selected_item() .and_then(|x| x.downcast::().ok()) .map(|x| x.string()) .unwrap_or_default(); PageAlignment::from(s.as_str()) } /// Returns user selected image width in pixels pub fn user_width(&self) -> f64 { f64::max(1., self.imp().width.value() / self.width_unit_factor()) } /// Returns user selected image height in pixels pub fn user_height(&self) -> f64 { let (_, orig_height) = self.original_size(); f64::max(1., orig_height * self.user_scale()) } /// Returns scaling of the original image based on selected image size pub fn user_scale(&self) -> f64 { let (orig_width, _) = self.original_size(); self.user_width() / orig_width } /// Returns scaling of the original image based on selected image size pub fn user_scale_original(&self) -> f64 { let (orig_width, _) = self.image().image_size(); self.user_width() / orig_width as f64 } pub fn on_margin_changed(&self) { if self.ui_updates() { let _ui_updates_disabled = self.disable_ui_updates(); self.set_ranges(); if self.fill_space() { self.update_width(); self.update_height(); } self.draw_preview(); } } /// Returns user selected left margin in pixels pub fn user_margin_horizontal(&self) -> f64 { self.imp().margin_horizontal.value() / self.margin_unit_factor() } /// Returns left margin that positions the image according to user settings pub fn effective_margin_left(&self) -> f64 { match self.user_alignment().horizontal { HAlignment::Left => self.user_margin_horizontal(), HAlignment::Center => (self.paper_width() - self.user_width()) / 2., HAlignment::Right => { self.paper_width() - self.user_width() - self.user_margin_horizontal() } } } /// Returns width of physical paper in pixels pub fn paper_width(&self) -> f64 { self.page_setup().paper_width(gtk::Unit::Inch) * self.dpi() } /// Returns height of physical paper in pixels pub fn paper_height(&self) -> f64 { self.page_setup().paper_height(gtk::Unit::Inch) * self.dpi() } /// Returns user selected top margin in pixels pub fn user_margin_vertical(&self) -> f64 { self.imp().margin_vertical.value() / self.margin_unit_factor() } pub fn effective_margin_top(&self) -> f64 { match self.user_alignment().vertical { VAlignment::Top => self.user_margin_vertical(), VAlignment::Middle => (self.paper_height() - self.user_height()) / 2., VAlignment::Bottom => { self.paper_height() - self.user_height() - self.user_margin_vertical() } } } /// Redraws the preview widget child fn draw_preview(&self) { self.imp().preview.queue_resize(); } /// Sets the allowed ranges for the spin button based on current unit fn set_ranges(&self) { let imp = self.imp(); let margin_unit = self.margin_unit(); let min_horizontal_margin = f64::max( self.page_setup().left_margin(gtk::Unit::Inch), self.page_setup().right_margin(gtk::Unit::Inch), ) * self.dpi(); let min_vertical_margin = f64::max( self.page_setup().top_margin(gtk::Unit::Inch), self.page_setup().bottom_margin(gtk::Unit::Inch), ) * self.dpi(); imp.margin_horizontal.set_range( margin_unit.ceil(min_horizontal_margin * self.margin_unit_factor()), margin_unit.floor((self.paper_width() / 2.) * self.margin_unit_factor()), ); imp.margin_vertical.set_range( margin_unit.ceil(min_vertical_margin * self.margin_unit_factor()), margin_unit.floor((self.paper_height() / 2.) * self.margin_unit_factor()), ); let size_unit = self.size_unit(); let max_width = self.fill_space_width(); let max_height = self.fill_space_height(); imp.width.set_range( size_unit.ceil(self.width_unit_factor()), size_unit.floor(max_width * self.width_unit_factor()), ); imp.height.set_range( size_unit.ceil(self.width_unit_factor()), size_unit.floor(max_height * self.height_unit_factor()), ); } /// Returns width that makes the image fill the page fn fill_space_width(&self) -> f64 { let width1 = self.paper_width() - 2. * self.user_margin_horizontal(); let height = self.paper_height() - 2. * self.user_margin_vertical(); let (orig_width, orig_height) = self.original_size(); let width2 = height * orig_width / orig_height; f64::min(width1, width2) } /// Returns height that makes the image fill the page fn fill_space_height(&self) -> f64 { let (orig_width, orig_height) = self.original_size(); self.fill_space_width() * orig_height / orig_width } /// Updates width according to margins if fill space is activated fn update_width(&self) { if self.fill_space() { let value = self .size_unit() .floor(self.fill_space_width() * self.width_unit_factor()); self.imp().width.set_value(value); } } /// Updates height according to width fn update_height(&self) { let height = if self.fill_space() { self.fill_space_height() } else { self.user_height() }; let value = self.size_unit().round(height * self.height_unit_factor()); self.imp().height.set_value(value); } fn on_size_unit_changed(&self) { let imp = self.imp(); // Only show one size input for percentage if matches!(self.size_unit(), Unit::Percent) { imp.width.set_title(&gettext("_Scale")); imp.height.set_visible(false); } else { imp.width.set_title(&gettext("_Width")); imp.height.set_visible(true); } let _ui_updates_disabled = self.disable_ui_updates(); let (orig_width, orig_height) = self.original_size(); let unit = self.size_unit(); let width_factor = unit.factor(self.dpi(), orig_width); let height_factor = unit.factor(self.dpi(), orig_height); let width = imp.width.value() * width_factor / self.width_unit_factor(); let height = imp.height.value() * height_factor / self.height_unit_factor(); self.set_width_unit_factor(width_factor); self.set_height_unit_factor(height_factor); let _ui_updates_disabled = self.disable_ui_updates(); self.set_ranges(); imp.width.set_digits(unit.digits()); imp.width.set_value(width); imp.height.set_digits(unit.digits()); imp.height.set_value(height); self.draw_preview(); } fn on_margin_unit_changed(&self) { let imp = self.imp(); let _ui_updates_disabled = self.disable_ui_updates(); let unit = self.margin_unit(); let unit_factor = unit.factor(self.dpi(), 1.); let update_factor = unit_factor / self.margin_unit_factor(); let margin_horizontal = imp.margin_horizontal.value() * update_factor; let margin_vertical = imp.margin_vertical.value() * update_factor; self.set_margin_unit_factor(unit_factor); self.set_ranges(); imp.margin_horizontal.set_digits(unit.digits()); imp.margin_horizontal.set_value(margin_horizontal); imp.margin_vertical.set_digits(unit.digits()); imp.margin_vertical.set_value(margin_vertical); self.draw_preview(); } /// Returns if the option to fill the page with the image is activated pub fn fill_space(&self) -> bool { self.imp().fill_space.is_active() } fn on_fill_space_changed(&self) { if self.fill_space() { let _ui_updates_disabled = self.disable_ui_updates(); self.update_width(); self.update_height(); self.draw_preview(); } } pub fn page_setup(&self) -> gtk::PageSetup { self.print_setup().page_setup().unwrap() } /// Go back to print dialog from image layout fn back(&self) { self.close(); Self::show_print_dialog(self.image(), self.parent_window(), Some(self.print_setup())); } /// Initialize actual print operation fn print(&self) { self.close(); let obj = self.clone(); glib::spawn_future_local(async move { let result = obj .print_dialog() .print_future(Some(&obj.parent_window()), Some(&obj.print_setup())) .await; let draw_result = match result { Err(err) => { log::warn!("Print error: {err}"); return; } Ok(output_stream) => obj.draw_page(output_stream.unwrap()).await, }; if let Err(err) = draw_result { log::warn!("Print error: {err}"); } }); } /// Draw PDF for printing async fn draw_page(&self, output_stream: gio::OutputStream) -> anyhow::Result<()> { log::debug!("Drawing image to print"); let image = self.image(); let (orig_width, orig_height) = self.original_size(); let texture_scale = self.user_width() / orig_width; let texture = if image.metadata().is_svg() { // Render SVG to exact needed sizes decoder::formats::Svg::render_print( &image.file().unwrap(), (orig_width * texture_scale) as i32, (orig_height * texture_scale) as i32, ) .await? } else { image.print_data(texture_scale).unwrap() }; let image_surface = { let mut downloader = gdk::TextureDownloader::new(&texture); downloader.set_format(gdk::MemoryFormat::B8g8r8a8Premultiplied); let (data, stride) = downloader.download_bytes(); let data = data.to_vec(); let width = texture.width(); let height = texture.height(); cairo::ImageSurface::create_for_data( data, cairo::Format::ARgb32, width, height, stride as i32, ) .unwrap() }; let paper_width = self.page_setup().paper_width(gtk::Unit::Inch) * self.dpi(); let paper_height = self.page_setup().paper_height(gtk::Unit::Inch) * self.dpi(); let cairo_surface = cairo::PdfSurface::for_stream(paper_width, paper_height, output_stream.into_write())?; let cairo_context = cairo::Context::new(&cairo_surface)?; let margin_left = self.effective_margin_left(); let margin_top = self.effective_margin_top(); cairo_context.set_source_surface(image_surface, margin_left, margin_top)?; cairo_context.paint()?; cairo_surface.finish_output_stream().map_err(|x| x.error)?; Ok(()) } } // TODO: Upstream to gettext if possible pub fn getlocale(category: gettextrs::LocaleCategory) -> Option> { unsafe { let ret = gettext_sys::setlocale(category as i32, std::ptr::null()); if ret.is_null() { None } else { Some(std::ffi::CStr::from_ptr(ret).to_bytes().to_owned()) } } }