Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_pearlite/src/raster.rs

12.3 KiB, 18 runs

created by r1870400018:58216, 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//! Rasterises a Pearl page to a pixmap, driving `fe2o3_graphics`'s anti-aliased [`Pixmap`] straight from
2//! the one leaf walk `pearl.rs` exposes as [`PearlDoc::render_page_to`]. A [`PixmapSink`] is a
3//! [`PageSink`](oxedyne_fe2o3_austenite::emit::pearl::PageSink): the SVG writer and this rasteriser share
4//! that single walk, so a page reaches pixels without ever round-tripping through an SVG string. The one
5//! new thing this module adds over the SVG arm is scaling by a caller-chosen DPI rather than a fixed
6//! pixel width, and skipping the invisible selectable-text layer (see [`PixmapSink::text_layer`]).
7
8use oxedyne_fe2o3_austenite::emit::pearl::{
9 PageSink,
10 PearlDoc,
11 TselRun,
12};
13
14use oxedyne_fe2o3_core::prelude::*;
15use oxedyne_fe2o3_graphics::colour::Rgba;
16use oxedyne_fe2o3_graphics::path::{
17 Bounds,
18 Path,
19};
20use oxedyne_fe2o3_graphics::pixmap::Pixmap;
21use oxedyne_fe2o3_graphics::stroke::Stroke;
22use oxedyne_fe2o3_graphics::transform::Transform;
23use oxedyne_fe2o3_text::base64;
24
25/// The standard screen DPI Pearl's own SVG is authored at: one point equals one pixel, so a page comes
26/// out at its media-box size in pixels.
27pub const DEFAULT_DPI: f32 = 72.0;
28
29/// A rasterised page: its pixels, and the pixel dimensions they were drawn at.
30pub struct RasterPage {
31 pub pixmap: Pixmap,
32 pub width_px: usize,
33 pub height_px: usize,
34}
35
36/// A [`PageSink`] that draws a page's placed ink onto an anti-aliased [`Pixmap`] at a chosen DPI. Every
37/// path arrives in the page's point frame; the sink applies the DPI scale as its device transform. The
38/// selectable-text layer is invisible ink, so it is dropped rather than drawn.
39pub struct PixmapSink {
40 dpi: f32,
41 pixmap: Option<Pixmap>,
42 width_px: usize,
43 height_px: usize,
44}
45
46impl PixmapSink {
47 pub fn new(dpi: f32) -> Self {
48 Self { dpi, pixmap: None, width_px: 0, height_px: 0 }
49 }
50
51 /// The finished page, or an error if no page was ever opened into the sink.
52 pub fn finish(self) -> Outcome<RasterPage> {
53 let pixmap = res!(self.pixmap.ok_or_else(|| err!(
54 "finish called before a page was rendered into the pixmap sink."; Bug, Missing)));
55 Ok(RasterPage { pixmap, width_px: self.width_px, height_px: self.height_px })
56 }
57
58 /// The pixel-per-point device scale for the sink's DPI.
59 fn scale(&self) -> f32 {
60 self.dpi / DEFAULT_DPI
61 }
62
63 /// The device transform for the sink's DPI.
64 fn device(&self) -> Transform {
65 let s = self.scale();
66 Transform::scale(s, s)
67 }
68
69 /// The open pixmap, or an error naming the sink method that ran before [`PageSink::begin`].
70 fn canvas(&mut self, who: &str) -> Outcome<&mut Pixmap> {
71 Ok(res!(self.pixmap.as_mut().ok_or_else(|| err!(
72 "pixmap sink {} was called before begin opened a page.", who; Bug, Missing))))
73 }
74}
75
76impl PageSink for PixmapSink {
77 fn begin(&mut self, w: usize, h: usize) -> Outcome<()> {
78 let s = self.scale();
79 // The media box scaled by the DPI, ceiled and floored at one, exactly as the media-box viewport
80 // (whole points) scales -- so a page's pixel size is the SVG viewBox size times the DPI ratio.
81 self.width_px = (((w as f32) * s).ceil() as usize).max(1);
82 self.height_px = (((h as f32) * s).ceil() as usize).max(1);
83 let mut pm = res!(Pixmap::new(self.width_px, self.height_px));
84 res!(pm.fill_bounds(
85 Bounds::new(0.0, 0.0, self.width_px as f32, self.height_px as f32), Rgba::WHITE, None));
86 self.pixmap = Some(pm);
87 Ok(())
88 }
89
90 fn fill(&mut self, path: &Path, colour: Rgba) -> Outcome<()> {
91 let t = self.device();
92 let pm = res!(self.canvas("fill"));
93 res!(pm.fill_path(path, &t, colour, None));
94 Ok(())
95 }
96
97 fn stroke(&mut self, path: &Path, colour: Rgba, pen: &Stroke) -> Outcome<()> {
98 let t = self.device();
99 let pm = res!(self.canvas("stroke"));
100 if pen.dash.is_some() {
101 // A dashed pen has no single-width fast path in `stroke_path`; bake it to its filled outline
102 // first, exactly as the generic SVG rasteriser example does. Pearl's own leaves never carry a
103 // dash, so this arm is future-proofing, not a path any current `.prl` reaches.
104 let outline = res!(path.stroke(pen));
105 res!(pm.fill_path(&outline, &t, colour, None));
106 } else {
107 res!(pm.stroke_path(path, &t, colour, None, pen));
108 }
109 Ok(())
110 }
111
112 fn image(&mut self, png_base64: &str, x: f32, y: f32, w: f32, h: f32) -> Outcome<()> {
113 if w <= 0.0 || h <= 0.0 {
114 return Ok(());
115 }
116 let s = self.scale();
117 // Decode the embedded PNG the way `svg_doc::emit_image` does -- strip any wrapping whitespace, then
118 // straight to a pixmap -- so the direct route matches the old SVG-reparse route byte for byte.
119 let clean: String = png_base64.chars().filter(|c| !c.is_whitespace()).collect();
120 let bytes = res!(base64::decode(&clean));
121 let img = res!(Pixmap::from_png(&bytes));
122 let iw = img.width();
123 let ih = img.height();
124 if iw == 0 || ih == 0 {
125 return Ok(());
126 }
127 let dw = (w * s).max(1.0);
128 let dh = (h * s).max(1.0);
129 let ox = x * s;
130 let oy = y * s;
131 let px0 = ox.floor().max(0.0) as usize;
132 let py0 = oy.floor().max(0.0) as usize;
133 let px1 = ((ox + dw).ceil() as usize).min(self.width_px);
134 let py1 = ((oy + dh).ceil() as usize).min(self.height_px);
135 let pm = res!(self.canvas("image"));
136 // Nearest-neighbour, as the graphics crate's own SVG rasteriser example does: enough fidelity for a
137 // document's embedded raster, and it pulls in no resampler.
138 for py in py0..py1 {
139 for px in px0..px1 {
140 let u = (((px as f32) + 0.5 - ox) / dw) * (iw as f32);
141 let v = (((py as f32) + 0.5 - oy) / dh) * (ih as f32);
142 if u < 0.0 || v < 0.0 {
143 continue;
144 }
145 let sx = (u as usize).min(iw - 1);
146 let sy = (v as usize).min(ih - 1);
147 if let Some(c) = img.pixel(sx, sy) {
148 pm.blend_pixel(px, py, c);
149 }
150 }
151 }
152 Ok(())
153 }
154
155 // The invisible, selectable text layer places no ink -- every visible glyph has already arrived as its
156 // own `fill` from the leaf's stored outline -- so a visual sink drops it. In a browser it is `fill:
157 // transparent`; drawing it here would double the ink.
158 fn text_layer(&mut self, _runs: &[TselRun]) -> Outcome<()> {
159 Ok(())
160 }
161
162 fn end(&mut self) -> Outcome<()> {
163 Ok(())
164 }
165}
166
167/// Renders one page of a Pearl document to a pixmap at `dpi` dots per inch, driving the shared leaf walk
168/// through a [`PixmapSink`].
169pub fn render_page_to_pixmap(doc: &PearlDoc, idx: usize, dpi: f32) -> Outcome<RasterPage> {
170 let mut sink = PixmapSink::new(dpi);
171 res!(doc.render_page_to(idx, &mut sink));
172 sink.finish()
173}
174
175/// Renders every page of `doc` to PNG bytes, in page order.
176pub fn render_all_pages_to_png(doc: &PearlDoc, dpi: f32) -> Outcome<Vec<Vec<u8>>> {
177 let pages = res!(doc.page_count());
178 let mut out = Vec::with_capacity(pages);
179 for idx in 0..pages {
180 let raster = res!(render_page_to_pixmap(doc, idx, dpi));
181 out.push(res!(raster.pixmap.to_png()));
182 }
183 Ok(out)
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 use oxedyne_fe2o3_graphics::svg_doc::{
191 self,
192 SvgOp,
193 };
194
195 // The old route to pixels, kept here as a reference oracle: `render_page` to an SVG string, re-parsed
196 // by the graphics crate's SVG-document reader, then blitted onto a pixmap. The direct `PixmapSink`
197 // must reproduce this byte for byte -- that is what proves the leaf-walk refactor changed nothing.
198 fn render_via_svg_reparse(doc: &PearlDoc, idx: usize, dpi: f32) -> Outcome<RasterPage> {
199 let svg = res!(doc.render_page(idx));
200 let pic = res!(svg_doc::read_document(&svg));
201
202 let s = dpi / DEFAULT_DPI;
203 let width_px = ((pic.width * s).ceil() as usize).max(1);
204 let height_px = ((pic.height * s).ceil() as usize).max(1);
205 let t = Transform::scale(s, s);
206
207 let mut pm = res!(Pixmap::new(width_px, height_px));
208 res!(pm.fill_bounds(
209 Bounds::new(0.0, 0.0, width_px as f32, height_px as f32), Rgba::WHITE, None));
210
211 for op in pic.ops {
212 match op {
213 SvgOp::Fill { path, colour } => {
214 res!(pm.fill_path(&path, &t, colour, None));
215 },
216 SvgOp::Stroke { path, colour, stroke } => {
217 if stroke.dash.is_some() {
218 let outline = res!(path.stroke(&stroke));
219 res!(pm.fill_path(&outline, &t, colour, None));
220 } else {
221 let pen = res!(Stroke::new(stroke.width));
222 let pen = pen.with_cap(stroke.cap).with_join(stroke.join);
223 res!(pm.stroke_path(&path, &t, colour, None, &pen));
224 }
225 },
226 SvgOp::Text { .. } => {},
227 SvgOp::Image { rgba, iw, ih, x, y, w, h } => {
228 if iw == 0 || ih == 0 || w <= 0.0 || h <= 0.0 {
229 continue;
230 }
231 let img = res!(Pixmap::from_data(iw, ih, rgba));
232 let dw = (w * s).max(1.0);
233 let dh = (h * s).max(1.0);
234 let ox = x * s;
235 let oy = y * s;
236 let px0 = ox.floor().max(0.0) as usize;
237 let py0 = oy.floor().max(0.0) as usize;
238 let px1 = ((ox + dw).ceil() as usize).min(width_px);
239 let py1 = ((oy + dh).ceil() as usize).min(height_px);
240 for py in py0..py1 {
241 for px in px0..px1 {
242 let u = (((px as f32) + 0.5 - ox) / dw) * (iw as f32);
243 let v = (((py as f32) + 0.5 - oy) / dh) * (ih as f32);
244 if u < 0.0 || v < 0.0 {
245 continue;
246 }
247 let sx = (u as usize).min(iw - 1);
248 let sy = (v as usize).min(ih - 1);
249 if let Some(c) = img.pixel(sx, sy) {
250 pm.blend_pixel(px, py, c);
251 }
252 }
253 }
254 },
255 }
256 }
257
258 Ok(RasterPage { pixmap: pm, width_px, height_px })
259 }
260
261 // A checked-in sample .prl rasters at 96 DPI to a pixmap whose dimensions are exactly the SVG viewBox
262 // scaled by 96/72 -- proving the DPI scaling landed, not just that some raster came out -- and whose
263 // pixels are not all the white the canvas starts on.
264 #[test]
265 fn test_a_sample_prl_rasters_to_a_correctly_sized_non_blank_png_00() -> Outcome<()> {
266 let path = concat!(env!("CARGO_MANIFEST_DIR"),
267 "/../fe2o3_austenite/web/pearl-reader/samples/keystone.prl");
268 let doc = res!(PearlDoc::read_file(path));
269 assert_eq!(res!(doc.page_count()), 1, "keystone.prl is a one-page fixture");
270
271 let dpi = 96.0;
272 let raster = res!(render_page_to_pixmap(&doc, 0, dpi));
273
274 let svg = res!(doc.render_page(0));
275 let pic = res!(svg_doc::read_document(&svg));
276 let want_w = ((pic.width * (dpi / DEFAULT_DPI)).ceil() as usize).max(1);
277 let want_h = ((pic.height * (dpi / DEFAULT_DPI)).ceil() as usize).max(1);
278 assert_eq!(raster.width_px, want_w, "pixel width did not scale by the chosen DPI");
279 assert_eq!(raster.height_px, want_h, "pixel height did not scale by the chosen DPI");
280
281 let has_ink = raster.pixmap.data().chunks(4).any(|px| px != [255, 255, 255, 255]);
282 assert!(has_ink, "the rasterised page is a blank white canvas");
283
284 let png = res!(raster.pixmap.to_png());
285 assert!(png.starts_with(&[0x89, b'P', b'N', b'G']), "to_png did not write a PNG signature");
286 Ok(())
287 }
288
289 // A multi-page document renders one PNG per page, and the count matches `page_count`.
290 #[test]
291 fn test_render_all_pages_matches_page_count_01() -> Outcome<()> {
292 let path = concat!(env!("CARGO_MANIFEST_DIR"),
293 "/../fe2o3_austenite/web/pearl-reader/samples/manuscript.prl");
294 let doc = res!(PearlDoc::read_file(path));
295 let pages = res!(doc.page_count());
296 assert!(pages > 1, "manuscript.prl should be a multi-page fixture");
297
298 let pngs = res!(render_all_pages_to_png(&doc, DEFAULT_DPI));
299 assert_eq!(pngs.len(), pages, "one PNG was not written per page");
300 for png in &pngs {
301 assert!(png.starts_with(&[0x89, b'P', b'N', b'G']), "not a PNG");
302 }
303 Ok(())
304 }
305
306 // The refactor's raster proof: the direct `PixmapSink` produces the SAME pixels, byte for byte, as the
307 // old route that re-parsed `render_page`'s SVG. Checked across every page of the checked-in fixtures,
308 // at 72 and 144 DPI, so both the one-to-one and a scaled case are covered.
309 #[test]
310 fn test_direct_sink_matches_the_svg_reparse_route_byte_for_byte_02() -> Outcome<()> {
311 for name in ["keystone.prl", "manuscript.prl"] {
312 let path = fmt!("{}/../fe2o3_austenite/web/pearl-reader/samples/{}",
313 env!("CARGO_MANIFEST_DIR"), name);
314 let doc = res!(PearlDoc::read_file(&path));
315 let pages = res!(doc.page_count());
316 for dpi in [72.0_f32, 144.0_f32] {
317 for idx in 0..pages {
318 let direct = res!(render_page_to_pixmap(&doc, idx, dpi));
319 let oracle = res!(render_via_svg_reparse(&doc, idx, dpi));
320 assert_eq!(direct.width_px, oracle.width_px,
321 "{} page {} at {} dpi: width differs", name, idx, dpi);
322 assert_eq!(direct.height_px, oracle.height_px,
323 "{} page {} at {} dpi: height differs", name, idx, dpi);
324 assert_eq!(direct.pixmap.data(), oracle.pixmap.data(),
325 "{} page {} at {} dpi: the direct sink and the SVG-reparse route drew different pixels",
326 name, idx, dpi);
327 }
328 }
329 }
330 Ok(())
331 }
332}