Skip to main content

pdfrum_edit/
builders.rs

1//! Page objects built from a few fields, ready to push onto a `PageEdit`.
2//!
3//! Each builder states the graphics state it paints under in full rather
4//! than inheriting one, because a regenerated content stream carries no
5//! state from whatever drew before it.
6
7use kurbo::{Affine, BezPath, Rect};
8use pdfrum_page::{
9    ColorSpace, Content, FillRule, GraphicsState, PageObject, PathObject, TextObject,
10    TextRenderMode, TextSegment,
11};
12
13/// The RGB triple and the alpha `pdfrum-edit` writes, out of a
14/// [`peniko::Color`].
15///
16/// The facade takes the idiomatic type and narrows on the first line, so the
17/// writer's `[f32; 3]` never appears in a public signature. Two things happen
18/// here.
19///
20/// **The components are clamped to `0..=1`.** A PDF colour operand outside
21/// that range is out of gamut, and the engine clamps it on the way back out
22/// (`pdfrum_page`'s `rgb_to_rgb`), so clamping here changes no rendered
23/// pixel — it only means the value a caller reads back off the *saved file*
24/// is the value the writer actually used, rather than one the reader would
25/// clamp again.
26///
27/// **Alpha is kept, not dropped.** The colour itself goes out as `rg`/`RG`,
28/// which carries no alpha, but constant alpha has its own home in a PDF:
29/// `/ca` and `/CA` in an `/ExtGState`, which `pdfrum-edit`'s emitter already
30/// writes from [`GraphicsState`]'s `fill_alpha` / `stroke_alpha`. So a
31/// translucent [`peniko::Color`] produces a translucent object rather than
32/// silently losing its alpha at the door.
33///
34/// The one consequence worth stating: **alpha 0 is transparent, not "no
35/// fill".** `Color::from_rgba8(255, 0, 0, 0)` paints an invisible red fill —
36/// the object is still there, still in the painting order, still saved. "No
37/// fill" is spelled `None` on [`PathBuilder::fill`], and that `Option` is the
38/// only thing that means it; a zero alpha is not a second spelling of it.
39/// [`TextBuilder::fill`] has no `None` at all, so there alpha 0 is simply
40/// invisible text.
41fn rgba_of(color: peniko::Color) -> ([f32; 3], f32) {
42    let [r, g, b, alpha] = color.components;
43    (
44        [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)],
45        alpha.clamp(0.0, 1.0),
46    )
47}
48
49/// A filled or stroked path, ready to [`PageEdit::push`](pdfrum_page::PageEdit::push).
50///
51/// A plain config struct filled in with struct-update syntax, and
52/// [`PathBuilder::build`] turns it into the page object. The graphics state it
53/// paints under is stated in full rather than inherited, because a regenerated
54/// stream restates everything from the PDF defaults anyway.
55#[derive(Debug, Clone, PartialEq)]
56pub struct PathBuilder {
57    /// The outline, in page space.
58    pub path: BezPath,
59    /// The fill colour, or `None` for no fill.
60    ///
61    /// The alpha is honoured — it goes out as an `/ExtGState` `/ca` — so a
62    /// translucent colour paints a translucent fill. Note that alpha 0 is
63    /// therefore *invisible*, not *absent*: `None` is the only spelling of
64    /// "do not fill".
65    pub fill: Option<peniko::Color>,
66    /// The stroke colour, or `None` for no stroke.
67    ///
68    /// The alpha is honoured as `/CA`, exactly as [`PathBuilder::fill`]'s is.
69    pub stroke: Option<peniko::Color>,
70    /// The stroke width in page units.
71    pub line_width: f32,
72    /// Whether the fill uses the even-odd rule rather than the nonzero one.
73    pub even_odd: bool,
74    /// A further transform on the path, composed after it.
75    pub matrix: Affine,
76}
77
78impl Default for PathBuilder {
79    fn default() -> Self {
80        Self {
81            path: BezPath::new(),
82            fill: Some(peniko::Color::BLACK),
83            stroke: None,
84            line_width: 1.0,
85            even_odd: false,
86            matrix: Affine::IDENTITY,
87        }
88    }
89}
90
91impl PathBuilder {
92    /// A rectangle, filled black.
93    #[must_use]
94    pub fn rect(rect: Rect) -> Self {
95        let mut path = BezPath::new();
96        path.move_to((rect.x0, rect.y0));
97        path.line_to((rect.x1, rect.y0));
98        path.line_to((rect.x1, rect.y1));
99        path.line_to((rect.x0, rect.y1));
100        path.close_path();
101        Self {
102            path,
103            ..Self::default()
104        }
105    }
106
107    /// The page object this describes.
108    #[must_use]
109    pub fn build(self) -> PageObject {
110        let mut state = GraphicsState::default();
111        if let Some(fill) = self.fill {
112            let (rgb, alpha) = rgba_of(fill);
113            state.fill.set_stock(ColorSpace::DeviceRgb, &rgb);
114            state.general.fill_alpha = alpha;
115        }
116        if let Some(stroke) = self.stroke {
117            let (rgb, alpha) = rgba_of(stroke);
118            state.stroke.set_stock(ColorSpace::DeviceRgb, &rgb);
119            state.general.stroke_alpha = alpha;
120        }
121        state.stroke_params.width = self.line_width;
122        let fill_rule = match (self.fill.is_some(), self.even_odd) {
123            (false, _) => FillRule::None,
124            (true, false) => FillRule::Winding,
125            (true, true) => FillRule::EvenOdd,
126        };
127        PageObject::Path(Box::new(Content::new(
128            PathObject {
129                path: self.path,
130                matrix: self.matrix,
131                fill_rule,
132                stroke: self.stroke.is_some(),
133            },
134            state,
135        )))
136    }
137}
138
139/// A run of text, ready to [`PageEdit::push`](pdfrum_page::PageEdit::push).
140///
141/// The font is named by an indirect `/Font` dictionary: a regenerated stream
142/// writes `/Name Tf` and [`ResourceTable::realize`](crate::ResourceTable::realize) allocates
143/// that name for [`TextBuilder::font`]. Obtain the reference from
144/// [`PageEdit::font_of`](pdfrum_page::PageEdit::font_of) (a font the page already has) or from
145/// [`EditDoc::embed_font`](crate::EditDoc::embed_font) / [`EditDoc::standard_font`](crate::EditDoc::standard_font) (a font
146/// this save is adding).
147///
148/// [`crate::ImageBuilder`] names an `/XObject` the same way.
149#[derive(Debug, Clone, PartialEq)]
150pub struct TextBuilder {
151    /// The character codes, in the font's own encoding.
152    pub codes: Vec<u8>,
153    /// The `/Font` resource this page reaches the font through.
154    pub font: pdfrum_object::ObjRef,
155    /// The font size in page units.
156    pub size: f32,
157    /// Where the baseline starts, in page space.
158    pub position: kurbo::Point,
159    /// How the glyphs are painted.
160    pub render_mode: TextRenderMode,
161    /// The fill colour.
162    ///
163    /// The alpha is honoured, as [`PathBuilder::fill`]'s is. There is no
164    /// `None` here, so alpha 0 paints invisible text rather than no text.
165    pub fill: peniko::Color,
166}
167
168impl TextBuilder {
169    /// A run of `codes` in the font `font` names, at `size`, starting at the
170    /// origin and painted black.
171    #[must_use]
172    pub fn new(codes: impl Into<Vec<u8>>, font: pdfrum_object::ObjRef, size: f32) -> Self {
173        Self {
174            codes: codes.into(),
175            font,
176            size,
177            position: kurbo::Point::ZERO,
178            render_mode: TextRenderMode::Fill,
179            fill: peniko::Color::BLACK,
180        }
181    }
182
183    /// The page object this describes.
184    #[must_use]
185    pub fn build(self) -> PageObject {
186        let mut state = GraphicsState::default();
187        let (rgb, alpha) = rgba_of(self.fill);
188        state.fill.set_stock(ColorSpace::DeviceRgb, &rgb);
189        state.general.fill_alpha = alpha;
190        PageObject::Text(Box::new(Content::new(
191            TextObject {
192                segments: Box::new([TextSegment {
193                    codes: self.codes.into_boxed_slice(),
194                    kerning: 0.0,
195                }]),
196                position: self.position,
197                // Size lives only in the matrix. The emitter writes `Tf` from
198                // this scale and `Tm` with it divided out; putting the size
199                // in `font.1` as well would scale the saved run twice. `font`
200                // stays `None`: a constructed object names the dict through
201                // `font_source`, and loading an unrelated face just to pass
202                // the emitter's old `font: None` refusal was the wrong kind
203                // of fix.
204                matrix: Affine::scale(f64::from(self.size)),
205                font: None,
206                font_source: Some(self.font),
207                render_mode: self.render_mode,
208                type3_metrics: std::collections::BTreeMap::new(),
209            },
210            state,
211        )))
212    }
213}
214
215/// An image placement, ready to [`PageEdit::push`](pdfrum_page::PageEdit::push).
216///
217/// The pixels are not supplied here: an image page object names an `/XObject`,
218/// and the regenerated stream writes `/Name Do`. Obtain the reference from
219/// [`PageEdit::image_of`](pdfrum_page::PageEdit::image_of) (an image the document already holds) or from
220/// [`EditDoc::embed_jpeg`](crate::EditDoc::embed_jpeg) / [`EditDoc::embed_image`](crate::EditDoc::embed_image) (one this
221/// save is adding).
222#[derive(Debug, Clone, PartialEq)]
223pub struct ImageBuilder {
224    /// The image `XObject`.
225    pub source: pdfrum_object::ObjRef,
226    /// Where it lands: the matrix mapping the unit square onto the page.
227    ///
228    /// `Affine::new([w, 0.0, 0.0, h, x, y])` places a `w` by `h` image with
229    /// its lower-left corner at `(x, y)`.
230    pub matrix: Affine,
231}
232
233impl ImageBuilder {
234    /// Place `source` in the rectangle `rect`.
235    ///
236    /// The image is stretched onto `rect`; nothing preserves its aspect
237    /// ratio, so a caller that wants it kept sizes `rect` from
238    /// [`EmbeddedImage::width`](crate::EmbeddedImage::width) and [`EmbeddedImage::height`](crate::EmbeddedImage::height).
239    #[must_use]
240    pub fn at(source: pdfrum_object::ObjRef, rect: Rect) -> Self {
241        Self {
242            source,
243            matrix: Affine::new([rect.width(), 0.0, 0.0, rect.height(), rect.x0, rect.y0]),
244        }
245    }
246
247    /// The page object this describes.
248    ///
249    /// The pixels are a one-by-one placeholder: a regenerated stream writes a
250    /// `Do` naming the source, and never looks at them. Rendering the edited
251    /// graph before saving would show the placeholder rather than the image,
252    /// so render the *saved* file to see the result.
253    #[must_use]
254    pub fn build(self) -> PageObject {
255        let placeholder = pdfrum_page::ImageData {
256            width: 1,
257            height: 1,
258            samples: pdfrum_page::Samples::Whole(pdfrum_page::Pixels::Gray8(Box::new([0]))),
259            mask: None,
260            matte: None,
261            interpolate: false,
262        };
263        PageObject::Image(Box::new(Content::new(
264            pdfrum_page::ImageObject {
265                image: std::sync::Arc::new(placeholder),
266                matrix: self.matrix,
267                is_mask: false,
268                oc: None,
269                source: Some(self.source),
270            },
271            GraphicsState::default(),
272        )))
273    }
274}