metadata: Centralize many loose ends
This commit is contained in:
parent
b6d578cc0a
commit
fe43bc3a4c
21 changed files with 179 additions and 195 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1488,6 +1488,7 @@ dependencies = [
|
||||||
"gettext-sys",
|
"gettext-sys",
|
||||||
"gio",
|
"gio",
|
||||||
"glycin",
|
"glycin",
|
||||||
|
"glycin-utils",
|
||||||
"gtk4",
|
"gtk4",
|
||||||
"gvdb-macros",
|
"gvdb-macros",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ env_logger = "0.10.0"
|
||||||
futures-channel = "0.3.25"
|
futures-channel = "0.3.25"
|
||||||
futures-lite = "2.1.0"
|
futures-lite = "2.1.0"
|
||||||
glycin = "1.0.0-alpha"
|
glycin = "1.0.0-alpha"
|
||||||
|
glycin-utils = "1.0.0-alpha"
|
||||||
gvdb-macros = "0.1.6"
|
gvdb-macros = "0.1.6"
|
||||||
indexmap = "2.0.0"
|
indexmap = "2.0.0"
|
||||||
kamadak-exif = "0.5.5"
|
kamadak-exif = "0.5.5"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
|
// Copyright (c) 2023-2024 Sophie Herold
|
||||||
|
// Copyright (c) 2023 Automeris naranja
|
||||||
// Copyright (c) 2023 Christopher Davis
|
// Copyright (c) 2023 Christopher Davis
|
||||||
// Copyright (c) 2023 Sophie Herold
|
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// 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
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -38,7 +39,7 @@ pub async fn window() -> adw::AboutWindow {
|
||||||
.designers(["Allan Day", "Jakub Steiner", "Tobias Bernard"])
|
.designers(["Allan Day", "Jakub Steiner", "Tobias Bernard"])
|
||||||
// Translators: Replace "translator-credits" with your names, one name per line
|
// Translators: Replace "translator-credits" with your names, one name per line
|
||||||
.translator_credits(gettext("translator-credits"))
|
.translator_credits(gettext("translator-credits"))
|
||||||
.copyright(gettext("Copyright © 2020–2023 Christopher Davis et al."))
|
.copyright(gettext("Copyright © 2020–2024 Christopher Davis et al."))
|
||||||
.license_type(gtk::License::Gpl30)
|
.license_type(gtk::License::Gpl30)
|
||||||
.debug_info(debug_info().await)
|
.debug_info(debug_info().await)
|
||||||
.build()
|
.build()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright (c) 2023 Sophie Herold
|
// Copyright (c) 2023-2024 Sophie Herold
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// 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
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -24,7 +24,7 @@ use gtk::prelude::*;
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::decoder::tiling::{self, SharedFrameBuffer};
|
use crate::decoder::tiling::{self, SharedFrameBuffer};
|
||||||
use crate::deps::*;
|
use crate::deps::*;
|
||||||
use crate::metadata::{ImageFormat, Metadata};
|
use crate::metadata::Metadata;
|
||||||
|
|
||||||
pub const FRAME_BUFFER: usize = 3;
|
pub const FRAME_BUFFER: usize = 3;
|
||||||
|
|
||||||
|
|
@ -57,23 +57,20 @@ impl Glycin {
|
||||||
|
|
||||||
let image = image_request.request().await?;
|
let image = image_request.request().await?;
|
||||||
|
|
||||||
if let Some(exif_raw) = image.info().details.exif.clone() {
|
let mut metadata: Metadata = Metadata::default();
|
||||||
let mut metadata = Metadata::default();
|
metadata.set_image_info(image.info().details.clone());
|
||||||
metadata.set_exif_bytes(exif_raw);
|
metadata.set_mime_type(image.mime_type());
|
||||||
metadata.set_transformations_applied(image.info().details.transformations_applied);
|
updater.send(DecoderUpdate::Metadata(Box::new(metadata)));
|
||||||
updater.send(DecoderUpdate::Metadata(metadata));
|
|
||||||
}
|
|
||||||
|
|
||||||
let dimensions = (image.info().width, image.info().height);
|
let dimensions = (image.info().width, image.info().height);
|
||||||
tiles.set_original_dimensions(dimensions);
|
tiles.set_original_dimensions(dimensions);
|
||||||
|
|
||||||
updater.send(DecoderUpdate::Format(ImageFormat::new(
|
|
||||||
image.mime_type(),
|
|
||||||
image.format_name(),
|
|
||||||
)));
|
|
||||||
|
|
||||||
let frame = image.next_frame().await?;
|
let frame = image.next_frame().await?;
|
||||||
|
|
||||||
|
let mut metadata: Metadata = Metadata::default();
|
||||||
|
metadata.set_frame_info(frame.details);
|
||||||
|
updater.send(DecoderUpdate::Metadata(Box::new(metadata)));
|
||||||
|
|
||||||
if let Some(delay) = frame.delay {
|
if let Some(delay) = frame.delay {
|
||||||
updater.send(DecoderUpdate::Animated);
|
updater.send(DecoderUpdate::Animated);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,3 @@ pub use glycin_proxy::Glycin;
|
||||||
pub use svg::{Svg, RSVG_MAX_SIZE};
|
pub use svg::{Svg, RSVG_MAX_SIZE};
|
||||||
|
|
||||||
use super::{DecoderUpdate, UpdateSender};
|
use super::{DecoderUpdate, UpdateSender};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
pub enum ImageDimensionDetails {
|
|
||||||
Svg(String, Option<(f64, f64)>),
|
|
||||||
#[default]
|
|
||||||
None,
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright (c) 2023 Sophie Herold
|
// Copyright (c) 2023-2024 Sophie Herold
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// 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
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -27,7 +27,7 @@ use super::*;
|
||||||
use crate::decoder::tiling::SharedFrameBuffer;
|
use crate::decoder::tiling::SharedFrameBuffer;
|
||||||
use crate::decoder::TileRequest;
|
use crate::decoder::TileRequest;
|
||||||
use crate::deps::*;
|
use crate::deps::*;
|
||||||
use crate::metadata::ImageFormat;
|
use crate::metadata::Metadata;
|
||||||
|
|
||||||
/// Current librsvg limit on maximum dimensions. See
|
/// Current librsvg limit on maximum dimensions. See
|
||||||
/// <https://gitlab.gnome.org/GNOME/librsvg/-/issues/938>
|
/// <https://gitlab.gnome.org/GNOME/librsvg/-/issues/938>
|
||||||
|
|
@ -78,21 +78,12 @@ impl Svg {
|
||||||
|
|
||||||
let image = image_request.request().await?;
|
let image = image_request.request().await?;
|
||||||
|
|
||||||
let dimensions = if let Some(string) = image.info().details.dimensions_text.as_ref() {
|
let mut metadata: Metadata = Metadata::default();
|
||||||
ImageDimensionDetails::Svg(string.to_string(), image.info().details.dimensions_inch)
|
metadata.set_image_info(image.info().details.clone());
|
||||||
} else {
|
metadata.set_mime_type(image.mime_type());
|
||||||
ImageDimensionDetails::None
|
updater.send(DecoderUpdate::Metadata(Box::new(metadata)));
|
||||||
};
|
|
||||||
|
|
||||||
tiles.set_original_dimensions_full(
|
tiles.set_original_dimensions((image.info().width, image.info().height));
|
||||||
(image.info().width, image.info().height),
|
|
||||||
dimensions,
|
|
||||||
);
|
|
||||||
|
|
||||||
updater.send(DecoderUpdate::Format(ImageFormat::new(
|
|
||||||
image.mime_type(),
|
|
||||||
image.format_name(),
|
|
||||||
)));
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let tile_request = {
|
let tile_request = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright (c) 2023 Sophie Herold
|
// Copyright (c) 2023-2024 Sophie Herold
|
||||||
// Copyright (c) 2023 Julian Hofer
|
// Copyright (c) 2023 Julian Hofer
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
|
@ -24,13 +24,13 @@ pub mod tiling;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
pub use formats::RSVG_MAX_SIZE;
|
||||||
use formats::*;
|
use formats::*;
|
||||||
pub use formats::{ImageDimensionDetails, RSVG_MAX_SIZE};
|
|
||||||
use futures_channel::mpsc;
|
use futures_channel::mpsc;
|
||||||
|
|
||||||
use self::tiling::SharedFrameBuffer;
|
use self::tiling::SharedFrameBuffer;
|
||||||
use crate::deps::*;
|
use crate::deps::*;
|
||||||
use crate::metadata::{ImageFormat, Metadata};
|
use crate::metadata::Metadata;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
/// Renderer requests new tiles
|
/// Renderer requests new tiles
|
||||||
|
|
@ -52,11 +52,9 @@ pub struct UpdateSender {
|
||||||
/// Signals for renderer (LpImage)
|
/// Signals for renderer (LpImage)
|
||||||
pub enum DecoderUpdate {
|
pub enum DecoderUpdate {
|
||||||
/// Dimensions of image in `TilingSore` available/updated
|
/// Dimensions of image in `TilingSore` available/updated
|
||||||
Dimensions(ImageDimensionDetails),
|
Dimensions,
|
||||||
/// Metadata available
|
/// Metadata available
|
||||||
Metadata(Metadata),
|
Metadata(Box<Metadata>),
|
||||||
/// Image format determined
|
|
||||||
Format(ImageFormat),
|
|
||||||
/// Image format not supported or unknown
|
/// Image format not supported or unknown
|
||||||
UnsupportedFormat,
|
UnsupportedFormat,
|
||||||
/// New image data available, redraw
|
/// New image data available, redraw
|
||||||
|
|
@ -144,10 +142,6 @@ impl Decoder {
|
||||||
// - image/svg+xml
|
// - image/svg+xml
|
||||||
// - image/svg+xml-compressed
|
// - image/svg+xml-compressed
|
||||||
if mime_type.split('+').next() == Some("image/svg") {
|
if mime_type.split('+').next() == Some("image/svg") {
|
||||||
update_sender.send(DecoderUpdate::Format(ImageFormat::new(
|
|
||||||
mime_type,
|
|
||||||
Some("SVG".into()),
|
|
||||||
)));
|
|
||||||
return FormatDecoder::Svg(Svg::new(file, update_sender, tiles));
|
return FormatDecoder::Svg(Svg::new(file, update_sender, tiles));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright (c) 2023 Sophie Herold
|
// Copyright (c) 2023-2024 Sophie Herold
|
||||||
// Copyright (c) 2023 FineFindus
|
// Copyright (c) 2023 FineFindus
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
|
@ -33,7 +33,7 @@ use std::time::Duration;
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
|
|
||||||
use super::{DecoderUpdate, ImageDimensionDetails, UpdateSender};
|
use super::{DecoderUpdate, UpdateSender};
|
||||||
use crate::deps::*;
|
use crate::deps::*;
|
||||||
|
|
||||||
const ZOOM_SIGNIFICANT_DIGITS: i32 = 6;
|
const ZOOM_SIGNIFICANT_DIGITS: i32 = 6;
|
||||||
|
|
@ -676,14 +676,6 @@ impl SharedFrameBuffer {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_original_dimensions(&self, size: Coordinates) {
|
pub fn set_original_dimensions(&self, size: Coordinates) {
|
||||||
self.set_original_dimensions_full(size, Default::default());
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_original_dimensions_full(
|
|
||||||
&self,
|
|
||||||
size: Coordinates,
|
|
||||||
dimension_details: ImageDimensionDetails,
|
|
||||||
) {
|
|
||||||
self.buffer.rcu(|tiling_store| {
|
self.buffer.rcu(|tiling_store| {
|
||||||
let mut new_store = (**tiling_store).clone();
|
let mut new_store = (**tiling_store).clone();
|
||||||
new_store.current().original_dimensions = Some(size);
|
new_store.current().original_dimensions = Some(size);
|
||||||
|
|
@ -691,7 +683,7 @@ impl SharedFrameBuffer {
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(updater) = &self.buffer.load().update_sender {
|
if let Some(updater) = &self.buffer.load().update_sender {
|
||||||
updater.send(DecoderUpdate::Dimensions(dimension_details));
|
updater.send(DecoderUpdate::Dimensions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
// Copyright (c) 2023 Sophie Herold
|
|
||||||
//
|
|
||||||
// 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 <http://www.gnu.org/licenses/>.
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
|
|
||||||
use crate::util::gettext::*;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct ImageFormat {
|
|
||||||
mime_type: glycin::MimeType,
|
|
||||||
name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImageFormat {
|
|
||||||
pub fn new(mime_type: glycin::MimeType, name: Option<String>) -> Self {
|
|
||||||
Self { mime_type, name }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_svg(&self) -> bool {
|
|
||||||
matches!(
|
|
||||||
self.mime_type.as_str(),
|
|
||||||
"image/svg+xml" | "image/svg+xml-compressed"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_potentially_transparent(&self) -> bool {
|
|
||||||
!matches!(self.mime_type.as_str(), "image/bmp" | "image/jpeg")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for ImageFormat {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"{}",
|
|
||||||
self.name.clone().unwrap_or_else(|| gettext("Unknown"))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright (c) 2022-2023 Sophie Herold
|
// Copyright (c) 2022-2024 Sophie Herold
|
||||||
// Copyright (c) 2023 FineFindus
|
// Copyright (c) 2023 FineFindus
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
|
@ -22,11 +22,10 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
mod file;
|
mod file;
|
||||||
mod format;
|
|
||||||
mod gps;
|
mod gps;
|
||||||
|
|
||||||
pub use file::FileInfo;
|
pub use file::FileInfo;
|
||||||
pub use format::ImageFormat;
|
use glycin_utils::{FrameDetails, ImageInfoDetails};
|
||||||
pub use gps::GPSLocation;
|
pub use gps::GPSLocation;
|
||||||
|
|
||||||
use crate::deps::*;
|
use crate::deps::*;
|
||||||
|
|
@ -35,10 +34,12 @@ use crate::util::gettext::*;
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Metadata {
|
pub struct Metadata {
|
||||||
|
mime_type: Option<String>,
|
||||||
exif: Option<exif::Exif>,
|
exif: Option<exif::Exif>,
|
||||||
file_info: Option<FileInfo>,
|
file_info: Option<FileInfo>,
|
||||||
format: Option<ImageFormat>,
|
// TODO: Replace with glycin in newer glycin version
|
||||||
transformations_applied: Option<bool>,
|
image_info: Option<glycin_utils::ImageInfoDetails>,
|
||||||
|
frame_info: Option<FrameDetails>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for Metadata {
|
impl std::fmt::Debug for Metadata {
|
||||||
|
|
@ -62,7 +63,7 @@ impl std::fmt::Debug for Metadata {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Metadata {
|
impl Metadata {
|
||||||
pub fn set_exif_bytes(&mut self, bytes: Vec<u8>) {
|
fn set_exif_bytes(&mut self, bytes: Vec<u8>) {
|
||||||
let reader = exif::Reader::new();
|
let reader = exif::Reader::new();
|
||||||
let exif = reader.read_raw(bytes);
|
let exif = reader.read_raw(bytes);
|
||||||
|
|
||||||
|
|
@ -73,16 +74,23 @@ impl Metadata {
|
||||||
self.exif = exif.ok();
|
self.exif = exif.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_transformations_applied(&mut self, applied: bool) {
|
pub fn set_image_info(&mut self, image_info: ImageInfoDetails) {
|
||||||
self.transformations_applied = Some(applied);
|
if let Some(exif_raw) = image_info.exif.as_ref() {
|
||||||
|
self.set_exif_bytes(exif_raw.clone());
|
||||||
|
}
|
||||||
|
self.image_info = Some(image_info);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_frame_info(&mut self, frame_info: FrameDetails) {
|
||||||
|
self.frame_info = Some(frame_info);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_file_info(&mut self, file_info: FileInfo) {
|
pub fn set_file_info(&mut self, file_info: FileInfo) {
|
||||||
self.file_info = Some(file_info);
|
self.file_info = Some(file_info);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_format(&mut self, format: ImageFormat) {
|
pub fn set_mime_type(&mut self, mime_type: String) {
|
||||||
self.format = Some(format);
|
self.mime_type = Some(mime_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn merge(&mut self, other: Self) {
|
pub fn merge(&mut self, other: Self) {
|
||||||
|
|
@ -92,8 +100,14 @@ impl Metadata {
|
||||||
if self.file_info.is_none() {
|
if self.file_info.is_none() {
|
||||||
self.file_info = other.file_info;
|
self.file_info = other.file_info;
|
||||||
}
|
}
|
||||||
if self.transformations_applied.is_none() {
|
if self.image_info.is_none() {
|
||||||
self.transformations_applied = other.transformations_applied;
|
self.image_info = other.image_info;
|
||||||
|
}
|
||||||
|
if self.mime_type.is_none() {
|
||||||
|
self.mime_type = other.mime_type;
|
||||||
|
}
|
||||||
|
if self.frame_info.is_none() {
|
||||||
|
self.frame_info = other.frame_info;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,18 +118,42 @@ impl Metadata {
|
||||||
.map(|x| x.to_string())
|
.map(|x| x.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn format(&self) -> Option<&ImageFormat> {
|
|
||||||
self.format.as_ref()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn format_name(&self) -> Option<String> {
|
pub fn format_name(&self) -> Option<String> {
|
||||||
self.format()
|
self.image_info
|
||||||
.map(|x| x.to_string())
|
.as_ref()
|
||||||
|
.and_then(|x| x.format_name.clone())
|
||||||
.or_else(|| self.mime_type())
|
.or_else(|| self.mime_type())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn alpha_channel(&self) -> Option<bool> {
|
||||||
|
self.frame_info.as_ref().and_then(|x| x.alpha_channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn grayscale(&self) -> Option<bool> {
|
||||||
|
self.frame_info.as_ref().and_then(|x| x.grayscale)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bit_depth(&self) -> Option<u8> {
|
||||||
|
self.frame_info.as_ref().and_then(|x| x.bit_depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_svg(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self.mime_type.as_deref(),
|
||||||
|
Some("image/svg+xml") | Some("image/svg+xml-compressed")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_potentially_transparent(&self) -> bool {
|
||||||
|
// TODO: Implement again
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
pub fn transformations_applied(&self) -> bool {
|
pub fn transformations_applied(&self) -> bool {
|
||||||
self.transformations_applied.unwrap_or(false)
|
self.image_info
|
||||||
|
.as_ref()
|
||||||
|
.map(|x| x.transformations_applied)
|
||||||
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn orientation(&self) -> Orientation {
|
pub fn orientation(&self) -> Orientation {
|
||||||
|
|
@ -136,6 +174,16 @@ impl Metadata {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn dimensions_inch(&self) -> Option<(f64, f64)> {
|
||||||
|
self.image_info.as_ref().and_then(|x| x.dimensions_inch)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dimensions_text(&self) -> Option<String> {
|
||||||
|
self.image_info
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|x| x.dimensions_text.clone())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn file_name(&self) -> Option<String> {
|
pub fn file_name(&self) -> Option<String> {
|
||||||
self.file_info.as_ref().map(|x| x.display_name.to_string())
|
self.file_info.as_ref().map(|x| x.display_name.to_string())
|
||||||
}
|
}
|
||||||
|
|
@ -210,7 +258,7 @@ impl Metadata {
|
||||||
let exposure = format!("{:.0}", 1. / rational.first()?.to_f32());
|
let exposure = format!("{:.0}", 1. / rational.first()?.to_f32());
|
||||||
|
|
||||||
// Translators: Unit for exposure time in seconds
|
// Translators: Unit for exposure time in seconds
|
||||||
return Some(gettext_f("1\u{2215}{}\u{202F}s", &[&exposure]));
|
return Some(gettext_f("1\u{2215}{}\u{202F}s", [exposure]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -235,7 +283,7 @@ impl Metadata {
|
||||||
if let exif::Value::Rational(rational) = &field.value {
|
if let exif::Value::Rational(rational) = &field.value {
|
||||||
let length = format!("{:.0}", rational.first()?.to_f32());
|
let length = format!("{:.0}", rational.first()?.to_f32());
|
||||||
// Translators: Unit for focal length in millimeters
|
// Translators: Unit for focal length in millimeters
|
||||||
return Some(gettext_f("{}\u{202F}mm", &[&length]));
|
return Some(gettext_f("{}\u{202F}mm", [length]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright (c) 2023 Sophie Herold
|
// Copyright (c) 2023-2024 Sophie Herold
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// 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
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -17,15 +17,15 @@
|
||||||
|
|
||||||
pub use gettextrs::gettext;
|
pub use gettextrs::gettext;
|
||||||
|
|
||||||
fn freplace(mut s: String, args: &[&str]) -> String {
|
fn freplace(mut s: String, args: impl IntoIterator<Item = impl AsRef<str>>) -> String {
|
||||||
for arg in args {
|
for arg in args {
|
||||||
s = s.replacen("{}", arg, 1);
|
s = s.replacen("{}", arg.as_ref(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn gettext_f(format: &str, args: &[&str]) -> String {
|
pub fn gettext_f(format: &str, args: impl IntoIterator<Item = impl AsRef<str>>) -> String {
|
||||||
let s = gettextrs::gettext(format);
|
let s = gettextrs::gettext(format);
|
||||||
freplace(s, args)
|
freplace(s, args)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -198,8 +198,6 @@ mod imp {
|
||||||
|
|
||||||
/// Currently EXIF data
|
/// Currently EXIF data
|
||||||
pub(super) metadata: RefCell<Metadata>,
|
pub(super) metadata: RefCell<Metadata>,
|
||||||
/// Image dimension details for SVGs
|
|
||||||
pub(super) dimension_details: RefCell<decoder::ImageDimensionDetails>,
|
|
||||||
|
|
||||||
#[property(get)]
|
#[property(get)]
|
||||||
_image_size_available: bool,
|
_image_size_available: bool,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright (c) 2023 Sophie Herold
|
// Copyright (c) 2023-2024 Sophie Herold
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// 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
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -62,11 +62,7 @@ impl imp::LpImage {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shortcut for formats that don't support transparency
|
// Shortcut for formats that don't support transparency
|
||||||
if !obj
|
if !obj.metadata().is_potentially_transparent() {
|
||||||
.metadata()
|
|
||||||
.format()
|
|
||||||
.map_or(true, |x| x.is_potentially_transparent())
|
|
||||||
{
|
|
||||||
log::debug!("This format does not support transparency");
|
log::debug!("This format does not support transparency");
|
||||||
return Some(Self::default_background_color());
|
return Some(Self::default_background_color());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright (c) 2023 Sophie Herold
|
// Copyright (c) 2023-2024 Sophie Herold
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// 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
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -45,41 +45,48 @@ impl imp::LpImage {
|
||||||
gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::VERTICAL);
|
gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::VERTICAL);
|
||||||
|
|
||||||
scroll_controller.connect_scroll(glib::clone!(@weak obj => @default-return glib::Propagation::Proceed, move |event, _, y| {
|
scroll_controller.connect_scroll(glib::clone!(@weak obj => @default-return glib::Propagation::Proceed, move |event, _, y| {
|
||||||
// use Ctrl key as modifier for vertical scrolling
|
let state = event.current_event_state();
|
||||||
if event.current_event_state().contains(gdk::ModifierType::CONTROL_MASK)
|
|
||||||
|| event.current_event_state().contains(gdk::ModifierType::SHIFT_MASK)
|
|
||||||
{
|
|
||||||
// propagate event to scrolled window
|
|
||||||
return glib::Propagation::Proceed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// touchpads do zoom via gestures only
|
if event.current_event_device().map(|x| x.source()) == Some(gdk::InputSource::Touchpad)
|
||||||
if event.current_event_device().map(|x| x.source())
|
|
||||||
== Some(gdk::InputSource::Touchpad)
|
|
||||||
{
|
{
|
||||||
// propagate event to scrolled window
|
// Touchpads do zoom via gestures, expect when Ctrl key is pressed
|
||||||
return glib::Propagation::Proceed;
|
if !state.contains(gdk::ModifierType::CONTROL_MASK) {
|
||||||
|
// propagate event to scrolled window
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// use Ctrl key as modifier for vertical scrolling
|
||||||
|
if state.contains(gdk::ModifierType::CONTROL_MASK)
|
||||||
|
|| state.contains(gdk::ModifierType::SHIFT_MASK)
|
||||||
|
{
|
||||||
|
// propagate event to scrolled window
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use exponential scaling since zoom is always multiplicative with the existing value
|
// Use exponential scaling since zoom is always multiplicative with the existing value
|
||||||
// This is the right thing since `exp(n/2)^2 == exp(n)` (two small steps are the same as one larger step)
|
// This is the right thing since `exp(n/2)^2 == exp(n)` (two small steps are the same as one larger step)
|
||||||
let (zoom_factor, animated) = match event.unit() {
|
let (zoom_factor, animated) = match event.unit() {
|
||||||
gdk::ScrollUnit::Wheel => (f64::exp( - y * f64::ln(ZOOM_FACTOR_SCROLL_WHEEL)), y.abs() >= 1.),
|
gdk::ScrollUnit::Wheel => (
|
||||||
gdk::ScrollUnit::Surface => (f64::exp( - y * f64::ln(ZOOM_FACTOR_SCROLL_SURFACE)), false),
|
f64::exp(-y * f64::ln(ZOOM_FACTOR_SCROLL_WHEEL)),
|
||||||
|
y.abs() >= 1.,
|
||||||
|
),
|
||||||
|
gdk::ScrollUnit::Surface => {
|
||||||
|
(f64::exp(-y * f64::ln(ZOOM_FACTOR_SCROLL_SURFACE)), false)
|
||||||
|
}
|
||||||
unknown_unit => {
|
unknown_unit => {
|
||||||
log::warn!("Ignoring unknown scroll unit: {unknown_unit}");
|
log::warn!("Ignoring unknown scroll unit: {unknown_unit}");
|
||||||
(1., false)
|
(1., false)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let zoom =
|
let zoom = obj.imp().zoom_target.get() * zoom_factor;
|
||||||
obj.imp().zoom_target.get() * zoom_factor;
|
|
||||||
|
|
||||||
if animated {
|
if animated {
|
||||||
obj.zoom_to(zoom);
|
obj.zoom_to(zoom);
|
||||||
} else {
|
} else {
|
||||||
obj.imp().zoom_to_full(zoom, false, false);
|
obj.imp().zoom_to_full(zoom, false, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// do not propagate event to scrolled window
|
// do not propagate event to scrolled window
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright (c) 2023 Sophie Herold
|
// Copyright (c) 2023-2024 Sophie Herold
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// 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
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -68,14 +68,13 @@ impl imp::LpImage {
|
||||||
match update {
|
match update {
|
||||||
DecoderUpdate::Metadata(metadata) => {
|
DecoderUpdate::Metadata(metadata) => {
|
||||||
log::debug!("Received metadata");
|
log::debug!("Received metadata");
|
||||||
self.metadata.borrow_mut().merge(metadata);
|
self.metadata.borrow_mut().merge(*metadata);
|
||||||
self.emmit_metadata_changed();
|
self.emmit_metadata_changed();
|
||||||
|
|
||||||
obj.reset_rotation();
|
obj.reset_rotation();
|
||||||
}
|
}
|
||||||
DecoderUpdate::Dimensions(dimension_details) => {
|
DecoderUpdate::Dimensions => {
|
||||||
log::debug!("Received dimensions: {:?}", self.original_dimensions());
|
log::debug!("Received dimensions: {:?}", self.original_dimensions());
|
||||||
self.dimension_details.replace(dimension_details);
|
|
||||||
obj.notify_image_size_available();
|
obj.notify_image_size_available();
|
||||||
self.configure_best_fit();
|
self.configure_best_fit();
|
||||||
self.request_tiles();
|
self.request_tiles();
|
||||||
|
|
@ -105,10 +104,6 @@ impl imp::LpImage {
|
||||||
DecoderUpdate::Error(err) => {
|
DecoderUpdate::Error(err) => {
|
||||||
self.set_error(Some(err));
|
self.set_error(Some(err));
|
||||||
}
|
}
|
||||||
DecoderUpdate::Format(format) => {
|
|
||||||
self.metadata.borrow_mut().set_format(format);
|
|
||||||
self.emmit_metadata_changed();
|
|
||||||
}
|
|
||||||
DecoderUpdate::Animated => {
|
DecoderUpdate::Animated => {
|
||||||
if self.still.get() {
|
if self.still.get() {
|
||||||
// Just load the first frame
|
// Just load the first frame
|
||||||
|
|
|
||||||
|
|
@ -83,10 +83,6 @@ impl imp::LpImage {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LpImage {
|
impl LpImage {
|
||||||
pub fn dimension_details(&self) -> decoder::ImageDimensionDetails {
|
|
||||||
self.imp().dimension_details.borrow().clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn file(&self) -> Option<gio::File> {
|
pub fn file(&self) -> Option<gio::File> {
|
||||||
self.imp().file.borrow().clone()
|
self.imp().file.borrow().clone()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright (c) 2023 Sophie Herold
|
// Copyright (c) 2023-2024 Sophie Herold
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// 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
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -65,7 +65,7 @@ impl WidgetImpl for imp::LpImage {
|
||||||
|
|
||||||
let applicable_zoom = self.applicable_zoom();
|
let applicable_zoom = self.applicable_zoom();
|
||||||
|
|
||||||
let scaling_filter = if obj.metadata().format().map_or(false, |x| x.is_svg()) {
|
let scaling_filter = if obj.metadata().is_svg() {
|
||||||
// Looks better in SVG animations and avoids rendering issues
|
// Looks better in SVG animations and avoids rendering issues
|
||||||
gsk::ScalingFilter::Linear
|
gsk::ScalingFilter::Linear
|
||||||
} else if applicable_zoom < 1. {
|
} else if applicable_zoom < 1. {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright (c) 2023 Sophie Herold
|
// Copyright (c) 2023-2024 Sophie Herold
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// 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
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -88,7 +88,7 @@ impl imp::LpImage {
|
||||||
pub(super) fn max_zoom(&self) -> f64 {
|
pub(super) fn max_zoom(&self) -> f64 {
|
||||||
let obj = self.obj();
|
let obj = self.obj();
|
||||||
|
|
||||||
if obj.metadata().format().map_or(false, |x| x.is_svg()) {
|
if obj.metadata().is_svg() {
|
||||||
let (width, height) = self.original_dimensions();
|
let (width, height) = self.original_dimensions();
|
||||||
// Avoid division by 0
|
// Avoid division by 0
|
||||||
let long_side = f64::max(1., i32::max(width, height) as f64);
|
let long_side = f64::max(1., i32::max(width, height) as f64);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright (c) 2023 Sophie Herold
|
// Copyright (c) 2023-2024 Sophie Herold
|
||||||
// Copyright (c) 2023 Julian Hofer
|
// Copyright (c) 2023 Julian Hofer
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
|
@ -27,7 +27,7 @@ use glib::Properties;
|
||||||
use gtk::CompositeTemplate;
|
use gtk::CompositeTemplate;
|
||||||
use once_cell::sync::OnceCell;
|
use once_cell::sync::OnceCell;
|
||||||
|
|
||||||
use crate::decoder::{self, ImageDimensionDetails};
|
use crate::decoder::{self};
|
||||||
use crate::deps::*;
|
use crate::deps::*;
|
||||||
use crate::util::gettext::*;
|
use crate::util::gettext::*;
|
||||||
use crate::widgets::{LpImage, LpPrintPreview};
|
use crate::widgets::{LpImage, LpPrintPreview};
|
||||||
|
|
@ -370,7 +370,7 @@ mod imp {
|
||||||
imp.title.set_title(&gettext_f(
|
imp.title.set_title(&gettext_f(
|
||||||
// Translators: {} is a placeholder for the filename
|
// Translators: {} is a placeholder for the filename
|
||||||
"Print “{}”",
|
"Print “{}”",
|
||||||
&[&basename],
|
[basename],
|
||||||
));
|
));
|
||||||
if let Some(printer) = operation.print_settings().and_then(|x| x.printer()) {
|
if let Some(printer) = operation.print_settings().and_then(|x| x.printer()) {
|
||||||
imp.title.set_subtitle(&printer);
|
imp.title.set_subtitle(&printer);
|
||||||
|
|
@ -514,7 +514,8 @@ impl LpPrint {
|
||||||
|
|
||||||
pub fn original_size(&self) -> (f64, f64) {
|
pub fn original_size(&self) -> (f64, f64) {
|
||||||
let image = self.image();
|
let image = self.image();
|
||||||
if let ImageDimensionDetails::Svg(_, Some((w, h))) = image.dimension_details() {
|
let metadata = image.metadata();
|
||||||
|
if let Some((w, h)) = metadata.dimensions_inch() {
|
||||||
let dpi = self.dpi();
|
let dpi = self.dpi();
|
||||||
|
|
||||||
((w * dpi).round(), (h * dpi).round())
|
((w * dpi).round(), (h * dpi).round())
|
||||||
|
|
@ -897,7 +898,7 @@ impl LpPrint {
|
||||||
let texture_scale = self.user_width() / orig_width;
|
let texture_scale = self.user_width() / orig_width;
|
||||||
let cairo_scale = cairo_dpi / self.dpi();
|
let cairo_scale = cairo_dpi / self.dpi();
|
||||||
|
|
||||||
let texture = if image.metadata().format().map_or(false, |x| x.is_svg()) {
|
let texture = if image.metadata().is_svg() {
|
||||||
// Render SVG to exact needed sizes
|
// Render SVG to exact needed sizes
|
||||||
// TODO: This should be async
|
// TODO: This should be async
|
||||||
decoder::formats::Svg::render_print(
|
decoder::formats::Svg::render_print(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright (c) 2022-2023 Sophie Herold
|
// Copyright (c) 2022-2024 Sophie Herold
|
||||||
// Copyright (c) 2022 Christopher Davis
|
// Copyright (c) 2022 Christopher Davis
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
|
@ -23,9 +23,9 @@ use adw::subclass::prelude::*;
|
||||||
use glib::Properties;
|
use glib::Properties;
|
||||||
use gtk::CompositeTemplate;
|
use gtk::CompositeTemplate;
|
||||||
|
|
||||||
use crate::decoder::ImageDimensionDetails;
|
|
||||||
use crate::deps::*;
|
use crate::deps::*;
|
||||||
use crate::util;
|
use crate::util::gettext::*;
|
||||||
|
use crate::util::{self};
|
||||||
use crate::widgets::image::LpImage;
|
use crate::widgets::image::LpImage;
|
||||||
|
|
||||||
const FALLBACK: &str = "–";
|
const FALLBACK: &str = "–";
|
||||||
|
|
@ -177,7 +177,7 @@ mod imp {
|
||||||
|
|
||||||
// Image info
|
// Image info
|
||||||
Self::update_row(&self.image_size, self.image_size());
|
Self::update_row(&self.image_size, self.image_size());
|
||||||
Self::update_row(&self.image_format, metadata.format_name());
|
Self::update_row(&self.image_format, self.format_name());
|
||||||
Self::update_row(&self.file_size, metadata.file_size());
|
Self::update_row(&self.file_size, metadata.file_size());
|
||||||
|
|
||||||
// Dates
|
// Dates
|
||||||
|
|
@ -222,12 +222,36 @@ mod imp {
|
||||||
.and_then(|p| util::get_file_display_name(&p))
|
.and_then(|p| util::get_file_display_name(&p))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_name(&self) -> Option<String> {
|
||||||
|
let image = self.image.borrow();
|
||||||
|
let metadata = image.as_ref()?.metadata();
|
||||||
|
|
||||||
|
let mut format = metadata.format_name().unwrap_or(gettext("Unknown"));
|
||||||
|
|
||||||
|
if metadata.alpha_channel().unwrap_or(false) {
|
||||||
|
// Translators: Addition of image being transparent to format name
|
||||||
|
format.push_str(&gettext(", transparent"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.grayscale().unwrap_or(false) {
|
||||||
|
// Translators: Addition of image being grayscale to format name
|
||||||
|
format.push_str(&gettext(", grayscale"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(bit_depth) = metadata.bit_depth() {
|
||||||
|
// Translators: Addition of bit size to format name
|
||||||
|
format.push_str(&gettext_f(", {}\u{202F}bit", [bit_depth.to_string()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(format)
|
||||||
|
}
|
||||||
|
|
||||||
fn image_size(&self) -> Option<String> {
|
fn image_size(&self) -> Option<String> {
|
||||||
let obj = self.obj();
|
let obj = self.obj();
|
||||||
|
|
||||||
if let Some(image) = obj.image() {
|
if let Some(image) = obj.image() {
|
||||||
match image.dimension_details() {
|
match image.metadata().dimensions_text() {
|
||||||
ImageDimensionDetails::Svg(string, _) => Some(string),
|
Some(string) => Some(string.to_string()),
|
||||||
_ => {
|
_ => {
|
||||||
let (width, height) = image.image_size();
|
let (width, height) = image.image_size();
|
||||||
if width > 0 && height > 0 {
|
if width > 0 && height > 0 {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Copyright (c) 2020-2023 Christopher Davis
|
// Copyright (c) 2020-2023 Christopher Davis
|
||||||
// Copyright (c) 2022-2023 Sophie Herold
|
// Copyright (c) 2022-2024 Sophie Herold
|
||||||
// Copyright (c) 2022 Elton A Rodrigues
|
// Copyright (c) 2022 Elton A Rodrigues
|
||||||
// Copyright (c) 2022 Maximiliano Sandoval R
|
// Copyright (c) 2022 Maximiliano Sandoval R
|
||||||
// Copyright (c) 2023 Matteo Nardi
|
// Copyright (c) 2023 Matteo Nardi
|
||||||
|
|
@ -684,7 +684,7 @@ impl LpWindow {
|
||||||
.heading(gettext("Permanently Delete Image?"))
|
.heading(gettext("Permanently Delete Image?"))
|
||||||
.body(gettext_f(
|
.body(gettext_f(
|
||||||
"The image “{}” can only be deleted permanently.",
|
"The image “{}” can only be deleted permanently.",
|
||||||
&[&PathBuf::from(&path.file_name().unwrap_or_default())
|
[PathBuf::from(&path.file_name().unwrap_or_default())
|
||||||
.display()
|
.display()
|
||||||
.to_string()],
|
.to_string()],
|
||||||
))
|
))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue