Skip to main content

pdfboss_output/
output.rs

1//! Output adapters: the renderers that turn the layout IR into a document.
2
3use crate::ir::{Block, Line, PageLayout};
4
5/// Renders laid-out pages into one document.
6pub trait Output {
7    fn render(&self, pages: &[PageLayout]) -> String;
8}
9
10/// Plain text: every line of every block in reading order joined with `\n`,
11/// pages separated by a form feed. Structure is invisible here — this is the
12/// adapter that must stay byte-equal to positional text extraction.
13pub struct Text;
14
15impl Output for Text {
16    fn render(&self, pages: &[PageLayout]) -> String {
17        let mut out = String::new();
18        for (page_index, page) in pages.iter().enumerate() {
19            if page_index > 0 {
20                out.push('\u{c}');
21            }
22            let mut written = 0usize;
23            for block in &page.blocks {
24                match block {
25                    Block::Heading { lines, .. } | Block::Paragraph { lines, .. } => {
26                        for line in lines {
27                            open_line(&mut out, &mut written);
28                            push_line(&mut out, line);
29                        }
30                    }
31                    Block::List { items, .. } => {
32                        for line in items.iter().flat_map(|item| &item.lines) {
33                            open_line(&mut out, &mut written);
34                            push_line(&mut out, line);
35                        }
36                    }
37                    Block::Table { rows, .. } => {
38                        for row in rows {
39                            open_line(&mut out, &mut written);
40                            push_row(&mut out, row);
41                        }
42                    }
43                }
44            }
45        }
46        out
47    }
48}
49
50/// Opens an output line: every line but the page's first is preceded by
51/// `\n`.
52fn open_line(out: &mut String, written: &mut usize) {
53    if *written > 0 {
54        out.push('\n');
55    }
56    *written += 1;
57}
58
59/// A line as unmarked text: what every adapter starts from.
60pub(crate) fn line_text(line: &Line) -> String {
61    let mut out = String::new();
62    push_line(&mut out, line);
63    out
64}
65
66/// A line's inline runs concatenated; the runs already carry the spaces the
67/// word-gap rule inserted.
68fn push_line(out: &mut String, line: &Line) {
69    for inline in &line.inlines {
70        out.push_str(&inline.text);
71    }
72}
73
74/// A table row reads as one visual line: its cells' text separated by the
75/// single space a qualifying word gap would have left, and by nothing more
76/// when a cell already ends or begins with one. Cells with no line
77/// contribute nothing.
78fn push_row(out: &mut String, row: &[crate::ir::Cell]) {
79    let row_start = out.len();
80    for line in row.iter().filter_map(|cell| cell.line.as_ref()) {
81        let text = line_text(line);
82        let separated = out.len() == row_start
83            || out.ends_with(char::is_whitespace)
84            || text.starts_with(char::is_whitespace);
85        if !separated {
86            out.push(' ');
87        }
88        out.push_str(&text);
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::ir::{BBox, Cell, Inline};
96
97    fn cell(text: &str) -> Cell {
98        Cell {
99            line: Some(Line {
100                inlines: vec![Inline {
101                    text: text.to_string(),
102                    bold: false,
103                    italic: false,
104                }],
105                y: 700.0,
106                x: 72.0,
107                end_x: 100.0,
108                size: 10.0,
109            }),
110            colspan: 1,
111            rowspan: 1,
112        }
113    }
114
115    /// A cell whose text ends in a space glyph, or whose neighbour starts
116    /// with one, is not separated from it by a second space.
117    #[test]
118    fn a_table_row_joins_cells_with_one_space() {
119        let page = PageLayout {
120            blocks: vec![Block::Table {
121                rows: vec![vec![cell("0.933 "), cell("0.215"), cell(" 0.216")]],
122                bbox: BBox {
123                    x0: 72.0,
124                    y0: 700.0,
125                    x1: 300.0,
126                    y1: 710.0,
127                },
128            }],
129        };
130        assert_eq!(Text.render(&[page]), "0.933 0.215 0.216");
131    }
132}