mirror of
https://github.com/typst/typst
synced 2025-05-21 04:25:28 +08:00
parent
8ac7be95e6
commit
27771bc329
17
src/doc.rs
17
src/doc.rs
@ -683,20 +683,19 @@ cast_to_value! {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::{doc::Region, util::option_eq};
|
use super::*;
|
||||||
|
use crate::util::option_eq;
|
||||||
#[test]
|
|
||||||
fn test_partialeq_str() {
|
|
||||||
let region = Region([b'U', b'S']);
|
|
||||||
assert_eq!(region, "US");
|
|
||||||
assert_ne!(region, "AB");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_region_option_eq() {
|
fn test_region_option_eq() {
|
||||||
let region = Some(Region([b'U', b'S']));
|
let region = Some(Region([b'U', b'S']));
|
||||||
|
|
||||||
assert!(option_eq(region, "US"));
|
assert!(option_eq(region, "US"));
|
||||||
assert!(!option_eq(region, "AB"));
|
assert!(!option_eq(region, "AB"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_document_is_send() {
|
||||||
|
fn ensure_send<T: Send>() {}
|
||||||
|
ensure_send::<Document>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,7 +19,7 @@ pub fn write_images(ctx: &mut PdfContext) {
|
|||||||
|
|
||||||
// Add the primary image.
|
// Add the primary image.
|
||||||
// TODO: Error if image could not be encoded.
|
// TODO: Error if image could not be encoded.
|
||||||
match image.decoded() {
|
match image.decoded().as_ref() {
|
||||||
DecodedImage::Raster(dynamic, icc, format) => {
|
DecodedImage::Raster(dynamic, icc, format) => {
|
||||||
// TODO: Error if image could not be encoded.
|
// TODO: Error if image could not be encoded.
|
||||||
let (data, filter, has_color) = encode_image(*format, dynamic).unwrap();
|
let (data, filter, has_color) = encode_image(*format, dynamic).unwrap();
|
||||||
|
@ -518,7 +518,7 @@ fn render_image(
|
|||||||
#[comemo::memoize]
|
#[comemo::memoize]
|
||||||
fn scaled_texture(image: &Image, w: u32, h: u32) -> Option<Arc<sk::Pixmap>> {
|
fn scaled_texture(image: &Image, w: u32, h: u32) -> Option<Arc<sk::Pixmap>> {
|
||||||
let mut pixmap = sk::Pixmap::new(w, h)?;
|
let mut pixmap = sk::Pixmap::new(w, h)?;
|
||||||
match image.decoded() {
|
match image.decoded().as_ref() {
|
||||||
DecodedImage::Raster(dynamic, _, _) => {
|
DecodedImage::Raster(dynamic, _, _) => {
|
||||||
let downscale = w < image.width();
|
let downscale = w < image.width();
|
||||||
let filter =
|
let filter =
|
||||||
|
256
src/image.rs
256
src/image.rs
@ -1,13 +1,13 @@
|
|||||||
//! Image handling.
|
//! Image handling.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fmt::{self, Debug, Formatter};
|
use std::fmt::{self, Debug, Formatter};
|
||||||
use std::hash::{Hash, Hasher};
|
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use comemo::Tracked;
|
use comemo::{Prehashed, Track, Tracked};
|
||||||
use ecow::EcoString;
|
use ecow::{EcoString, EcoVec};
|
||||||
use image::codecs::gif::GifDecoder;
|
use image::codecs::gif::GifDecoder;
|
||||||
use image::codecs::jpeg::JpegDecoder;
|
use image::codecs::jpeg::JpegDecoder;
|
||||||
use image::codecs::png::PngDecoder;
|
use image::codecs::png::PngDecoder;
|
||||||
@ -16,40 +16,60 @@ use image::{ImageDecoder, ImageResult};
|
|||||||
use usvg::{TreeParsing, TreeTextToPath};
|
use usvg::{TreeParsing, TreeTextToPath};
|
||||||
|
|
||||||
use crate::diag::{format_xml_like_error, StrResult};
|
use crate::diag::{format_xml_like_error, StrResult};
|
||||||
|
use crate::font::Font;
|
||||||
|
use crate::geom::Axes;
|
||||||
use crate::util::Buffer;
|
use crate::util::Buffer;
|
||||||
use crate::World;
|
use crate::World;
|
||||||
|
|
||||||
/// A raster or vector image.
|
/// A raster or vector image.
|
||||||
///
|
///
|
||||||
/// Values of this type are cheap to clone and hash.
|
/// Values of this type are cheap to clone and hash.
|
||||||
#[derive(Clone)]
|
#[derive(Clone, Hash, Eq, PartialEq)]
|
||||||
pub struct Image {
|
pub struct Image(Arc<Prehashed<Repr>>);
|
||||||
|
|
||||||
|
/// The internal representation.
|
||||||
|
#[derive(Hash)]
|
||||||
|
struct Repr {
|
||||||
/// The raw, undecoded image data.
|
/// The raw, undecoded image data.
|
||||||
data: Buffer,
|
data: Buffer,
|
||||||
/// The format of the encoded `buffer`.
|
/// The format of the encoded `buffer`.
|
||||||
format: ImageFormat,
|
format: ImageFormat,
|
||||||
/// The decoded image.
|
/// The size of the image.
|
||||||
decoded: Arc<DecodedImage>,
|
size: Axes<u32>,
|
||||||
|
/// A loader for fonts referenced by an image (currently, only applies to
|
||||||
|
/// SVG).
|
||||||
|
loader: PreparedLoader,
|
||||||
/// A text describing the image.
|
/// A text describing the image.
|
||||||
alt: Option<EcoString>,
|
alt: Option<EcoString>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Image {
|
impl Image {
|
||||||
/// Create an image from a buffer and a format.
|
/// Create an image from a buffer and a format.
|
||||||
|
#[comemo::memoize]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
data: Buffer,
|
data: Buffer,
|
||||||
format: ImageFormat,
|
format: ImageFormat,
|
||||||
alt: Option<EcoString>,
|
alt: Option<EcoString>,
|
||||||
) -> StrResult<Self> {
|
) -> StrResult<Self> {
|
||||||
|
let loader = PreparedLoader::default();
|
||||||
let decoded = match format {
|
let decoded = match format {
|
||||||
ImageFormat::Raster(format) => decode_raster(&data, format)?,
|
ImageFormat::Raster(format) => decode_raster(&data, format)?,
|
||||||
ImageFormat::Vector(VectorFormat::Svg) => decode_svg(&data)?,
|
ImageFormat::Vector(VectorFormat::Svg) => {
|
||||||
|
decode_svg(&data, (&loader as &dyn SvgFontLoader).track())?
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self { data, format, decoded, alt })
|
Ok(Self(Arc::new(Prehashed::new(Repr {
|
||||||
|
data,
|
||||||
|
format,
|
||||||
|
size: decoded.size(),
|
||||||
|
loader,
|
||||||
|
alt,
|
||||||
|
}))))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a font-dependant image from a buffer and a format.
|
/// Create a font-dependant image from a buffer and a format.
|
||||||
|
#[comemo::memoize]
|
||||||
pub fn with_fonts(
|
pub fn with_fonts(
|
||||||
data: Buffer,
|
data: Buffer,
|
||||||
format: ImageFormat,
|
format: ImageFormat,
|
||||||
@ -57,44 +77,62 @@ impl Image {
|
|||||||
fallback_family: Option<&str>,
|
fallback_family: Option<&str>,
|
||||||
alt: Option<EcoString>,
|
alt: Option<EcoString>,
|
||||||
) -> StrResult<Self> {
|
) -> StrResult<Self> {
|
||||||
|
let loader = WorldLoader::new(world, fallback_family);
|
||||||
let decoded = match format {
|
let decoded = match format {
|
||||||
ImageFormat::Raster(format) => decode_raster(&data, format)?,
|
ImageFormat::Raster(format) => decode_raster(&data, format)?,
|
||||||
ImageFormat::Vector(VectorFormat::Svg) => {
|
ImageFormat::Vector(VectorFormat::Svg) => {
|
||||||
decode_svg_with_fonts(&data, world, fallback_family)?
|
decode_svg(&data, (&loader as &dyn SvgFontLoader).track())?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self { data, format, decoded, alt })
|
Ok(Self(Arc::new(Prehashed::new(Repr {
|
||||||
|
data,
|
||||||
|
format,
|
||||||
|
size: decoded.size(),
|
||||||
|
loader: loader.into_prepared(),
|
||||||
|
alt,
|
||||||
|
}))))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The raw image data.
|
/// The raw image data.
|
||||||
pub fn data(&self) -> &Buffer {
|
pub fn data(&self) -> &Buffer {
|
||||||
&self.data
|
&self.0.data
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The format of the image.
|
/// The format of the image.
|
||||||
pub fn format(&self) -> ImageFormat {
|
pub fn format(&self) -> ImageFormat {
|
||||||
self.format
|
self.0.format
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The decoded version of the image.
|
/// The size of the image in pixels.
|
||||||
pub fn decoded(&self) -> &DecodedImage {
|
pub fn size(&self) -> Axes<u32> {
|
||||||
&self.decoded
|
self.0.size
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The width of the image in pixels.
|
/// The width of the image in pixels.
|
||||||
pub fn width(&self) -> u32 {
|
pub fn width(&self) -> u32 {
|
||||||
self.decoded().width()
|
self.size().x
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The height of the image in pixels.
|
/// The height of the image in pixels.
|
||||||
pub fn height(&self) -> u32 {
|
pub fn height(&self) -> u32 {
|
||||||
self.decoded().height()
|
self.size().y
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A text describing the image.
|
/// A text describing the image.
|
||||||
pub fn alt(&self) -> Option<&str> {
|
pub fn alt(&self) -> Option<&str> {
|
||||||
self.alt.as_deref()
|
self.0.alt.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The decoded version of the image.
|
||||||
|
pub fn decoded(&self) -> Arc<DecodedImage> {
|
||||||
|
match self.format() {
|
||||||
|
ImageFormat::Raster(format) => decode_raster(self.data(), format),
|
||||||
|
ImageFormat::Vector(VectorFormat::Svg) => {
|
||||||
|
decode_svg(self.data(), (&self.0.loader as &dyn SvgFontLoader).track())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.unwrap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,21 +147,6 @@ impl Debug for Image {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Eq for Image {}
|
|
||||||
|
|
||||||
impl PartialEq for Image {
|
|
||||||
fn eq(&self, other: &Self) -> bool {
|
|
||||||
self.data() == other.data() && self.format() == other.format()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Hash for Image {
|
|
||||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
|
||||||
self.data().hash(state);
|
|
||||||
self.format().hash(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A raster or vector image format.
|
/// A raster or vector image format.
|
||||||
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
|
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
|
||||||
pub enum ImageFormat {
|
pub enum ImageFormat {
|
||||||
@ -184,6 +207,11 @@ pub enum DecodedImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DecodedImage {
|
impl DecodedImage {
|
||||||
|
/// The size of the image in pixels.
|
||||||
|
pub fn size(&self) -> Axes<u32> {
|
||||||
|
Axes::new(self.width(), self.height())
|
||||||
|
}
|
||||||
|
|
||||||
/// The width of the image in pixels.
|
/// The width of the image in pixels.
|
||||||
pub fn width(&self) -> u32 {
|
pub fn width(&self) -> u32 {
|
||||||
match self {
|
match self {
|
||||||
@ -230,74 +258,54 @@ fn decode_raster(data: &Buffer, format: RasterFormat) -> StrResult<Arc<DecodedIm
|
|||||||
|
|
||||||
/// Decode an SVG image.
|
/// Decode an SVG image.
|
||||||
#[comemo::memoize]
|
#[comemo::memoize]
|
||||||
fn decode_svg(data: &Buffer) -> StrResult<Arc<DecodedImage>> {
|
fn decode_svg(
|
||||||
let opts = usvg::Options::default();
|
|
||||||
let tree = usvg::Tree::from_data(data, &opts).map_err(format_usvg_error)?;
|
|
||||||
Ok(Arc::new(DecodedImage::Svg(tree)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode an SVG image with access to fonts.
|
|
||||||
#[comemo::memoize]
|
|
||||||
fn decode_svg_with_fonts(
|
|
||||||
data: &Buffer,
|
data: &Buffer,
|
||||||
world: Tracked<dyn World + '_>,
|
loader: Tracked<dyn SvgFontLoader + '_>,
|
||||||
fallback_family: Option<&str>,
|
|
||||||
) -> StrResult<Arc<DecodedImage>> {
|
) -> StrResult<Arc<DecodedImage>> {
|
||||||
let mut opts = usvg::Options::default();
|
// Disable usvg's default to "Times New Roman". Instead, we default to
|
||||||
|
// the empty family and later, when we traverse the SVG, we check for
|
||||||
// Recover the non-lowercased version of the family because
|
// empty and non-existing family names and replace them with the true
|
||||||
// usvg is case sensitive.
|
// fallback family. This way, we can memoize SVG decoding with and without
|
||||||
let book = world.book();
|
// fonts if the SVG does not contain text.
|
||||||
let fallback_family = fallback_family
|
let opts = usvg::Options { font_family: String::new(), ..Default::default() };
|
||||||
.and_then(|lowercase| book.select_family(lowercase).next())
|
|
||||||
.and_then(|index| book.info(index))
|
|
||||||
.map(|info| info.family.clone());
|
|
||||||
|
|
||||||
if let Some(family) = &fallback_family {
|
|
||||||
opts.font_family = family.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut tree = usvg::Tree::from_data(data, &opts).map_err(format_usvg_error)?;
|
let mut tree = usvg::Tree::from_data(data, &opts).map_err(format_usvg_error)?;
|
||||||
if tree.has_text_nodes() {
|
if tree.has_text_nodes() {
|
||||||
let fontdb = load_svg_fonts(&tree, world, fallback_family.as_deref());
|
let fontdb = load_svg_fonts(&tree, loader);
|
||||||
tree.convert_text(&fontdb);
|
tree.convert_text(&fontdb);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Arc::new(DecodedImage::Svg(tree)))
|
Ok(Arc::new(DecodedImage::Svg(tree)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discover and load the fonts referenced by an SVG.
|
/// Discover and load the fonts referenced by an SVG.
|
||||||
fn load_svg_fonts(
|
fn load_svg_fonts(
|
||||||
tree: &usvg::Tree,
|
tree: &usvg::Tree,
|
||||||
world: Tracked<dyn World + '_>,
|
loader: Tracked<dyn SvgFontLoader + '_>,
|
||||||
fallback_family: Option<&str>,
|
|
||||||
) -> fontdb::Database {
|
) -> fontdb::Database {
|
||||||
let mut referenced = BTreeMap::<EcoString, bool>::new();
|
let mut referenced = BTreeMap::<EcoString, bool>::new();
|
||||||
let mut fontdb = fontdb::Database::new();
|
let mut fontdb = fontdb::Database::new();
|
||||||
let mut load = |family: &str| {
|
let mut load = |family_cased: &str| {
|
||||||
let lower = EcoString::from(family.trim()).to_lowercase();
|
let family = EcoString::from(family_cased.trim()).to_lowercase();
|
||||||
if let Some(&success) = referenced.get(&lower) {
|
if let Some(&success) = referenced.get(&family) {
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
// We load all variants for the family, since we don't know which will
|
// We load all variants for the family, since we don't know which will
|
||||||
// be used.
|
// be used.
|
||||||
let mut success = false;
|
let mut success = false;
|
||||||
for id in world.book().select_family(&lower) {
|
for font in loader.load(&family) {
|
||||||
if let Some(font) = world.font(id) {
|
let source = Arc::new(font.data().clone());
|
||||||
let source = Arc::new(font.data().clone());
|
fontdb.load_font_source(fontdb::Source::Binary(source));
|
||||||
fontdb.load_font_source(fontdb::Source::Binary(source));
|
success = true;
|
||||||
success = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
referenced.insert(lower, success);
|
referenced.insert(family, success);
|
||||||
success
|
success
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load fallback family.
|
// Load fallback family.
|
||||||
if let Some(family) = fallback_family {
|
let fallback_cased = loader.fallback();
|
||||||
load(family);
|
if let Some(family_cased) = fallback_cased {
|
||||||
|
load(family_cased);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find out which font families are referenced by the SVG.
|
// Find out which font families are referenced by the SVG.
|
||||||
@ -305,10 +313,10 @@ fn load_svg_fonts(
|
|||||||
let usvg::NodeKind::Text(text) = &mut *node.borrow_mut() else { return };
|
let usvg::NodeKind::Text(text) = &mut *node.borrow_mut() else { return };
|
||||||
for chunk in &mut text.chunks {
|
for chunk in &mut text.chunks {
|
||||||
for span in &mut chunk.spans {
|
for span in &mut chunk.spans {
|
||||||
for family in &mut span.font.families {
|
for family_cased in &mut span.font.families {
|
||||||
if !load(family) {
|
if family_cased.is_empty() || !load(family_cased) {
|
||||||
let Some(fallback) = fallback_family else { continue };
|
let Some(fallback) = fallback_cased else { continue };
|
||||||
*family = fallback.into();
|
*family_cased = fallback.into();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -329,6 +337,96 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Interface for loading fonts for an SVG.
|
||||||
|
///
|
||||||
|
/// Can be backed by a `WorldLoader` or a `PreparedLoader`. The first is used
|
||||||
|
/// when the image is initially decoded. It records all required fonts and
|
||||||
|
/// produces a `PreparedLoader` from it. This loader can then be used to
|
||||||
|
/// redecode the image with a cache hit from the initial decoding. This way, we
|
||||||
|
/// can cheaply access the decoded version of an image.
|
||||||
|
///
|
||||||
|
/// The alternative would be to store the decoded image directly in the image,
|
||||||
|
/// but that would make `Image` not `Send` because `usvg::Tree` is not `Send`.
|
||||||
|
/// The current design also has the added benefit that large decoded images can
|
||||||
|
/// be evicted if they are not used anymore.
|
||||||
|
#[comemo::track]
|
||||||
|
trait SvgFontLoader {
|
||||||
|
/// Load all fonts for the given lowercased font family.
|
||||||
|
fn load(&self, lower_family: &str) -> EcoVec<Font>;
|
||||||
|
|
||||||
|
/// The case-sensitive name of the fallback family.
|
||||||
|
fn fallback(&self) -> Option<&str>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads fonts for an SVG from a world
|
||||||
|
struct WorldLoader<'a> {
|
||||||
|
world: Tracked<'a, dyn World + 'a>,
|
||||||
|
seen: RefCell<BTreeMap<EcoString, EcoVec<Font>>>,
|
||||||
|
fallback_family_cased: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> WorldLoader<'a> {
|
||||||
|
fn new(world: Tracked<'a, dyn World + 'a>, fallback_family: Option<&str>) -> Self {
|
||||||
|
// Recover the non-lowercased version of the family because
|
||||||
|
// usvg is case sensitive.
|
||||||
|
let book = world.book();
|
||||||
|
let fallback_family_cased = fallback_family
|
||||||
|
.and_then(|lowercase| book.select_family(lowercase).next())
|
||||||
|
.and_then(|index| book.info(index))
|
||||||
|
.map(|info| info.family.clone());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
world,
|
||||||
|
fallback_family_cased,
|
||||||
|
seen: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_prepared(self) -> PreparedLoader {
|
||||||
|
PreparedLoader {
|
||||||
|
families: self.seen.into_inner(),
|
||||||
|
fallback_family_cased: self.fallback_family_cased,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SvgFontLoader for WorldLoader<'_> {
|
||||||
|
fn load(&self, family: &str) -> EcoVec<Font> {
|
||||||
|
self.seen
|
||||||
|
.borrow_mut()
|
||||||
|
.entry(family.into())
|
||||||
|
.or_insert_with(|| {
|
||||||
|
self.world
|
||||||
|
.book()
|
||||||
|
.select_family(family)
|
||||||
|
.filter_map(|id| self.world.font(id))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback(&self) -> Option<&str> {
|
||||||
|
self.fallback_family_cased.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads fonts for an SVG from a prepared list.
|
||||||
|
#[derive(Default, Hash)]
|
||||||
|
struct PreparedLoader {
|
||||||
|
families: BTreeMap<EcoString, EcoVec<Font>>,
|
||||||
|
fallback_family_cased: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SvgFontLoader for PreparedLoader {
|
||||||
|
fn load(&self, family: &str) -> EcoVec<Font> {
|
||||||
|
self.families.get(family).cloned().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback(&self) -> Option<&str> {
|
||||||
|
self.fallback_family_cased.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Format the user-facing raster graphic decoding error message.
|
/// Format the user-facing raster graphic decoding error message.
|
||||||
fn format_image_error(error: image::ImageError) -> EcoString {
|
fn format_image_error(error: image::ImageError) -> EcoString {
|
||||||
match error {
|
match error {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user