Skip to main content

ravenrs_report/
document.rs

1//! The rendered result: pages of positioned primitives, in points. The viewer
2//! and every exporter draw this same structure, so they cannot disagree.
3
4use std::fmt::Write as _;
5
6use std::ops::Range;
7use std::sync::Arc;
8
9use ravenrs_core::{Color, ImageData, Point, Rect};
10use ravenrs_criteria::Value;
11
12use crate::base64::encode;
13use crate::model::TextAlign;
14
15/// A paginated document.
16#[derive(Clone, Debug, Default, PartialEq)]
17pub struct Document {
18    /// The title (the report's name).
19    pub title: String,
20    /// The culture the document was formatted with (`pt-BR`, `en-US`).
21    pub culture: String,
22    /// The pages, in order.
23    pub pages: Vec<Page>,
24    /// Problems found while rendering (expression errors, missing data), with
25    /// where they happened. The document is still produced.
26    pub errors: Vec<String>,
27}
28
29/// One page.
30#[derive(Clone, Debug, Default, PartialEq)]
31pub struct Page {
32    /// Paper width in points.
33    pub width: f32,
34    /// Paper height in points.
35    pub height: f32,
36    /// What is printed, back to front.
37    pub items: Vec<Item>,
38    /// The part of `items` that the page header printed. Exporters that flow
39    /// pages together keep it only once.
40    pub page_header: Range<usize>,
41    /// The part of `items` that the page footer printed.
42    pub page_footer: Range<usize>,
43}
44
45/// A printed primitive.
46#[derive(Clone, Debug, PartialEq)]
47#[non_exhaustive]
48pub enum Item {
49    /// A filled and/or outlined rectangle.
50    Rect {
51        /// Its box.
52        rect: Rect,
53        /// Fill color.
54        fill: Option<Color>,
55        /// Outline width and color.
56        stroke: Option<(f32, Color)>,
57        /// Corner radius.
58        radius: f32,
59    },
60    /// A filled and/or outlined ellipse inscribed in `rect`.
61    Ellipse {
62        /// The bounding box.
63        rect: Rect,
64        /// Fill color.
65        fill: Option<Color>,
66        /// Outline width and color.
67        stroke: Option<(f32, Color)>,
68    },
69    /// A straight line.
70    Line {
71        /// Start point.
72        from: Point,
73        /// End point.
74        to: Point,
75        /// Thickness.
76        width: f32,
77        /// Color.
78        color: Color,
79    },
80    /// Lines of text in one font, clipped to a box.
81    Text(TextBlock),
82    /// An image.
83    Image {
84        /// Where the whole image is drawn; it can be larger than `clip`.
85        rect: Rect,
86        /// The control's box; the image is cut there.
87        clip: Rect,
88        /// The image.
89        image: DocumentImage,
90    },
91    /// Vector shapes that belong together, such as a chart or a barcode. PDF and SVG write
92    /// them as they are; the screen and spreadsheets get them as one picture.
93    Vector {
94        /// The box they are in.
95        rect: Rect,
96        /// The shapes, back to front, in the page's coordinates.
97        shapes: Vec<crate::vector::Shape>,
98    },
99    /// An area that opens an address when clicked. Prints nothing.
100    Link {
101        /// The clickable area.
102        rect: Rect,
103        /// The address (`https:`, `http:` or `mailto:`).
104        url: String,
105    },
106    /// An entry in the document's outline. Prints nothing.
107    Bookmark {
108        /// Its text.
109        title: String,
110        /// How deep it is: 0 at the top.
111        level: u32,
112        /// Where on the page it leads.
113        y: f32,
114    },
115}
116
117/// The file format of a [`DocumentImage`].
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119#[non_exhaustive]
120pub enum ImageFormat {
121    /// PNG.
122    Png,
123    /// JPEG.
124    Jpeg,
125}
126
127/// A decoded image, with the file it came from: exporters can embed the file
128/// itself, which keeps JPEG photos small and unchanged. Clones share the data.
129#[derive(Clone, Debug, PartialEq)]
130pub struct DocumentImage {
131    pixels: ImageData,
132    encoded: Arc<[u8]>,
133    format: ImageFormat,
134    channels: u8,
135}
136
137impl DocumentImage {
138    pub(crate) fn new(pixels: ImageData, encoded: Arc<[u8]>, format: ImageFormat, channels: u8) -> Self {
139        Self { pixels, encoded, format, channels }
140    }
141
142    /// The decoded pixels, RGBA8.
143    pub fn pixels(&self) -> &ImageData {
144        &self.pixels
145    }
146
147    /// The original file.
148    pub fn encoded(&self) -> &[u8] {
149        &self.encoded
150    }
151
152    /// The original file's format.
153    pub fn format(&self) -> ImageFormat {
154        self.format
155    }
156
157    /// How many color channels the original file has: 1 gray, 3 color; 2 or 4 with transparency.
158    pub fn channels(&self) -> u8 {
159        self.channels
160    }
161}
162
163/// Text already broken into lines and positioned.
164#[derive(Clone, Debug, PartialEq)]
165pub struct TextBlock {
166    /// The control's box; text outside it is clipped.
167    pub clip: Rect,
168    /// The lines.
169    pub lines: Vec<PlacedLine>,
170    /// Font size in points.
171    pub size: f32,
172    /// Line height in points.
173    pub line_height: f32,
174    /// Bold weight.
175    pub bold: bool,
176    /// Text color.
177    pub color: Color,
178    /// How the text is aligned in its box.
179    pub align: TextAlign,
180    /// The value behind the text, when it is one number, date or truth value,
181    /// so data exports can write the value and not its formatted text.
182    pub value: Option<Value>,
183    /// The format specifier that turned `value` into the text (`C2`, `dd/MM/yyyy`).
184    pub format: Option<String>,
185}
186
187/// A line of text at its position on the page.
188#[derive(Clone, Debug, PartialEq)]
189pub struct PlacedLine {
190    /// The text of the line, without the line break.
191    pub text: String,
192    /// Left edge.
193    pub x: f32,
194    /// Top of the line box.
195    pub top: f32,
196    /// Baseline.
197    pub baseline: f32,
198    /// Width of the text.
199    pub width: f32,
200}
201
202impl Document {
203    /// All the text of a page in reading order, one line per text line (for
204    /// tests, search and accessibility).
205    pub fn page_text(&self, page: usize) -> Vec<String> {
206        let Some(p) = self.pages.get(page) else { return Vec::new() };
207        let mut lines: Vec<(f32, f32, String)> = p
208            .items
209            .iter()
210            .filter_map(|i| if let Item::Text(t) = i { Some(t) } else { None })
211            .flat_map(|t| t.lines.iter().map(|l| (l.baseline, l.x, l.text.clone())))
212            .collect();
213        lines.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)));
214        lines.into_iter().map(|(_, _, s)| s).collect()
215    }
216
217    /// Whether `needle` appears on the page (case-insensitive).
218    pub fn page_contains(&self, page: usize, needle: &str) -> bool {
219        let needle = needle.to_lowercase();
220        self.page_text(page).iter().any(|l| l.to_lowercase().contains(&needle))
221    }
222
223    /// A page as SVG, for previews, tests and debugging. Text uses the Inter
224    /// font the document was measured with.
225    pub fn page_svg(&self, page: usize) -> String {
226        let Some(p) = self.pages.get(page) else { return String::new() };
227        let mut s = String::new();
228        let _ = write!(
229            s,
230            r##"<svg xmlns="http://www.w3.org/2000/svg" width="{w}pt" height="{h}pt" viewBox="0 0 {w} {h}"><rect width="{w}" height="{h}" fill="#fff"/>"##,
231            w = p.width,
232            h = p.height
233        );
234        for item in &p.items {
235            match item {
236                Item::Rect { rect, fill, stroke, radius } => {
237                    let _ = write!(
238                        s,
239                        r#"<rect x="{}" y="{}" width="{}" height="{}" rx="{radius}" fill="{}"{}/>"#,
240                        rect.x(),
241                        rect.y(),
242                        rect.width(),
243                        rect.height(),
244                        fill.map_or("none".into(), css),
245                        stroke_attr(*stroke)
246                    );
247                }
248                Item::Ellipse { rect, fill, stroke } => {
249                    let c = rect.center();
250                    let _ = write!(
251                        s,
252                        r#"<ellipse cx="{}" cy="{}" rx="{}" ry="{}" fill="{}"{}/>"#,
253                        c.x,
254                        c.y,
255                        rect.width() / 2.0,
256                        rect.height() / 2.0,
257                        fill.map_or("none".into(), css),
258                        stroke_attr(*stroke)
259                    );
260                }
261                Item::Line { from, to, width, color } => {
262                    let _ = write!(
263                        s,
264                        r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="{width}"/>"#,
265                        from.x,
266                        from.y,
267                        to.x,
268                        to.y,
269                        css(*color)
270                    );
271                }
272                Item::Text(t) => {
273                    // Text wider than its box is cut there, as on screen.
274                    let overflows = t.lines.iter().any(|l| l.x + l.width > t.clip.max_x() + 0.5);
275                    if overflows {
276                        let c = t.clip;
277                        let _ = write!(
278                            s,
279                            r#"<svg x="{x}" y="{y}" width="{w}" height="{h}" viewBox="{x} {y} {w} {h}">"#,
280                            x = c.x(),
281                            y = c.y(),
282                            w = c.width(),
283                            h = c.height()
284                        );
285                    }
286                    for l in &t.lines {
287                        // textLength keeps the measured width even if another font is substituted.
288                        let _ = write!(
289                            s,
290                            r#"<text x="{}" y="{}" textLength="{}" lengthAdjust="spacingAndGlyphs" xml:space="preserve" font-family="Inter, sans-serif" font-size="{}" font-weight="{}" fill="{}">{}</text>"#,
291                            l.x,
292                            l.baseline,
293                            l.width.max(0.01),
294                            t.size,
295                            if t.bold { 600 } else { 400 },
296                            css(t.color),
297                            escape(l.text.trim_end())
298                        );
299                    }
300                    if overflows {
301                        s.push_str("</svg>");
302                    }
303                }
304                Item::Image { rect, clip, image } => {
305                    let mime = match image.format() {
306                        ImageFormat::Png => "image/png",
307                        ImageFormat::Jpeg => "image/jpeg",
308                    };
309                    // A nested viewport cuts the image at its box.
310                    let _ = write!(
311                        s,
312                        r#"<svg x="{x}" y="{y}" width="{w}" height="{h}" viewBox="{x} {y} {w} {h}"><image x="{}" y="{}" width="{}" height="{}" preserveAspectRatio="none" href="data:{mime};base64,{}"/></svg>"#,
313                        rect.x(),
314                        rect.y(),
315                        rect.width(),
316                        rect.height(),
317                        encode(image.encoded()),
318                        x = clip.x(),
319                        y = clip.y(),
320                        w = clip.width(),
321                        h = clip.height(),
322                    );
323                }
324                Item::Link { rect, url } => {
325                    let _ = write!(
326                        s,
327                        r#"<a href="{}"><rect x="{}" y="{}" width="{}" height="{}" fill="transparent"/></a>"#,
328                        escape(url).replace('"', "&quot;"),
329                        rect.x(),
330                        rect.y(),
331                        rect.width(),
332                        rect.height(),
333                    );
334                }
335                Item::Vector { shapes, .. } => {
336                    for shape in shapes {
337                        let mut d = String::new();
338                        for segment in &shape.path {
339                            match segment {
340                                crate::vector::PathSegment::Move(p) => {
341                                    let _ = write!(d, "M{} {}", p.x, p.y);
342                                }
343                                crate::vector::PathSegment::Line(p) => {
344                                    let _ = write!(d, "L{} {}", p.x, p.y);
345                                }
346                                crate::vector::PathSegment::Cubic(a, b, c) => {
347                                    let _ = write!(d, "C{} {} {} {} {} {}", a.x, a.y, b.x, b.y, c.x, c.y);
348                                }
349                                crate::vector::PathSegment::Close => d.push('Z'),
350                                _ => {}
351                            }
352                        }
353                        let _ = write!(
354                            s,
355                            r#"<path d="{d}" fill="{}"{}/>"#,
356                            shape.fill.map_or("none".into(), css),
357                            stroke_attr(shape.stroke)
358                        );
359                    }
360                }
361                Item::Bookmark { .. } => {}
362            }
363        }
364        s.push_str("</svg>");
365        s
366    }
367
368    /// The whole document as a standalone HTML page sized for printing: one
369    /// sheet per page, with the Inter font embedded so it prints as measured.
370    /// Open it in a browser and print, or save it as PDF from the print dialog.
371    pub fn print_html(&self) -> String {
372        let (w, h) = self.pages.first().map_or((595.28, 841.89), |p| (p.width, p.height));
373        let mut s = String::new();
374        let _ = write!(
375            s,
376            "<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title><style>\
377             @font-face{{font-family:Inter;font-weight:400;src:url(data:font/ttf;base64,{})}}\
378             @font-face{{font-family:Inter;font-weight:600;src:url(data:font/ttf;base64,{})}}\
379             @page{{size:{w}pt {h}pt;margin:0}}\
380             html,body{{margin:0;padding:0;background:#fff}}\
381             svg{{display:block}}\
382             .sheet{{break-after:page;overflow:hidden}}\
383             .sheet:last-child{{break-after:auto}}\
384             </style></head><body>",
385            escape(&self.title),
386            encode(ravenrs_text::inter_font(false)),
387            encode(ravenrs_text::inter_font(true)),
388        );
389        for page in 0..self.pages.len() {
390            s.push_str("<div class=\"sheet\">");
391            s.push_str(&self.page_svg(page));
392            s.push_str("</div>");
393        }
394        s.push_str("</body></html>");
395        s
396    }
397}
398
399fn stroke_attr(stroke: Option<(f32, Color)>) -> String {
400    stroke.map_or(String::new(), |(w, c)| format!(r#" stroke="{}" stroke-width="{w}""#, css(c)))
401}
402
403/// A color as CSS `rgba(...)`.
404fn css(c: Color) -> String {
405    format!(
406        "rgba({},{},{},{})",
407        (c.r * 255.0).round() as u8,
408        (c.g * 255.0).round() as u8,
409        (c.b * 255.0).round() as u8,
410        c.a
411    )
412}
413
414fn escape(s: &str) -> String {
415    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
416}
417
418/// Parses `#rgb`, `#rrggbb` or `#rrggbbaa`.
419pub(crate) fn parse_color(s: &str) -> Option<Color> {
420    let hex = s.trim().strip_prefix('#')?;
421    let digit = |i: usize, len: usize| u8::from_str_radix(hex.get(i..i + len)?, 16).ok();
422    let (r, g, b, a) = match hex.len() {
423        3 => (digit(0, 1)? * 17, digit(1, 1)? * 17, digit(2, 1)? * 17, 255),
424        6 => (digit(0, 2)?, digit(2, 2)?, digit(4, 2)?, 255),
425        8 => (digit(0, 2)?, digit(2, 2)?, digit(4, 2)?, digit(6, 2)?),
426        _ => return None,
427    };
428    Some(Color::rgba(f32::from(r) / 255.0, f32::from(g) / 255.0, f32::from(b) / 255.0, f32::from(a) / 255.0))
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn colors_parse() {
437        assert_eq!(parse_color("#ff0000"), Some(Color::rgba(1.0, 0.0, 0.0, 1.0)));
438        assert_eq!(parse_color("#0f0"), Some(Color::rgba(0.0, 1.0, 0.0, 1.0)));
439        assert_eq!(parse_color("#00000080").map(|c| (c.a * 255.0).round()), Some(128.0));
440        assert_eq!(parse_color("red"), None);
441    }
442}