change to asynchronous and add cropping
This commit is contained in:
parent
ddfac89ac4
commit
0261b94820
3 changed files with 372 additions and 55 deletions
96
Cargo.lock
generated
96
Cargo.lock
generated
|
|
@ -111,6 +111,95 @@ dependencies = [
|
||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-executor",
|
||||||
|
"futures-io",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-channel"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-core"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-executor"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-io"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-macro"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-sink"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-task"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-util"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"futures-macro",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-task",
|
||||||
|
"memchr",
|
||||||
|
"pin-project-lite",
|
||||||
|
"pin-utils",
|
||||||
|
"slab",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gimli"
|
name = "gimli"
|
||||||
version = "0.31.1"
|
version = "0.31.1"
|
||||||
|
|
@ -224,6 +313,12 @@ version = "0.2.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-utils"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "png"
|
name = "png"
|
||||||
version = "0.17.16"
|
version = "0.17.16"
|
||||||
|
|
@ -318,6 +413,7 @@ name = "stitcher"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fast_image_resize",
|
"fast_image_resize",
|
||||||
|
"futures",
|
||||||
"png",
|
"png",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ name = "stitcher"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
png = "0.17.16"
|
png = "0.17.16"
|
||||||
tokio = { version = "1.46.0", features = ["full"] }
|
tokio = { version = "1.46.0", features = ["full"] }
|
||||||
fast_image_resize = "5.1.4"
|
fast_image_resize = "5.1.4"
|
||||||
|
futures = "0.3.31"
|
||||||
|
|
|
||||||
329
src/main.rs
329
src/main.rs
|
|
@ -1,75 +1,173 @@
|
||||||
use fast_image_resize as fr;
|
use fast_image_resize as fr;
|
||||||
use fr::{PixelType, ResizeAlg, ResizeOptions, Resizer, images::Image};
|
use fr::{PixelType, ResizeAlg, ResizeOptions, Resizer, images::Image};
|
||||||
use std::io::Cursor;
|
use futures::future::join_all;
|
||||||
|
use std::{
|
||||||
|
cell::UnsafeCell,
|
||||||
|
io::Cursor,
|
||||||
|
io::Write,
|
||||||
|
ops::{Bound, RangeBounds},
|
||||||
|
};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
// use tokio::net::TcpListener;
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let start = std::time::Instant::now();
|
// let listener = TcpListener::bind("127.0.0.1:8080").await?;
|
||||||
let base_path = "/mnt/c/Users/Formulatrix/Documents/Explorations/WebApplication1/tiles1705/";
|
// println!("Server listening on port 8080");
|
||||||
const SCALE: f64 = 0.3;
|
//
|
||||||
|
// loop {
|
||||||
|
// let (socket, _) = listener.accept().await?;
|
||||||
|
// tokio::spawn(async move {
|
||||||
|
// if let Err(e) = handle_client(socket).await {
|
||||||
|
// eprintln!("Error: {}", e);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// async fn handle_client(socket: tokio::net::TcpStream) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// // let file = std::fs::File::create("raw_image_123.png").unwrap();
|
||||||
|
// let offset_x = 0.5;
|
||||||
|
// let size_x = 0.5;
|
||||||
|
// let offset_y = 0.5;
|
||||||
|
// let size_y = 0.5;
|
||||||
|
// let rect = "A1:AE55";
|
||||||
|
// let scale = 0.4;
|
||||||
|
//
|
||||||
|
// resize_image(
|
||||||
|
// &mut socket.into_std().unwrap(),
|
||||||
|
// rect,
|
||||||
|
// scale,
|
||||||
|
// offset_x,
|
||||||
|
// offset_y,
|
||||||
|
// size_x,
|
||||||
|
// size_y,
|
||||||
|
// )
|
||||||
|
// .await;
|
||||||
|
//
|
||||||
|
// Ok(())
|
||||||
|
// }
|
||||||
|
let timer_start = std::time::Instant::now();
|
||||||
|
|
||||||
|
let file = std::fs::File::create("raw_image_123.png").unwrap();
|
||||||
|
let offset_x = 0.5;
|
||||||
|
let size_x = 0.5;
|
||||||
|
let offset_y = 0.5;
|
||||||
|
let size_y = 0.5;
|
||||||
|
let rect = "A1:AE55";
|
||||||
|
let scale = 0.4;
|
||||||
|
|
||||||
|
resize_image(file, rect, scale, offset_x, offset_y, size_x, size_y).await;
|
||||||
|
|
||||||
|
let duration = timer_start.elapsed();
|
||||||
|
println!("Operation completed in: {:.2?}", duration);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn resize_image(
|
||||||
|
out: impl Write,
|
||||||
|
rect: &str,
|
||||||
|
scale: f32,
|
||||||
|
offset_x: f32,
|
||||||
|
offset_y: f32,
|
||||||
|
size_x: f32,
|
||||||
|
size_y: f32,
|
||||||
|
) {
|
||||||
|
let mut rect = rect.split(":");
|
||||||
const SIZE: u32 = 720;
|
const SIZE: u32 = 720;
|
||||||
let actual_size = (SIZE as f64 * SCALE).trunc() as u32;
|
let actual_size = (SIZE as f32 * scale).trunc() as u32;
|
||||||
|
|
||||||
// Open the image (a PhotonImage is returned)
|
let start = rect.next().unwrap();
|
||||||
// let max_col = 30;
|
let end = rect.next().unwrap();
|
||||||
// let max_row = 30;
|
|
||||||
let max_col = 31;
|
|
||||||
let max_row = 55;
|
|
||||||
let canvas_width = (max_row * actual_size) as usize;
|
|
||||||
let canvas_height = (max_col * actual_size) as usize;
|
|
||||||
let mut canvas = vec![0u8; canvas_width * canvas_height * 3];
|
|
||||||
let mut resizer = Resizer::new();
|
|
||||||
for i in 1..=max_col {
|
|
||||||
for j in 1..=max_row {
|
|
||||||
let filename = format!("{}{}{}.png", base_path, number_to_column(i), j);
|
|
||||||
// println!("{filename}");
|
|
||||||
|
|
||||||
let data = Cursor::new(fs::read(filename).await.unwrap()); // .unwrap();
|
let (start_col_s, start_row) = split_before_number(start);
|
||||||
let decoder = png::Decoder::new(data);
|
let start_col = column_to_number(start_col_s);
|
||||||
let mut reader = decoder.read_info().unwrap();
|
let start_row = start_row.parse::<u32>().unwrap();
|
||||||
let mut buf = vec![0; reader.output_buffer_size()];
|
let (end_col_s, end_row) = split_before_number(end);
|
||||||
let _info = reader.next_frame(&mut buf).unwrap();
|
let end_col = column_to_number(end_col_s);
|
||||||
|
let end_row = end_row.parse::<u32>().unwrap();
|
||||||
|
let col_count = end_col - start_col + 1;
|
||||||
|
let row_count = end_row - start_row + 1;
|
||||||
|
let canvas_width = (row_count * actual_size) as usize;
|
||||||
|
let canvas_height = (col_count * actual_size) as usize;
|
||||||
|
let canvas = UnsafeVec::new(vec![0u8; canvas_width * canvas_height * 3]);
|
||||||
|
|
||||||
let src_img = Image::from_vec_u8(SIZE, SIZE, buf, PixelType::U8x3).unwrap();
|
let min_row = start_row + (row_count as f32 * offset_x).floor() as u32;
|
||||||
let mut img = Image::new(actual_size, actual_size, PixelType::U8x3);
|
let max_row = start_row + (row_count as f32 * (offset_x + size_x)).floor() as u32;
|
||||||
|
let min_col = start_col + (col_count as f32 * offset_y).floor() as u32;
|
||||||
|
let max_col = start_col + (col_count as f32 * (offset_y + size_y)).floor() as u32;
|
||||||
|
|
||||||
resizer
|
let mut handles = Vec::new();
|
||||||
.resize(
|
for i in start_col..=end_col {
|
||||||
&src_img,
|
for j in start_row..=end_row {
|
||||||
&mut img,
|
if i < min_col {
|
||||||
&ResizeOptions::new()
|
continue;
|
||||||
.resize_alg(ResizeAlg::Nearest)
|
}
|
||||||
.use_alpha(false),
|
if i > max_col {
|
||||||
)
|
continue;
|
||||||
.unwrap();
|
}
|
||||||
place_image(
|
if j < min_row {
|
||||||
&mut canvas,
|
continue;
|
||||||
canvas_width as usize,
|
}
|
||||||
canvas_height as usize,
|
if j > max_row {
|
||||||
&img.into_vec(),
|
continue;
|
||||||
(actual_size) as usize,
|
}
|
||||||
(actual_size) as usize,
|
handles.push(add_to_canvas(
|
||||||
(j * actual_size) as usize,
|
&canvas,
|
||||||
(i * actual_size) as usize,
|
i,
|
||||||
);
|
start_col,
|
||||||
|
j,
|
||||||
|
start_row,
|
||||||
|
SIZE,
|
||||||
|
actual_size,
|
||||||
|
canvas_width,
|
||||||
|
canvas_height,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let file = std::fs::File::create("raw_image.png").unwrap();
|
let results = join_all(handles).await;
|
||||||
let writer = std::io::BufWriter::new(file);
|
|
||||||
|
let writer = std::io::BufWriter::new(out);
|
||||||
|
|
||||||
|
let start_x = (offset_x * canvas_width as f32).trunc() as usize;
|
||||||
|
let start_y = (offset_y * canvas_height as f32).trunc() as usize;
|
||||||
|
let end_x = ((offset_x + size_x) * canvas_width as f32).trunc() as usize;
|
||||||
|
let end_y = ((offset_y + size_y) * canvas_height as f32).trunc() as usize;
|
||||||
|
|
||||||
|
let crop_width = end_x - start_x;
|
||||||
|
let crop_height = end_y - start_y;
|
||||||
|
|
||||||
let mut encoder = png::Encoder::new(
|
let mut encoder = png::Encoder::new(
|
||||||
writer,
|
writer,
|
||||||
canvas_width.try_into().unwrap(),
|
crop_width.try_into().unwrap(),
|
||||||
canvas_height.try_into().unwrap(),
|
crop_height.try_into().unwrap(),
|
||||||
);
|
);
|
||||||
encoder.set_color(png::ColorType::Rgb);
|
encoder.set_color(png::ColorType::Rgb);
|
||||||
encoder.set_depth(png::BitDepth::Eight);
|
encoder.set_depth(png::BitDepth::Eight);
|
||||||
|
|
||||||
|
println!("Writing img");
|
||||||
let mut png_writer = encoder.write_header().unwrap();
|
let mut png_writer = encoder.write_header().unwrap();
|
||||||
png_writer.write_image_data(&canvas).unwrap();
|
png_writer
|
||||||
|
.write_image_data(&crop_rgb_image(
|
||||||
let duration = start.elapsed();
|
&canvas.to_vec(),
|
||||||
println!("Operation completed in: {:.2?}", duration);
|
(actual_size * row_count) as usize,
|
||||||
|
(actual_size * col_count) as usize,
|
||||||
|
start_x,
|
||||||
|
start_y,
|
||||||
|
crop_width,
|
||||||
|
crop_height,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
fn split_before_number(input: &str) -> (&str, &str) {
|
||||||
|
let index = find_first_number_start(input);
|
||||||
|
(&input[..index], &input[index..])
|
||||||
|
}
|
||||||
|
fn find_first_number_start(s: &str) -> usize {
|
||||||
|
for (i, c) in s.char_indices() {
|
||||||
|
if c.is_ascii_digit() {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.len()
|
||||||
}
|
}
|
||||||
fn number_to_column(mut num: u32) -> String {
|
fn number_to_column(mut num: u32) -> String {
|
||||||
let mut result = String::new();
|
let mut result = String::new();
|
||||||
|
|
@ -83,8 +181,62 @@ fn number_to_column(mut num: u32) -> String {
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
fn column_to_number(s: &str) -> u32 {
|
||||||
|
let mut result = 0;
|
||||||
|
|
||||||
|
for ch in s.chars() {
|
||||||
|
result = result * 26 + (ch as u32 - 'A' as u32 + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
async fn add_to_canvas(
|
||||||
|
canvas: &UnsafeVec<u8>,
|
||||||
|
i: u32,
|
||||||
|
start_col: u32,
|
||||||
|
j: u32,
|
||||||
|
start_row: u32,
|
||||||
|
size: u32,
|
||||||
|
actual_size: u32,
|
||||||
|
canvas_width: usize,
|
||||||
|
canvas_height: usize,
|
||||||
|
) {
|
||||||
|
let mut resizer = Resizer::new();
|
||||||
|
let base_path = "/mnt/c/Users/Formulatrix/Documents/Explorations/WebApplication1/tiles1705/";
|
||||||
|
let filename = format!("{}{}{}.png", base_path, number_to_column(i), j);
|
||||||
|
// println!("{filename}");
|
||||||
|
|
||||||
|
let data = Cursor::new(fs::read(filename).await.unwrap()); // .unwrap();
|
||||||
|
let decoder = png::Decoder::new(data);
|
||||||
|
let mut reader = decoder.read_info().unwrap();
|
||||||
|
let mut buf = vec![0; reader.output_buffer_size()];
|
||||||
|
let _info = reader.next_frame(&mut buf).unwrap();
|
||||||
|
|
||||||
|
let src_img = Image::from_vec_u8(size, size, buf, PixelType::U8x3).unwrap();
|
||||||
|
let mut img = Image::new(actual_size, actual_size, PixelType::U8x3);
|
||||||
|
|
||||||
|
resizer
|
||||||
|
.resize(
|
||||||
|
&src_img,
|
||||||
|
&mut img,
|
||||||
|
&ResizeOptions::new()
|
||||||
|
.resize_alg(ResizeAlg::Nearest)
|
||||||
|
.use_alpha(false),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
place_image(
|
||||||
|
&canvas,
|
||||||
|
canvas_width,
|
||||||
|
canvas_height,
|
||||||
|
&img.into_vec(),
|
||||||
|
(actual_size) as usize,
|
||||||
|
(actual_size) as usize,
|
||||||
|
((j - start_row) * actual_size) as usize,
|
||||||
|
((i - start_col) * actual_size) as usize,
|
||||||
|
);
|
||||||
|
}
|
||||||
fn place_image(
|
fn place_image(
|
||||||
canvas: &mut [u8],
|
canvas: &UnsafeVec<u8>,
|
||||||
canvas_width: usize,
|
canvas_width: usize,
|
||||||
canvas_height: usize,
|
canvas_height: usize,
|
||||||
image: &[u8],
|
image: &[u8],
|
||||||
|
|
@ -108,7 +260,74 @@ fn place_image(
|
||||||
let image_end = image_start + image_width * channels;
|
let image_end = image_start + image_width * channels;
|
||||||
|
|
||||||
if canvas_end <= canvas.len() && image_end <= image.len() {
|
if canvas_end <= canvas.len() && image_end <= image.len() {
|
||||||
canvas[canvas_start..canvas_end].copy_from_slice(&image[image_start..image_end]);
|
unsafe {
|
||||||
|
canvas.range_mut(canvas_start..canvas_end, &image[image_start..image_end]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn crop_rgb_image(
|
||||||
|
img: &[u8],
|
||||||
|
width: usize,
|
||||||
|
height: usize,
|
||||||
|
start_x: usize,
|
||||||
|
start_y: usize,
|
||||||
|
crop_width: usize,
|
||||||
|
crop_height: usize,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut cropped = Vec::with_capacity(crop_width * crop_height * 3);
|
||||||
|
|
||||||
|
let end_x = start_x + crop_width;
|
||||||
|
let end_y = start_y + crop_height;
|
||||||
|
for y in start_y..end_y {
|
||||||
|
let row_start = (y * width + start_x) * 3;
|
||||||
|
let row_end = (y * width + end_x) * 3;
|
||||||
|
cropped.extend_from_slice(&img[row_start..row_end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
cropped
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UnsafeVec<T> {
|
||||||
|
data: UnsafeCell<Vec<T>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Copy> UnsafeVec<T> {
|
||||||
|
pub fn new(initial: Vec<T>) -> Self {
|
||||||
|
UnsafeVec {
|
||||||
|
data: UnsafeCell::new(initial),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
unsafe { (*self.data.get()).len() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_vec(self) -> Vec<T> {
|
||||||
|
self.data.into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub unsafe fn range_mut<R: RangeBounds<usize>>(&self, range: R, value: &[T]) {
|
||||||
|
unsafe {
|
||||||
|
let vec = &mut *self.data.get();
|
||||||
|
|
||||||
|
let start = match range.start_bound() {
|
||||||
|
Bound::Included(&s) => s,
|
||||||
|
Bound::Excluded(&s) => s + 1,
|
||||||
|
Bound::Unbounded => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let end = match range.end_bound() {
|
||||||
|
Bound::Included(&e) => e + 1,
|
||||||
|
Bound::Excluded(&e) => e,
|
||||||
|
Bound::Unbounded => vec.len(),
|
||||||
|
};
|
||||||
|
|
||||||
|
&mut vec[start..end].copy_from_slice(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl<T> Sync for UnsafeVec<T> {}
|
||||||
|
unsafe impl<T> Send for UnsafeVec<T> {}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue