diff --git a/library/src/visualize/image.rs b/library/src/visualize/image.rs index 4b8be5c7f..86f87931b 100644 --- a/library/src/visualize/image.rs +++ b/library/src/visualize/image.rs @@ -32,7 +32,7 @@ pub struct ImageElem { let Spanned { v: path, span } = args.expect::>("path to image file")?; let path: EcoString = vm.locate(&path).at(span)?.to_string_lossy().into(); - let _ = load(vm.world(), &path, None).at(span)?; + let _ = load(vm.world(), &path, None, None).at(span)?; path )] pub path: EcoString, @@ -43,6 +43,9 @@ pub struct ImageElem { /// The height of the image. pub height: Smart>, + /// A text describing the image. + pub alt: Option, + /// How the image should adjust itself to a given area. #[default(ImageFit::Cover)] pub fit: ImageFit, @@ -57,7 +60,8 @@ impl Layout for ImageElem { ) -> SourceResult { let first = families(styles).next(); let fallback_family = first.as_ref().map(|f| f.as_str()); - let image = load(vt.world, &self.path(), fallback_family).unwrap(); + let image = + load(vt.world, &self.path(), fallback_family, self.alt(styles)).unwrap(); let sizing = Axes::new(self.width(styles), self.height(styles)); let region = sizing .zip(regions.base()) @@ -163,6 +167,7 @@ fn load( world: Tracked, full: &str, fallback_family: Option<&str>, + alt: Option, ) -> StrResult { let full = Path::new(full); let buffer = world.file(full)?; @@ -174,5 +179,5 @@ fn load( "svg" | "svgz" => ImageFormat::Vector(VectorFormat::Svg), _ => return Err("unknown image format".into()), }; - Image::with_fonts(buffer, format, world, fallback_family) + Image::with_fonts(buffer, format, world, fallback_family, alt) } diff --git a/src/export/pdf/page.rs b/src/export/pdf/page.rs index d6ead124d..94650a3fc 100644 --- a/src/export/pdf/page.rs +++ b/src/export/pdf/page.rs @@ -483,7 +483,21 @@ fn write_image(ctx: &mut PageContext, x: f32, y: f32, image: &Image, size: Size) let h = size.y.to_f32(); ctx.content.save_state(); ctx.content.transform([w, 0.0, 0.0, -h, x, y + h]); - ctx.content.x_object(Name(name.as_bytes())); + + if let Some(alt) = image.alt() { + let mut image_span = + ctx.content.begin_marked_content_with_properties(Name(b"Span")); + let mut image_alt = image_span.properties_direct(); + image_alt.pair(Name(b"Alt"), pdf_writer::Str(alt.as_bytes())); + image_alt.finish(); + image_span.finish(); + + ctx.content.x_object(Name(name.as_bytes())); + ctx.content.end_marked_content(); + } else { + ctx.content.x_object(Name(name.as_bytes())); + } + ctx.content.restore_state(); } diff --git a/src/export/render.rs b/src/export/render.rs index 3c2cea8dc..b3a8f5ce8 100644 --- a/src/export/render.rs +++ b/src/export/render.rs @@ -238,7 +238,7 @@ fn render_bitmap_glyph( let size = text.size.to_f32(); let ppem = size * ts.sy; let raster = text.font.ttf().glyph_raster_image(id, ppem as u16)?; - let image = Image::new(raster.data.into(), raster.format.into()).ok()?; + let image = Image::new(raster.data.into(), raster.format.into(), None).ok()?; // FIXME: Vertical alignment isn't quite right for Apple Color Emoji, // and maybe also for Noto Color Emoji. And: Is the size calculation diff --git a/src/image.rs b/src/image.rs index 09aaf24dc..49d919082 100644 --- a/src/image.rs +++ b/src/image.rs @@ -17,25 +17,30 @@ use crate::World; /// /// Values of this type are cheap to clone and hash. #[derive(Clone)] -pub struct Image(Arc); - -/// The internal representation. -struct Repr { +pub struct Image { /// The raw, undecoded image data. data: Buffer, /// The format of the encoded `buffer`. format: ImageFormat, /// The decoded image. - decoded: DecodedImage, + decoded: Arc, + /// A text describing the image. + alt: Option, } impl Image { /// Create an image from a buffer and a format. - pub fn new(data: Buffer, format: ImageFormat) -> StrResult { - match format { - ImageFormat::Raster(format) => decode_raster(data, format), - ImageFormat::Vector(VectorFormat::Svg) => decode_svg(data), - } + pub fn new( + data: Buffer, + format: ImageFormat, + alt: Option, + ) -> StrResult { + let decoded = match format { + ImageFormat::Raster(format) => decode_raster(&data, format)?, + ImageFormat::Vector(VectorFormat::Svg) => decode_svg(&data)?, + }; + + Ok(Self { data, format, decoded, alt }) } /// Create a font-dependant image from a buffer and a format. @@ -44,28 +49,31 @@ impl Image { format: ImageFormat, world: Tracked, fallback_family: Option<&str>, + alt: Option, ) -> StrResult { - match format { - ImageFormat::Raster(format) => decode_raster(data, format), + let decoded = match format { + ImageFormat::Raster(format) => decode_raster(&data, format)?, ImageFormat::Vector(VectorFormat::Svg) => { - decode_svg_with_fonts(data, world, fallback_family) + decode_svg_with_fonts(&data, world, fallback_family)? } - } + }; + + Ok(Self { data, format, decoded, alt }) } /// The raw image data. pub fn data(&self) -> &Buffer { - &self.0.data + &self.data } /// The format of the image. pub fn format(&self) -> ImageFormat { - self.0.format + self.format } /// The decoded version of the image. pub fn decoded(&self) -> &DecodedImage { - &self.0.decoded + &self.decoded } /// The width of the image in pixels. @@ -77,6 +85,11 @@ impl Image { pub fn height(&self) -> u32 { self.decoded().height() } + + /// A text describing the image. + pub fn alt(&self) -> Option<&str> { + self.alt.as_deref() + } } impl Debug for Image { @@ -85,6 +98,7 @@ impl Debug for Image { .field("format", &self.format()) .field("width", &self.width()) .field("height", &self.height()) + .field("alt", &self.alt()) .finish() } } @@ -183,38 +197,30 @@ impl DecodedImage { /// Decode a raster image. #[comemo::memoize] -fn decode_raster(data: Buffer, format: RasterFormat) -> StrResult { +fn decode_raster(data: &Buffer, format: RasterFormat) -> StrResult> { let cursor = io::Cursor::new(&data); let reader = image::io::Reader::with_format(cursor, format.into()); let dynamic = reader.decode().map_err(format_image_error)?; - Ok(Image(Arc::new(Repr { - data, - format: ImageFormat::Raster(format), - decoded: DecodedImage::Raster(dynamic, format), - }))) + Ok(Arc::new(DecodedImage::Raster(dynamic, format))) } /// Decode an SVG image. #[comemo::memoize] -fn decode_svg(data: Buffer) -> StrResult { +fn decode_svg(data: &Buffer) -> StrResult> { let opts = usvg::Options::default(); - let tree = usvg::Tree::from_data(&data, &opts.to_ref()).map_err(format_usvg_error)?; - Ok(Image(Arc::new(Repr { - data, - format: ImageFormat::Vector(VectorFormat::Svg), - decoded: DecodedImage::Svg(tree), - }))) + let tree = usvg::Tree::from_data(data, &opts.to_ref()).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, fallback_family: Option<&str>, -) -> StrResult { +) -> StrResult> { // Parse XML. - let xml = std::str::from_utf8(&data) + let xml = std::str::from_utf8(data) .map_err(|_| format_usvg_error(usvg::Error::NotAnUtf8Str))?; let document = roxmltree::Document::parse(xml) .map_err(|err| format_xml_like_error("svg", err))?; @@ -239,11 +245,7 @@ fn decode_svg_with_fonts( let tree = usvg::Tree::from_xmltree(&document, &opts.to_ref()).map_err(format_usvg_error)?; - Ok(Image(Arc::new(Repr { - data, - format: ImageFormat::Vector(VectorFormat::Svg), - decoded: DecodedImage::Svg(tree), - }))) + Ok(Arc::new(DecodedImage::Svg(tree))) } /// Discover and load the fonts referenced by an SVG. diff --git a/tests/typ/visualize/image.typ b/tests/typ/visualize/image.typ index 4b3d390f1..dc5b2ef67 100644 --- a/tests/typ/visualize/image.typ +++ b/tests/typ/visualize/image.typ @@ -21,7 +21,7 @@ #image("/monkey.svg", width: 100%, height: 20pt, fit: "stretch") // Make sure the bounding-box of the image is correct. -#align(bottom + right, image("/tiger.jpg", width: 40pt)) +#align(bottom + right, image("/tiger.jpg", width: 40pt, alt: "A tiger")) --- // Test all three fit modes.