Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_austenite/src/image.rs

7.7 KiB, 17 runs

created by r1870400018:36865, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! External image loading for figures.
2//!
3//! A `#figure(...)` body may be an `image("...")` or `padded-image("...")` call naming a file in the
4//! book's asset tree. This module resolves that path against the book root and loads it by type: a
5//! raster (PNG or JPEG) is decoded to straight RGBA through `fe2o3_graphics`'s [`Pixmap`] and handed to
6//! the block layer as a [`RasterImage`] it wraps in a [`DrawOp::Image`](crate::ir::DrawOp); an SVG is
7//! read as native vectors through `fe2o3_graphics`'s [`svg_doc`](oxedyne_fe2o3_graphics::svg_doc) into an
8//! [`SvgPicture`] the block layer scales to the figure width and maps to filled and stroked ops. The
9//! typesetter's SVG bakes its text to glyph outlines, so no font is needed to read it back.
10//!
11//! [`load_figure`] is the type-dispatching entry the figure path uses. [`load`] remains for the cover and
12//! logo, which are rasters; it still serves an SVG there from a same-stem raster, and where none exists
13//! the caller keeps its placeholder.
14//!
15//! The book root is not threaded through the block layer -- the reader sets one file with no notion of
16//! where the tree lives -- so the binary records it once with [`set_base_dir`] before authoring, and the
17//! render reads it back here. A source path is Typst-root-relative (`/assets/...`); it is resolved
18//! against the recorded base and, failing that, a couple of enclosing directories, so a chapter compiled
19//! on its own finds the assets through the book's `assets` symlink just as a whole-book compile does.
20
21use crate::ir::RasterImage;
22use crate::vfs;
23
24use oxedyne_fe2o3_core::prelude::*;
25use oxedyne_fe2o3_graphics::pixmap::Pixmap;
26use oxedyne_fe2o3_graphics::svg_doc::{
27 self,
28 SvgPicture,
29};
30
31use std::path::{
32 Path,
33 PathBuf,
34};
35use std::sync::RwLock;
36
37// The book root the render resolves image paths against, set once by the binary before authoring. A
38// process-global rather than a threaded argument because the block layer, which sets one file at a time,
39// carries no path of its own.
40static BASE: RwLock<Option<PathBuf>> = RwLock::new(None);
41
42/// Records the directory image paths resolve against -- the book's source directory, whose `assets`
43/// entry (a real tree or a symlink to the shared one) roots the `/assets/...` paths the figures name.
44pub fn set_base_dir(dir: PathBuf) -> Outcome<()> {
45 let mut guard = lock_write!(BASE, "While recording the image base directory");
46 *guard = Some(dir);
47 Ok(())
48}
49
50/// The recorded base directory, or `None` when the binary set none.
51fn base_dir() -> Outcome<Option<PathBuf>> {
52 let guard = lock_read!(BASE, "While reading the image base directory");
53 Ok(guard.clone())
54}
55
56/// Resolves a Typst image path to a file on disk, or `None` when none of the candidate roots holds it.
57///
58/// A leading `/` makes the path root-relative in Typst, not filesystem-absolute, so it is stripped and
59/// tried against the recorded base and two of its ancestors; a plain relative path is tried against the
60/// base and the working directory. The first candidate that exists wins.
61pub fn resolve(src: &str) -> Outcome<Option<PathBuf>> {
62 let rel = src.trim();
63 let stripped = rel.strip_prefix('/').unwrap_or(rel);
64 let mut cands: Vec<PathBuf> = Vec::new();
65 if let Some(base) = res!(base_dir()) {
66 cands.push(base.join(stripped));
67 if let Some(p) = base.parent() {
68 cands.push(p.join(stripped));
69 if let Some(pp) = p.parent() {
70 cands.push(pp.join(stripped));
71 }
72 }
73 }
74 cands.push(PathBuf::from(stripped));
75 Ok(cands.into_iter().find(|p| vfs::exists(p)))
76}
77
78/// A loaded figure: a decoded raster, or an SVG read as a resolution-independent [`SvgPicture`] the
79/// caller sizes and maps to drawing ops. The two are kept apart because a raster fills a rectangle and a
80/// vector carries its own paths, and the block layer draws them by different routes.
81pub enum Figure {
82 Raster(RasterImage),
83 Vector(SvgPicture),
84}
85
86/// Loads a figure by type: an SVG read as native vectors, a PNG or JPEG decoded to a raster. The path is
87/// resolved against the book root, and a type that is neither is an error the caller turns into a
88/// placeholder.
89pub fn load_figure(src: &str) -> Outcome<Figure> {
90 let path = res!(res!(resolve(src)).ok_or_else(|| err!(
91 "Could not resolve the figure image path {:?} against the book root.", src;
92 Input, Missing, File)));
93 let ext = path.extension()
94 .and_then(|e| e.to_str())
95 .unwrap_or("")
96 .to_lowercase();
97 if ext == "svg" {
98 let src = match vfs::read_to_string(&path) {
99 Ok(s) => s,
100 Err(e) => return Err(err!(e, "Could not read the SVG figure {:?}.", path; File, Read)),
101 };
102 return Ok(Figure::Vector(res!(svg_doc::read_document(&src))));
103 }
104 Ok(Figure::Raster(res!(load_file(&path))))
105}
106
107/// Loads the raster a figure names: a PNG or JPEG decoded straight, or -- for an SVG, which has no
108/// reader here -- the same-stem raster the book ships beside it. An SVG with no such raster, or a path
109/// that resolves to nothing, is an error the caller turns back into a placeholder.
110pub fn load(src: &str) -> Outcome<RasterImage> {
111 let path = res!(res!(resolve(src)).ok_or_else(|| err!(
112 "Could not resolve the figure image path {:?} against the book root.", src;
113 Input, Missing, File)));
114 load_file(&path)
115}
116
117/// Loads one resolved file: a raster decoded, an SVG served by its same-stem raster, anything else
118/// refused.
119fn load_file(path: &Path) -> Outcome<RasterImage> {
120 let ext = path.extension()
121 .and_then(|e| e.to_str())
122 .unwrap_or("")
123 .to_lowercase();
124 match ext.as_str() {
125 "png" | "jpg" | "jpeg" => decode_raster(path),
126 "svg" => {
127 // No SVG document reader exists in the workspace; the books ship a same-stem raster beside
128 // each vector figure, so that is loaded in its place. A missing one is reported, not guessed.
129 for alt in ["png", "jpg", "jpeg"] {
130 let raster = path.with_extension(alt);
131 if vfs::exists(&raster) {
132 return decode_raster(&raster);
133 }
134 }
135 Err(err!(
136 "The figure image {:?} is an SVG, which has no reader here, and no same-stem raster \
137 sits beside it to load instead.", path; Input, Invalid, Missing))
138 },
139 other => Err(err!(
140 "The figure image {:?} has an unsupported type {:?}.", path, other; Input, Invalid)),
141 }
142}
143
144/// Decodes a PNG or JPEG file to straight RGBA, choosing the decoder by the file's own magic bytes and
145/// falling back to its extension.
146fn decode_raster(path: &Path) -> Outcome<RasterImage> {
147 let bytes = match vfs::read(path) {
148 Ok(b) => b,
149 Err(e) => return Err(err!(e, "Could not read the image file {:?}.", path; File, Read)),
150 };
151 let pm = if bytes.starts_with(&[0x89, b'P', b'N', b'G']) {
152 res!(Pixmap::from_png(&bytes))
153 } else if bytes.starts_with(&[0xFF, 0xD8]) {
154 res!(Pixmap::from_jpeg(&bytes))
155 } else {
156 let ext = path.extension()
157 .and_then(|e| e.to_str())
158 .unwrap_or("")
159 .to_lowercase();
160 match ext.as_str() {
161 "png" => res!(Pixmap::from_png(&bytes)),
162 "jpg" | "jpeg" => res!(Pixmap::from_jpeg(&bytes)),
163 _ => return Err(err!(
164 "The image {:?} is neither PNG nor JPEG by its bytes or its extension.", path;
165 Input, Invalid)),
166 }
167 };
168 Ok(RasterImage { width: pm.width(), height: pm.height(), rgba: pm.into_data() })
169}
170
171/// Splits straight RGBA into the packed RGB the image XObject and `<image>` writers want, and a grey
172/// soft mask when any sample is translucent. An all-opaque image returns `None` for the mask, so the
173/// common case carries no extra channel.
174pub fn split_rgba(img: &RasterImage) -> (Vec<u8>, Option<Vec<u8>>) {
175 let n = img.width * img.height;
176 let mut rgb = Vec::with_capacity(n * 3);
177 let mut alpha = Vec::with_capacity(n);
178 let mut any = false;
179 for px in img.rgba.chunks_exact(4) {
180 rgb.push(px[0]);
181 rgb.push(px[1]);
182 rgb.push(px[2]);
183 alpha.push(px[3]);
184 if px[3] != 255 {
185 any = true;
186 }
187 }
188 (rgb, if any { Some(alpha) } else { None })
189}