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}