pdfrum_edit/canvas.rs
1//! Drawing on an existing page without writing content-stream operators.
2//!
3//! A [`Canvas`] is a retained drawing surface over one page. A caller places
4//! fills, strokes, text and images in the page's *displayed* coordinate space
5//! — y-up, in PDF points, with the crop box and `/Rotate` already composed in
6//! — and the canvas emits one content stream that is **appended** to the
7//! page's `/Contents`. The page's own streams are never rewritten, so nothing
8//! a save would otherwise lose (`pdfrum_edit`'s regeneration losses) applies
9//! to a page that is only drawn on. The coordinate space is on [`Canvas`];
10//! the resource-merging rule is on [`EditDoc::draw_page`].
11//!
12//! # There is no layout here, deliberately
13//!
14//! [`Canvas::text`] draws one string at one point. There is no line breaking,
15//! no wrapping and no paragraph model, and the only measurement is
16//! [`Canvas::text_width`], a single string's advance. A caller who needs
17//! layout has a typesetting problem and brings their own layout to `text`.
18
19use std::fmt::Write as _;
20
21use crate::{ContentsShape, EmbeddedFont, EmbeddedImage, write_float, write_matrix, write_point};
22use kurbo::{Affine, BezPath, PathEl, Point, Rect, RoundedRect, Shape};
23use pdfrum_common::{Diagnostics, Limits, PageIndex};
24use pdfrum_object::{
25 Array, ByteSpan, Dict, Name, ObjRef, Object, Resolve, Stream, names as pdf_names,
26};
27
28use crate::{EditDoc, Error};
29use peniko::Color;
30
31/// A drawing either applies or names why it could not.
32type Result<T> = core::result::Result<T, Error>;
33
34/// How a shape is painted.
35///
36/// An enum rather than two `Option<Color>` fields because the three states
37/// are what the PDF paint operators actually offer, and "neither" is not one
38/// of them: a caller who wants to paint nothing does not call the method.
39#[derive(Debug, Clone, PartialEq)]
40pub enum Paint {
41 /// Filled only. Written as `f` or `f*`.
42 Fill(Color),
43 /// Stroked only, at [`Stroke::width`]. Written as `S`.
44 Stroke(Stroke),
45 /// Filled and stroked, the fill first. Written as `B` or `B*`.
46 FillStroke(Color, Stroke),
47}
48
49impl Paint {
50 /// The fill colour, if this paint fills.
51 ///
52 /// ```
53 /// use pdfrum::{Color, Paint};
54 ///
55 /// assert_eq!(Paint::Fill(Color::BLACK).fill(), Some(Color::BLACK));
56 /// ```
57 #[must_use]
58 pub fn fill(&self) -> Option<Color> {
59 match self {
60 Self::Fill(color) | Self::FillStroke(color, _) => Some(*color),
61 Self::Stroke(_) => None,
62 }
63 }
64
65 /// The stroke, if this paint strokes.
66 ///
67 /// ```
68 /// use pdfrum::{Color, Paint, Stroke};
69 ///
70 /// assert!(Paint::Fill(Color::BLACK).stroke().is_none());
71 /// assert!(Paint::Stroke(Stroke::new(Color::BLACK, 2.0)).stroke().is_some());
72 /// ```
73 #[must_use]
74 pub fn stroke(&self) -> Option<&Stroke> {
75 match self {
76 Self::Stroke(stroke) | Self::FillStroke(_, stroke) => Some(stroke),
77 Self::Fill(_) => None,
78 }
79 }
80}
81
82/// A stroke's colour, width and pen shape, in canvas units.
83///
84/// The fields stay public and every one but `color` and `width` has a
85/// default, so `Stroke { cap: LineCap::Round, ..Stroke::new(color, 1.0) }`
86/// works and the four settings ISO 32000-1 §8.4.3.3-§8.4.3.6 name are
87/// reachable without builder ceremony. [`Stroke::new`] keeps meaning what it
88/// always meant: PDF's own defaults — butt cap, miter join, miter limit 10,
89/// no dash.
90#[derive(Debug, Clone, PartialEq)]
91pub struct Stroke {
92 /// The colour. Its alpha is honoured, as an `/ExtGState` `/CA`.
93 pub color: Color,
94 /// The line width in canvas units — page points.
95 pub width: f64,
96 /// How the open ends of a subpath are drawn. `J`.
97 pub cap: LineCap,
98 /// How two segments meet at a corner. `j`.
99 pub join: LineJoin,
100 /// Where a [`LineJoin::Miter`] corner becomes a bevel instead, as the
101 /// ratio of miter length to line width. `M`.
102 pub miter_limit: MiterLimit,
103 /// The on/off pattern, or `None` for a solid line. `d`.
104 pub dash: Option<Dash>,
105}
106
107impl Stroke {
108 /// A solid stroke of `color` at `width` points, with PDF's default pen:
109 /// butt cap, miter join, miter limit 10.
110 ///
111 /// ```
112 /// let hairline = pdfrum::Stroke::new(pdfrum::Color::BLACK, 0.5);
113 /// assert_eq!(hairline.width, 0.5);
114 /// assert_eq!(hairline.cap, pdfrum::LineCap::Butt);
115 /// assert!(hairline.dash.is_none());
116 /// ```
117 #[must_use]
118 pub fn new(color: Color, width: f64) -> Self {
119 Self {
120 color,
121 width,
122 cap: LineCap::Butt,
123 join: LineJoin::Miter,
124 miter_limit: MiterLimit::default(),
125 dash: None,
126 }
127 }
128
129 /// The same stroke with `cap` at its open ends.
130 ///
131 /// ```
132 /// use pdfrum::{Color, LineCap, Stroke};
133 ///
134 /// let round = Stroke::new(Color::BLACK, 4.0).with_cap(LineCap::Round);
135 /// assert_eq!(round.cap, LineCap::Round);
136 /// ```
137 #[must_use]
138 pub fn with_cap(mut self, cap: LineCap) -> Self {
139 self.cap = cap;
140 self
141 }
142
143 /// The same stroke with `join` at its corners.
144 ///
145 /// ```
146 /// use pdfrum::{Color, LineJoin, Stroke};
147 ///
148 /// let soft = Stroke::new(Color::BLACK, 4.0).with_join(LineJoin::Round);
149 /// assert_eq!(soft.join, LineJoin::Round);
150 /// ```
151 #[must_use]
152 pub fn with_join(mut self, join: LineJoin) -> Self {
153 self.join = join;
154 self
155 }
156
157 /// The same stroke with `limit` on its miter joins.
158 ///
159 /// ```
160 /// use pdfrum::{Color, MiterLimit, Stroke};
161 ///
162 /// let blunt = Stroke::new(Color::BLACK, 4.0).with_miter_limit(MiterLimit::new(2.0));
163 /// assert_eq!(blunt.miter_limit.get(), 2.0);
164 /// ```
165 #[must_use]
166 pub fn with_miter_limit(mut self, limit: MiterLimit) -> Self {
167 self.miter_limit = limit;
168 self
169 }
170
171 /// The same stroke dashed by `dash`.
172 ///
173 /// ```
174 /// use pdfrum::{Color, Dash, Stroke};
175 ///
176 /// let dashed = Stroke::new(Color::BLACK, 1.0)
177 /// .with_dash(Dash::new(&[4.0, 2.0], 0.0).expect("a valid dash"));
178 /// assert!(dashed.dash.is_some());
179 /// ```
180 #[must_use]
181 pub fn with_dash(mut self, dash: Dash) -> Self {
182 self.dash = Some(dash);
183 self
184 }
185}
186
187/// How the open ends of a stroked subpath are drawn — ISO 32000-1 §8.4.3.3's
188/// line cap style, written as `J`.
189///
190/// An enum rather than the `0`/`1`/`2` the operator takes: the wire spelling
191/// is an encoding detail, and `LineCap::Round` says at a call
192/// site what `1` does not.
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
194pub enum LineCap {
195 /// Squared off exactly at the endpoint. PDF's default.
196 #[default]
197 Butt,
198 /// A half-disc of the line's width centred on the endpoint.
199 Round,
200 /// A half-square projecting half a line width past the endpoint.
201 Square,
202}
203
204impl LineCap {
205 /// The operand `J` takes.
206 fn operand(self) -> u8 {
207 match self {
208 Self::Butt => 0,
209 Self::Round => 1,
210 Self::Square => 2,
211 }
212 }
213}
214
215/// How two segments meet at a corner — ISO 32000-1 §8.4.3.4's line join
216/// style, written as `j`.
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
218pub enum LineJoin {
219 /// Extended outer edges meeting in a point, beveled past
220 /// [`Stroke::miter_limit`]. PDF's default.
221 #[default]
222 Miter,
223 /// An arc of the line's width around the corner point.
224 Round,
225 /// The notch between the two segments filled with a triangle.
226 Bevel,
227}
228
229impl LineJoin {
230 /// The operand `j` takes.
231 fn operand(self) -> u8 {
232 match self {
233 Self::Miter => 0,
234 Self::Round => 1,
235 Self::Bevel => 2,
236 }
237 }
238}
239
240/// The ratio of miter length to line width past which a [`LineJoin::Miter`]
241/// corner is drawn beveled instead — ISO 32000-1 §8.4.3.5's `M`.
242///
243/// A newtype rather than a bare `f64` because the value has a floor: the
244/// miter length is never shorter than the line width, so a ratio below 1
245/// asks for something that cannot happen. It clamps rather than refusing,
246/// unlike [`Dash`], because every out-of-range ratio has one obviously
247/// intended reading and none of them makes a reader reject the stream.
248#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
249pub struct MiterLimit(f64);
250
251impl MiterLimit {
252 /// A miter limit of `ratio`, clamped up to 1; a non-finite ratio gives
253 /// the default.
254 ///
255 /// ```
256 /// use pdfrum::MiterLimit;
257 ///
258 /// assert_eq!(MiterLimit::new(4.0).get(), 4.0);
259 /// assert_eq!(MiterLimit::new(0.5).get(), 1.0);
260 /// assert_eq!(MiterLimit::new(f64::NAN).get(), 10.0);
261 /// ```
262 #[must_use]
263 pub fn new(ratio: f64) -> Self {
264 if ratio.is_finite() {
265 Self(ratio.max(1.0))
266 } else {
267 Self::default()
268 }
269 }
270
271 /// The ratio.
272 ///
273 /// ```
274 /// assert_eq!(pdfrum::MiterLimit::default().get(), 10.0);
275 /// ```
276 #[must_use]
277 pub fn get(self) -> f64 {
278 self.0
279 }
280}
281
282impl Default for MiterLimit {
283 /// PDF's own initial value, 10 (ISO 32000-1 table 52).
284 fn default() -> Self {
285 Self(10.0)
286 }
287}
288
289/// A dash pattern — ISO 32000-1 §8.4.3.6's dash array and phase, written as
290/// `d`.
291///
292/// # Why construction is fallible
293///
294/// `d` is one of the few graphics-state operators a reader may reject
295/// outright: a negative length, or an array summing to zero, is not a
296/// degenerate dash but an *invalid* one, and a viewer that refuses it refuses
297/// the whole content stream — every later operator with it. So an invalid
298/// array is caught here, at construction, rather than normalized into a
299/// pattern the caller did not ask for and cannot see. [`Dash::new`] returns
300/// `None` and nothing reaches the stream.
301///
302/// A solid line is spelled `Stroke::dash = None`, so an empty array is
303/// refused too: it has a valid PDF spelling, but it means the thing the
304/// `Option` already says.
305#[derive(Debug, Clone, PartialEq)]
306pub struct Dash {
307 /// Alternating on and off lengths, all finite and non-negative, summing
308 /// to more than zero.
309 lengths: Vec<f64>,
310 /// How far into the pattern the line starts. Finite and non-negative.
311 phase: f64,
312}
313
314impl Dash {
315 /// A dash of alternating on/off `lengths`, starting `phase` units into
316 /// the pattern.
317 ///
318 /// `None` if `lengths` is empty, holds anything negative or not finite,
319 /// or sums to zero, or if `phase` is negative or not finite — each of
320 /// which is an invalid `d` operand rather than an unusual one.
321 ///
322 /// ```
323 /// use pdfrum::Dash;
324 ///
325 /// assert!(Dash::new(&[4.0, 2.0], 0.0).is_some());
326 /// assert!(Dash::new(&[4.0, -2.0], 0.0).is_none());
327 /// assert!(Dash::new(&[0.0, 0.0], 0.0).is_none());
328 /// assert!(Dash::new(&[], 0.0).is_none());
329 /// ```
330 #[must_use]
331 pub fn new(lengths: &[f64], phase: f64) -> Option<Self> {
332 if lengths.is_empty() || !phase.is_finite() || phase < 0.0 {
333 return None;
334 }
335 if lengths
336 .iter()
337 .any(|length| !length.is_finite() || *length < 0.0)
338 {
339 return None;
340 }
341 if lengths.iter().sum::<f64>() <= 0.0 {
342 return None;
343 }
344 Some(Self {
345 lengths: lengths.to_vec(),
346 phase,
347 })
348 }
349
350 /// The alternating on/off lengths.
351 ///
352 /// ```
353 /// let dash = pdfrum::Dash::new(&[3.0, 1.0], 0.5).expect("a valid dash");
354 /// assert_eq!(dash.lengths(), &[3.0, 1.0]);
355 /// assert_eq!(dash.phase(), 0.5);
356 /// ```
357 #[must_use]
358 pub fn lengths(&self) -> &[f64] {
359 &self.lengths
360 }
361
362 /// How far into the pattern the line starts.
363 #[must_use]
364 pub fn phase(&self) -> f64 {
365 self.phase
366 }
367}
368
369/// Which points a fill considers inside.
370///
371/// The `pdfrum-page` reader's `FillRule` carries a third `None` case for a
372/// path that is only stroked; here that case is spelled by [`Paint::Stroke`]
373/// instead, so this enum has exactly the two rules ISO 32000-1 §8.5.3.3
374/// defines.
375#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
376pub enum Fill {
377 /// The nonzero winding rule — `f`, `B`. The default.
378 #[default]
379 NonZero,
380 /// The even-odd rule — `f*`, `B*`.
381 EvenOdd,
382}
383
384/// One page's drawing surface.
385///
386/// Handed to the closure of [`EditDoc::draw_page`] and
387/// [`EditDoc::draw_pages`]; it cannot be constructed otherwise, because a
388/// canvas is only meaningful against the page whose space it maps and the
389/// session whose resources it merges into.
390///
391/// # The coordinate space
392///
393/// Canvas coordinates are the page **as displayed**, in points:
394///
395/// - the origin is the lower-left corner of the crop box after `/Rotate`;
396/// - x runs right and **y runs up**, as PDF page space does and unlike a
397/// raster;
398/// - the extent is [`Canvas::size`], whose sides are the crop box's *swapped*
399/// on a quarter or three-quarter turn.
400///
401/// So a caller places things where they see them: on a page with
402/// `/Rotate 90`, `Point::new(0.0, 0.0)` is the bottom-left corner on screen,
403/// and text drawn along +x reads upright there. The composition is exactly
404/// the inverse of [`pdfrum_page::Rotation::display_matrix`] over the crop
405/// box, which is the same matrix the renderer uses, so what a caller places
406/// and what a viewer shows cannot drift apart.
407///
408/// Every method takes canvas coordinates. [`Canvas::transform`] composes a
409/// further transform *inside* that space, so a rotation about a point is
410/// written in the coordinates the caller is already using.
411pub struct Canvas<'a, 'b> {
412 /// Where the operators accumulate. Not yet wrapped in `q`/`Q`.
413 out: String,
414 /// The session the resources are merged into and new objects allocated
415 /// from.
416 edit: &'a mut EditDoc<'b>,
417 /// The ceilings a font read while measuring text obeys.
418 limits: Limits,
419 /// The faces an ingested SVG's `<text>` is set in.
420 #[cfg(feature = "svg-text")]
421 fonts: crate::svg_text::SvgFonts,
422 /// The resources this drawing needs, by category, under names already
423 /// checked against the page's own.
424 added: Vec<(&'static Name, Name, Object)>,
425 /// Names already taken: the page's own, plus every name this drawing has
426 /// allocated. Fresh names are chosen against this set, per category.
427 taken: Vec<(&'static Name, Name)>,
428 /// The displayed size, in points.
429 size: kurbo::Size,
430 /// What the operators are being written into.
431 surface: Surface,
432 /// The first error a drawing method hit. Reported once, from
433 /// [`EditDoc::draw_page`], rather than at every call.
434 failed: Option<Error>,
435}
436
437impl std::fmt::Debug for Canvas<'_, '_> {
438 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
439 f.debug_struct("Canvas")
440 .field("size", &self.size)
441 .field("surface", &self.surface)
442 .field("failed", &self.failed)
443 .finish_non_exhaustive()
444 }
445}
446
447/// What a canvas's operators are being written into.
448///
449/// An enum rather than an `Option<PageIndex>`, because the two destinations
450/// differ in more than whether a page index exists: a page's drawing is
451/// *appended* to `/Contents` and merges into the page's own `/Resources`,
452/// while a form's becomes a standalone `/Subtype /Form` stream with a
453/// `/Resources` of its own and no page to collide with. [`Canvas::page`]
454/// answers for the first and has nothing to answer for the second, which is
455/// why it returns an `Option`.
456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
457enum Surface {
458 /// One page's appended content stream.
459 Page(PageIndex),
460 /// A Form `XObject`'s own stream, placed later by [`Canvas::place_form`].
461 ///
462 /// Behind the feature that is the only thing that compiles a form: with
463 /// `svg-import` off nothing constructs it, and a variant nothing
464 /// constructs is the dead code forbids.
465 #[cfg(feature = "svg-import")]
466 Form,
467}
468
469impl Canvas<'_, '_> {
470 /// The page's displayed size in points — the crop box's, with its sides
471 /// swapped on a quarter turn.
472 ///
473 /// The canvas's own extent: `Rect::from_origin_size(Point::ZERO, size)`
474 /// is the whole visible page.
475 ///
476 /// ```
477 /// let doc = pdfrum::Document::open("tests/fixtures/hello_world.pdf")?;
478 /// let mut edit = doc.edit();
479 /// edit.draw_page(0, |c| {
480 /// assert!(c.size().width > 0.0);
481 /// })?;
482 /// # Ok::<(), pdfrum::Error>(())
483 /// ```
484 #[must_use]
485 pub fn size(&self) -> kurbo::Size {
486 self.size
487 }
488
489 /// The whole visible page, as a rectangle in canvas coordinates.
490 ///
491 /// ```
492 /// let doc = pdfrum::Document::open("tests/fixtures/hello_world.pdf")?;
493 /// let mut edit = doc.edit();
494 /// edit.draw_page(0, |c| {
495 /// assert_eq!(c.bounds().origin(), pdfrum::Point::ZERO);
496 /// })?;
497 /// # Ok::<(), pdfrum::Error>(())
498 /// ```
499 #[must_use]
500 pub fn bounds(&self) -> Rect {
501 Rect::from_origin_size(Point::ZERO, self.size)
502 }
503
504 /// The page this canvas draws on, or `None` when it is compiling a Form
505 /// `XObject` that no page owns yet.
506 ///
507 /// A canvas handed to [`EditDoc::draw_page`] or [`EditDoc::draw_pages`]
508 /// always answers `Some`. The `None` case is a Form `XObject` compiled
509 /// by `EditDoc::compile_svg` (feature `svg-import`), whose content
510 /// belongs to no page until `Canvas::place_svg` puts it on one.
511 ///
512 /// ```
513 /// let doc = pdfrum::Document::open("tests/fixtures/hello_world_2_pages.pdf")?;
514 /// let mut edit = doc.edit();
515 /// edit.draw_pages(|c| assert!(c.page().is_some_and(|p| u32::from(p) < 2)))?;
516 /// # Ok::<(), pdfrum::Error>(())
517 /// ```
518 #[must_use]
519 pub fn page(&self) -> Option<PageIndex> {
520 match self.surface {
521 Surface::Page(index) => Some(index),
522 #[cfg(feature = "svg-import")]
523 Surface::Form => None,
524 }
525 }
526
527 /// Draw inside a saved graphics state, restored when `body` returns.
528 ///
529 /// This is the *only* spelling of `q`/`Q`: there is no bare `save` a
530 /// caller could leave unmatched, and no `restore` that could pop a state
531 /// the caller did not push. Nesting is the closure nesting, so an
532 /// unbalanced stream is not expressible.
533 ///
534 /// ```
535 /// use pdfrum::{Color, Paint, Rect};
536 ///
537 /// let doc = pdfrum::Document::open("tests/fixtures/hello_world.pdf")?;
538 /// let mut edit = doc.edit();
539 /// edit.draw_page(0, |c| {
540 /// c.saved(|c| {
541 /// c.clip(Rect::new(0.0, 0.0, 100.0, 100.0), pdfrum::Fill::NonZero);
542 /// c.fill_rect(Rect::new(0.0, 0.0, 500.0, 500.0), Color::from_rgb8(200, 0, 0));
543 /// });
544 /// // The clip is gone here.
545 /// c.fill_rect(Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK);
546 /// })?;
547 /// # Ok::<(), pdfrum::Error>(())
548 /// ```
549 pub fn saved(&mut self, body: impl FnOnce(&mut Self)) {
550 self.out.push_str("q\n");
551 body(self);
552 self.out.push_str("Q\n");
553 }
554
555 /// Compose `transform` into the canvas space, for everything drawn after
556 /// it.
557 ///
558 /// Scoped by [`Canvas::saved`], like every other graphics-state change; a
559 /// transform outside one lasts for the rest of the drawing.
560 ///
561 /// ```
562 /// use pdfrum::{Affine, Color, Rect};
563 ///
564 /// let doc = pdfrum::Document::open("tests/fixtures/hello_world.pdf")?;
565 /// let mut edit = doc.edit();
566 /// edit.draw_page(0, |c| {
567 /// c.saved(|c| {
568 /// c.transform(Affine::rotate_about(0.5, c.bounds().center()));
569 /// c.fill_rect(Rect::new(0.0, 0.0, 100.0, 20.0), Color::BLACK);
570 /// });
571 /// })?;
572 /// # Ok::<(), pdfrum::Error>(())
573 /// ```
574 pub fn transform(&mut self, transform: Affine) {
575 write_matrix(&mut self.out, transform);
576 self.out.push_str(" cm\n");
577 }
578
579 /// Intersect the clip with `shape`, for everything drawn after it.
580 ///
581 /// Scoped by [`Canvas::saved`]: a PDF clip can only ever be narrowed, so
582 /// a `q`/`Q` is the only way back.
583 ///
584 /// ```
585 /// use pdfrum::{Fill, Rect};
586 ///
587 /// let doc = pdfrum::Document::open("tests/fixtures/hello_world.pdf")?;
588 /// let mut edit = doc.edit();
589 /// edit.draw_page(0, |c| {
590 /// c.saved(|c| c.clip(Rect::new(10.0, 10.0, 90.0, 90.0), Fill::NonZero));
591 /// })?;
592 /// # Ok::<(), pdfrum::Error>(())
593 /// ```
594 pub fn clip(&mut self, shape: impl Shape, rule: Fill) {
595 self.write_path(&shape.into_path(0.1));
596 self.out.push_str(match rule {
597 Fill::NonZero => " W n\n",
598 Fill::EvenOdd => " W* n\n",
599 });
600 }
601
602 /// Fill `shape` with `color`, by the nonzero rule.
603 ///
604 /// ```
605 /// use pdfrum::{Color, Rect};
606 ///
607 /// let doc = pdfrum::Document::open("tests/fixtures/hello_world.pdf")?;
608 /// let mut edit = doc.edit();
609 /// edit.draw_page(0, |c| {
610 /// c.fill(Rect::new(0.0, 0.0, 50.0, 50.0), Color::from_rgb8(0, 0, 255));
611 /// })?;
612 /// # Ok::<(), pdfrum::Error>(())
613 /// ```
614 pub fn fill(&mut self, shape: impl Shape, color: Color) {
615 self.draw(shape, Paint::Fill(color), Fill::NonZero);
616 }
617
618 /// Fill `rect` with `color` — [`Canvas::fill`] on the commonest shape.
619 ///
620 /// ```
621 /// use pdfrum::{Color, Rect};
622 ///
623 /// let doc = pdfrum::Document::open("tests/fixtures/hello_world.pdf")?;
624 /// let mut edit = doc.edit();
625 /// edit.draw_page(0, |c| {
626 /// c.fill_rect(Rect::new(0.0, 0.0, 50.0, 50.0), Color::BLACK);
627 /// })?;
628 /// # Ok::<(), pdfrum::Error>(())
629 /// ```
630 pub fn fill_rect(&mut self, rect: Rect, color: Color) {
631 self.fill(rect, color);
632 }
633
634 /// Fill a rectangle with `radius`-point rounded corners.
635 ///
636 /// ```
637 /// use pdfrum::{Color, Rect};
638 ///
639 /// let doc = pdfrum::Document::open("tests/fixtures/hello_world.pdf")?;
640 /// let mut edit = doc.edit();
641 /// edit.draw_page(0, |c| {
642 /// c.fill_rounded_rect(Rect::new(0.0, 0.0, 80.0, 30.0), 6.0, Color::BLACK);
643 /// })?;
644 /// # Ok::<(), pdfrum::Error>(())
645 /// ```
646 pub fn fill_rounded_rect(&mut self, rect: Rect, radius: f64, color: Color) {
647 self.fill(RoundedRect::from_rect(rect, radius), color);
648 }
649
650 /// Stroke `shape`.
651 ///
652 /// ```
653 /// use pdfrum::{Color, Rect, Stroke};
654 ///
655 /// let doc = pdfrum::Document::open("tests/fixtures/hello_world.pdf")?;
656 /// let mut edit = doc.edit();
657 /// edit.draw_page(0, |c| {
658 /// c.stroke(Rect::new(0.0, 0.0, 50.0, 50.0), Stroke::new(Color::BLACK, 1.0));
659 /// })?;
660 /// # Ok::<(), pdfrum::Error>(())
661 /// ```
662 pub fn stroke(&mut self, shape: impl Shape, stroke: Stroke) {
663 self.draw(shape, Paint::Stroke(stroke), Fill::NonZero);
664 }
665
666 /// Stroke the straight segment from `from` to `to` — a header rule, a
667 /// divider.
668 ///
669 /// ```
670 /// use pdfrum::{Color, Point, Stroke};
671 ///
672 /// let doc = pdfrum::Document::open("tests/fixtures/hello_world.pdf")?;
673 /// let mut edit = doc.edit();
674 /// edit.draw_page(0, |c| {
675 /// let y = c.size().height - 50.0;
676 /// c.line(Point::new(50.0, y), Point::new(c.size().width - 50.0, y),
677 /// Stroke::new(Color::BLACK, 0.75));
678 /// })?;
679 /// # Ok::<(), pdfrum::Error>(())
680 /// ```
681 pub fn line(&mut self, from: Point, to: Point, stroke: Stroke) {
682 self.stroke(kurbo::Line::new(from, to), stroke);
683 }
684
685 /// Paint `shape` with `paint`, filling by `rule`.
686 ///
687 /// The general case the other shape methods narrow: [`Canvas::fill`] is
688 /// `Paint::Fill` with [`Fill::NonZero`], [`Canvas::stroke`] is
689 /// `Paint::Stroke`.
690 ///
691 /// ```
692 /// use pdfrum::{Color, Fill, Paint, Rect, Stroke};
693 ///
694 /// let doc = pdfrum::Document::open("tests/fixtures/hello_world.pdf")?;
695 /// let mut edit = doc.edit();
696 /// edit.draw_page(0, |c| {
697 /// c.draw(
698 /// Rect::new(0.0, 0.0, 40.0, 40.0),
699 /// Paint::FillStroke(Color::from_rgb8(255, 255, 0), Stroke::new(Color::BLACK, 2.0)),
700 /// Fill::EvenOdd,
701 /// );
702 /// })?;
703 /// # Ok::<(), pdfrum::Error>(())
704 /// ```
705 pub fn draw(&mut self, shape: impl Shape, paint: Paint, rule: Fill) {
706 let path = shape.into_path(0.1);
707 if path.elements().is_empty() {
708 return;
709 }
710 let operator = paint_operator(&paint, rule);
711 self.out.push_str("q\n");
712 self.set_paint(paint);
713 self.write_path(&path);
714 self.out.push_str(operator);
715 self.out.push_str("\nQ\n");
716 }
717
718 /// Draw `text` in `font` at `size`, with its baseline starting at `at`.
719 ///
720 /// `font` is one this session loaded through [`EditDoc::embed_font`] or
721 /// [`EditDoc::standard_font`], so its glyphs are subset and embedded by
722 /// the machinery that already does that for a saved font. A base-14 face
723 /// from `standard_font` needs no embedded program.
724 ///
725 /// One string, one point, one line: see the module documentation for why
726 /// there is no wrapping.
727 ///
728 /// # Errors
729 ///
730 /// A character `font` has no glyph for is an error, not a blank — the
731 /// canvas records it and [`EditDoc::draw_page`] returns it. Nothing of
732 /// this call is written when it fails, so a refused string leaves no
733 /// half-drawn run behind.
734 ///
735 /// ```
736 /// use pdfrum::{Color, Point, StandardFont};
737 ///
738 /// let doc = pdfrum::Document::open("tests/fixtures/hello_world.pdf")?;
739 /// let mut edit = doc.edit();
740 /// let font = edit.standard_font(StandardFont::Helvetica)?;
741 /// edit.draw_page(0, |c| {
742 /// c.text("Page 1", &font, 10.0, Point::new(72.0, 72.0), Color::BLACK);
743 /// })?;
744 /// # Ok::<(), pdfrum::Error>(())
745 /// ```
746 pub fn text(&mut self, text: &str, font: &EmbeddedFont, size: f64, at: Point, color: Color) {
747 let codes = match font.encode_checked(text) {
748 Ok(codes) => codes,
749 Err(missing) => return self.fail(Error::from(missing)),
750 };
751 if codes.is_empty() {
752 return;
753 }
754 let name = self.realize(pdf_names::FONT, Object::Ref(font.object()));
755 self.out.push_str("q\n");
756 self.set_paint(Paint::Fill(color));
757 self.out.push_str("BT\n/");
758 self.push_name(&name);
759 self.out.push(' ');
760 write_f64(&mut self.out, size);
761 self.out.push_str(" Tf 1 0 0 1 ");
762 write_point(&mut self.out, at);
763 self.out.push_str(" Tm ");
764 write_hex_string(&mut self.out, &codes);
765 self.out.push_str(" Tj\nET\nQ\n");
766 }
767
768 /// The advance of `text` in `font` at `size`, in canvas units.
769 ///
770 /// The **only** measurement this API offers, and it is what centring a
771 /// single string needs. It is not a layout engine and does not claim to
772 /// be: no line breaking, no kerning beyond the font's own advances, and
773 /// no vertical metrics.
774 ///
775 /// `0.0` for a string `font` cannot encode.
776 ///
777 /// ```
778 /// use pdfrum::StandardFont;
779 ///
780 /// let doc = pdfrum::Document::open("tests/fixtures/hello_world.pdf")?;
781 /// let mut edit = doc.edit();
782 /// let font = edit.standard_font(StandardFont::Helvetica)?;
783 /// edit.draw_page(0, |c| {
784 /// assert!(c.text_width("Hello", &font, 12.0) > 0.0);
785 /// })?;
786 /// # Ok::<(), pdfrum::Error>(())
787 /// ```
788 #[must_use]
789 pub fn text_width(&self, text: &str, font: &EmbeddedFont, size: f64) -> f64 {
790 let Ok(codes) = font.encode_checked(text) else {
791 return 0.0;
792 };
793 crate::string_width(
794 font.object(),
795 &codes,
796 self.edit,
797 &self.limits,
798 &mut Diagnostics::default(),
799 ) * size
800 / 1000.0
801 }
802
803 /// Draw `image` stretched onto `rect`.
804 ///
805 /// `image` is one this session embedded through [`EditDoc::embed_jpeg`]
806 /// or [`EditDoc::embed_image`]. Nothing preserves the aspect ratio: a
807 /// caller who wants it kept sizes `rect` from
808 /// [`EmbeddedImage::width`](crate::EmbeddedImage::width) and
809 /// [`EmbeddedImage::height`](crate::EmbeddedImage::height).
810 ///
811 /// ```
812 /// use pdfrum::Rect;
813 ///
814 /// let doc = pdfrum::Document::open("tests/fixtures/hello_world.pdf")?;
815 /// let mut edit = doc.edit();
816 /// let logo = edit.embed_jpeg(include_bytes!("../tests/fixtures/mona_lisa.jpg"))?;
817 /// edit.draw_page(0, |c| {
818 /// c.image(&logo, Rect::new(20.0, 20.0, 80.0, 80.0));
819 /// })?;
820 /// # Ok::<(), pdfrum::Error>(())
821 /// ```
822 pub fn image(&mut self, image: &EmbeddedImage, rect: Rect) {
823 if rect.width() == 0.0 || rect.height() == 0.0 {
824 return;
825 }
826 let name = self.realize(pdf_names::XOBJECT, Object::Ref(image.object()));
827 self.out.push_str("q\n");
828 write_matrix(
829 &mut self.out,
830 Affine::new([rect.width(), 0.0, 0.0, rect.height(), rect.x0, rect.y0]),
831 );
832 self.out.push_str(" cm /");
833 self.push_name(&name);
834 self.out.push_str(" Do\nQ\n");
835 }
836
837 /// Set the constant alpha for everything drawn after it, as an
838 /// `/ExtGState` naming `/ca` and `/CA`.
839 ///
840 /// Scoped by [`Canvas::saved`]. The alpha a [`Color`] already carries is
841 /// applied on top of this, so a translucent colour under a 0.5 opacity is
842 /// twice translucent.
843 ///
844 /// ```
845 /// use pdfrum::{Color, Rect};
846 ///
847 /// let doc = pdfrum::Document::open("tests/fixtures/hello_world.pdf")?;
848 /// let mut edit = doc.edit();
849 /// edit.draw_page(0, |c| {
850 /// c.saved(|c| {
851 /// c.opacity(0.2);
852 /// c.fill_rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::from_rgb8(255, 0, 0));
853 /// });
854 /// })?;
855 /// # Ok::<(), pdfrum::Error>(())
856 /// ```
857 pub fn opacity(&mut self, alpha: f64) {
858 let alpha = alpha.clamp(0.0, 1.0);
859 let state = Dict::from_pairs([
860 (Name::from("ca"), Object::Real(as_f32(alpha))),
861 (Name::from("CA"), Object::Real(as_f32(alpha))),
862 ]);
863 let name = self.realize(pdf_names::EXT_G_STATE, Object::Dict(state));
864 self.out.push('/');
865 self.push_name(&name);
866 self.out.push_str(" gs\n");
867 }
868
869 /// Paint `shape` with a shading dictionary, clipped to the shape.
870 ///
871 /// `sh` fills the *whole current clip*, so the shape becomes a clip and
872 /// the shading is painted through it — which is how PDF spells a
873 /// gradient-filled path. `transform` is the gradient's own coordinate
874 /// mapping, applied inside the clip so it moves the gradient rather than
875 /// the shape.
876 ///
877 /// Crate-internal because a caller-facing shading API is a design of its
878 /// own — colour spaces, function types, the extend flags — and the one
879 /// caller here is [`Canvas::draw_svg`](crate::Canvas::draw_svg), which
880 /// builds the dictionary from a `usvg` gradient.
881 #[cfg(feature = "svg-import")]
882 pub(crate) fn shade(
883 &mut self,
884 shape: &BezPath,
885 rule: Fill,
886 shading: &Dict,
887 transform: Affine,
888 opacity: f64,
889 ) {
890 let name = self.realize(pdf_names::SHADING, Object::Dict(shading.clone()));
891 self.out.push_str("q\n");
892 self.write_path(shape);
893 self.out.push_str(match rule {
894 Fill::NonZero => " W n\n",
895 Fill::EvenOdd => " W* n\n",
896 });
897 if opacity < 1.0 {
898 self.opacity(opacity);
899 }
900 write_matrix(&mut self.out, transform);
901 self.out.push_str(" cm /");
902 self.push_name(&name);
903 self.out.push_str(" sh\nQ\n");
904 }
905
906 /// Embed a PNG or JPEG an SVG `<image>` carried, as a new image
907 /// `/XObject`.
908 ///
909 /// `None` when the bytes are neither, or decode to nothing this session
910 /// can embed; the caller reports that as
911 /// [`Unsupported::ImageFormat`](crate::Unsupported::ImageFormat) rather
912 /// than failing the whole drawing, because one bad `<image>` should not
913 /// cost the rest of the document.
914 #[cfg(feature = "svg-import")]
915 pub(crate) fn embed_svg_image(&mut self, bytes: &[u8]) -> Option<EmbeddedImage> {
916 // JPEG passes through whole: `/DCTDecode` is the PDF filter for
917 // exactly these bytes, so nothing is decoded and nothing is lost.
918 if bytes.starts_with(&[0xFF, 0xD8]) {
919 return self.edit.embed_jpeg(bytes).ok();
920 }
921 let decoded = crate::svg_ingest::decode_png(bytes)?;
922 self.edit
923 .embed_image(
924 &decoded.pixels,
925 decoded.width,
926 decoded.height,
927 decoded.format,
928 )
929 .ok()
930 }
931
932 /// The session this canvas draws into.
933 ///
934 /// Ingestion reads the SVG font set off it before parsing; there is no
935 /// mutable access, because a drawing method that reached into the session
936 /// past the resource machinery could add an object nothing names.
937 #[cfg(feature = "svg-text")]
938 pub(crate) fn fonts(&self) -> &crate::svg_text::SvgFonts {
939 &self.fonts
940 }
941
942 /// Record the first failure; later ones are dropped, because the first is
943 /// the one that explains the rest.
944 fn fail(&mut self, error: Error) {
945 if self.failed.is_none() {
946 self.failed = Some(error);
947 }
948 }
949
950 /// Write the colour, width and pen operators `paint` asks for.
951 fn set_paint(&mut self, paint: Paint) {
952 let (fill, stroke) = match paint {
953 Paint::Fill(color) => (Some(color), None),
954 Paint::Stroke(stroke) => (None, Some(stroke)),
955 Paint::FillStroke(color, stroke) => (Some(color), Some(stroke)),
956 };
957
958 // Alpha rides on an `/ExtGState`, since `rg`/`RG` carry none.
959 let alpha = fill
960 .map(alpha_of)
961 .into_iter()
962 .chain(stroke.as_ref().map(|stroke| alpha_of(stroke.color)))
963 .fold(1.0_f64, f64::min);
964 if alpha < 1.0 {
965 self.opacity(alpha);
966 }
967 if let Some(color) = fill {
968 self.write_rgb(color);
969 self.out.push_str(" rg\n");
970 }
971 if let Some(stroke) = stroke {
972 self.write_rgb(stroke.color);
973 self.out.push_str(" RG\n");
974 write_f64(&mut self.out, stroke.width.max(0.0));
975 self.out.push_str(" w\n");
976 self.write_pen(&stroke);
977 }
978 }
979
980 /// Write the cap, join, miter-limit and dash operators, each only when it
981 /// differs from the graphics state's own initial value (ISO 32000-1
982 /// table 52): the drawing runs inside a fresh `q`, so an unwritten one is
983 /// already what the caller asked for and the stream stays short.
984 fn write_pen(&mut self, stroke: &Stroke) {
985 if stroke.cap != LineCap::Butt {
986 let _ = writeln!(self.out, "{} J", stroke.cap.operand());
987 }
988 if stroke.join != LineJoin::Miter {
989 let _ = writeln!(self.out, "{} j", stroke.join.operand());
990 }
991 if stroke.miter_limit != MiterLimit::default() {
992 write_f64(&mut self.out, stroke.miter_limit.get());
993 self.out.push_str(" M\n");
994 }
995 if let Some(dash) = &stroke.dash {
996 self.out.push('[');
997 for (i, length) in dash.lengths().iter().enumerate() {
998 if i > 0 {
999 self.out.push(' ');
1000 }
1001 write_f64(&mut self.out, *length);
1002 }
1003 self.out.push_str("] ");
1004 write_f64(&mut self.out, dash.phase());
1005 self.out.push_str(" d\n");
1006 }
1007 }
1008
1009 /// Append a colour's three clamped components, space separated.
1010 fn write_rgb(&mut self, color: Color) {
1011 let [r, g, b, _] = color.components;
1012 for (i, component) in [r, g, b].into_iter().enumerate() {
1013 if i > 0 {
1014 self.out.push(' ');
1015 }
1016 write_float(&mut self.out, component.clamp(0.0, 1.0));
1017 }
1018 }
1019
1020 /// Append a path's construction operators, with no trailing separator.
1021 ///
1022 /// A path that is exactly an axis-aligned rectangle is written as one
1023 /// `re`, which is both shorter and what `pdfrum-edit`'s own emitter
1024 /// writes, so the two producers spell the commonest shape the same way.
1025 ///
1026 /// Otherwise a running cursor tracks the current point, because a
1027 /// quadratic segment needs the point it starts from: PDF has no quadratic
1028 /// operator, so each one is raised to the cubic with the identical curve
1029 /// rather than flattened into lines.
1030 fn write_path(&mut self, path: &BezPath) {
1031 if let Some(rect) = axis_aligned_rect(path) {
1032 crate::write_rect(&mut self.out, rect);
1033 self.out.push_str(" re");
1034 return;
1035 }
1036 let mut at = Point::ZERO;
1037 let mut start = Point::ZERO;
1038 for (index, element) in path.elements().iter().enumerate() {
1039 if index > 0 {
1040 self.out.push(' ');
1041 }
1042 match *element {
1043 PathEl::MoveTo(p) => {
1044 write_point(&mut self.out, p);
1045 self.out.push_str(" m");
1046 at = p;
1047 start = p;
1048 }
1049 PathEl::LineTo(p) => {
1050 write_point(&mut self.out, p);
1051 self.out.push_str(" l");
1052 at = p;
1053 }
1054 PathEl::QuadTo(c, p) => {
1055 let (c1, c2) = quad_to_cubic(at, c, p);
1056 self.write_cubic(c1, c2, p);
1057 at = p;
1058 }
1059 PathEl::CurveTo(c1, c2, p) => {
1060 self.write_cubic(c1, c2, p);
1061 at = p;
1062 }
1063 PathEl::ClosePath => {
1064 self.out.push('h');
1065 at = start;
1066 }
1067 }
1068 }
1069 }
1070
1071 /// Append one `c` operator: three points, space separated.
1072 fn write_cubic(&mut self, c1: Point, c2: Point, end: Point) {
1073 write_point(&mut self.out, c1);
1074 self.out.push(' ');
1075 write_point(&mut self.out, c2);
1076 self.out.push(' ');
1077 write_point(&mut self.out, end);
1078 self.out.push_str(" c");
1079 }
1080
1081 /// Append a name's bytes, escaping what ISO 32000-1 §7.3.5 requires.
1082 ///
1083 /// Every name this canvas writes is one it minted, so nothing needs
1084 /// escaping in practice; the escape is here so that a name reaching it
1085 /// some other way still produces a stream our own lexer reads back.
1086 fn push_name(&mut self, name: &Name) {
1087 for byte in name.as_bytes() {
1088 if byte.is_ascii_alphanumeric() {
1089 self.out.push(char::from(*byte));
1090 } else {
1091 let _ = write!(self.out, "#{byte:02X}");
1092 }
1093 }
1094 }
1095
1096 /// The name `value` is known by in `category`, allocating a fresh one
1097 /// that collides with neither the page's own resources nor anything this
1098 /// drawing already added.
1099 fn realize(&mut self, category: &'static Name, value: Object) -> Name {
1100 // The same font or image drawn twice takes one name, not two.
1101 if let Some((_, name, _)) = self
1102 .added
1103 .iter()
1104 .find(|(held_category, _, held)| *held_category == category && *held == value)
1105 {
1106 return name.clone();
1107 }
1108 let name = self.free_name(category);
1109 self.taken.push((category, name.clone()));
1110 self.added.push((category, name.clone(), value));
1111 name
1112 }
1113
1114 /// The first `PdfrumC<n>` name free in `category`.
1115 ///
1116 /// The prefix is this crate's and nothing else in the workspace mints it:
1117 /// `pdfrum-edit`'s regeneration uses `FX*`, and a producer's own names are
1118 /// whatever the page already holds — which is exactly what `taken`
1119 /// carries, so a collision is checked rather than assumed away.
1120 fn free_name(&self, category: &Name) -> Name {
1121 for id in 1u32.. {
1122 let candidate = Name::from(format!("PdfrumC{id}").as_str());
1123 if !self
1124 .taken
1125 .iter()
1126 .any(|(cat, name)| *cat == category && *name == candidate)
1127 {
1128 return candidate;
1129 }
1130 }
1131 Name::from("PdfrumC1")
1132 }
1133}
1134
1135/// Drawing compiled once into a Form `XObject`, placeable on any number of
1136/// pages.
1137///
1138/// A `/Subtype /Form` stream with its own `/BBox` and `/Resources`, held as a
1139/// single object in the document. Placing it writes one `Do` — so the same
1140/// logo on twenty pages is one copy of the content and twenty references,
1141/// rather than twenty copies of the content.
1142///
1143/// Produced by [`EditDoc::compile_svg`] and placed by
1144/// [`Canvas::place_svg`](crate::Canvas::place_svg).
1145/// It carries no borrow of the session that made it, so a caller compiles
1146/// once and then places inside as many `draw_page` closures as they like.
1147#[cfg(feature = "svg-import")]
1148#[derive(Debug, Clone, PartialEq)]
1149pub struct SvgForm {
1150 /// The form's object in the session that compiled it.
1151 object: ObjRef,
1152 /// The form's own coordinate box, in its own space. A placement maps this
1153 /// onto the destination rectangle.
1154 bbox: Rect,
1155}
1156
1157#[cfg(feature = "svg-import")]
1158impl SvgForm {
1159 /// The form's `/BBox`, in the form's own coordinate space.
1160 ///
1161 /// Its aspect ratio is what [`SvgFit`](crate::SvgFit) preserves when the
1162 /// destination rectangle has a different one.
1163 #[must_use]
1164 pub fn bbox(&self) -> Rect {
1165 self.bbox
1166 }
1167}
1168
1169#[cfg(feature = "svg-import")]
1170impl Canvas<'_, '_> {
1171 /// Place `form` so its [`SvgForm::bbox`] covers `into`.
1172 ///
1173 /// One `Do` operator against the form's single object, so placing the
1174 /// same form on every page of a document costs one copy of the content
1175 /// and one reference per page. The placement is scoped in its own `q`/`Q`
1176 /// and clipped to `into`, so nothing the form draws escapes the rectangle
1177 /// and the canvas's own state survives it.
1178 ///
1179 /// The form's box is stretched onto `into`, with no fit of its own — the
1180 /// caller-facing spelling is
1181 /// [`Canvas::place_svg`](crate::Canvas::place_svg), which chooses the
1182 /// rectangle through an [`SvgFit`](crate::SvgFit) and then calls this.
1183 /// Crate-internal because a second public placement that differs only in
1184 /// taking a pre-fitted rectangle would be a way of saying the same thing
1185 /// twice.
1186 pub(crate) fn place_form(&mut self, form: &SvgForm, into: Rect) {
1187 if into.width() == 0.0 || into.height() == 0.0 || form.bbox.is_zero_area() {
1188 return;
1189 }
1190 let name = self.realize(pdf_names::XOBJECT, Object::Ref(form.object));
1191 // The form's `/BBox` is mapped onto `into`: scale by the ratio of the
1192 // two, then carry the form's own origin to the destination's. `/BBox`
1193 // is *not* assumed to start at the origin, because a compiled SVG's
1194 // need not.
1195 let scale_x = into.width() / form.bbox.width();
1196 let scale_y = into.height() / form.bbox.height();
1197 let placement = Affine::new([
1198 scale_x,
1199 0.0,
1200 0.0,
1201 scale_y,
1202 into.x0 - form.bbox.x0 * scale_x,
1203 into.y0 - form.bbox.y0 * scale_y,
1204 ]);
1205 self.out.push_str("q\n");
1206 self.write_path(&into.into_path(0.1));
1207 self.out.push_str(" W n\n");
1208 write_matrix(&mut self.out, placement);
1209 self.out.push_str(" cm /");
1210 self.push_name(&name);
1211 self.out.push_str(" Do\nQ\n");
1212 }
1213}
1214
1215#[cfg(feature = "svg-import")]
1216impl EditDoc<'_> {
1217 /// Compile `body`'s drawing into a Form `XObject` over `bbox`.
1218 ///
1219 /// The canvas `body` receives writes into the form's own stream and its
1220 /// own `/Resources`, so nothing it names can collide with a page's — a
1221 /// form is a fresh resource scope, which is why the placement is one
1222 /// object rather than a merge per page.
1223 ///
1224 /// The shared half of [`EditDoc::compile_svg`]; it is crate-internal
1225 /// because the caller-facing surface for "drawing a caller wrote once" is
1226 /// [`EditDoc::draw_page`] with the caller's own closure, and a second
1227 /// spelling of it would be an option with no reader.
1228 ///
1229 /// # Errors
1230 ///
1231 /// Whatever `body` refused to draw, as [`EditDoc::draw_page`] reports it.
1232 pub(crate) fn compile_form(
1233 &mut self,
1234 bbox: Rect,
1235 limits: &Limits,
1236 #[cfg(feature = "svg-text")] fonts: &crate::svg_text::SvgFonts,
1237 body: impl FnOnce(&mut Canvas<'_, '_>),
1238 ) -> Result<SvgForm> {
1239 let mut canvas = Canvas {
1240 out: String::new(),
1241 limits: limits.clone(),
1242 #[cfg(feature = "svg-text")]
1243 fonts: fonts.clone(),
1244 edit: self,
1245 added: Vec::new(),
1246 // A form's resource scope is its own and starts empty: there is
1247 // no page dictionary whose names it has to avoid.
1248 taken: Vec::new(),
1249 size: bbox.size(),
1250 surface: Surface::Form,
1251 failed: None,
1252 };
1253 body(&mut canvas);
1254 if let Some(error) = canvas.failed {
1255 return Err(error);
1256 }
1257 let Canvas { out, added, .. } = canvas;
1258
1259 let resources = merge_resources(&Dict::new(), &added);
1260 let bytes = out.into_bytes();
1261 let dict = Dict::from_pairs([
1262 (
1263 pdf_names::TYPE.clone(),
1264 Object::Name(pdf_names::XOBJECT.clone()),
1265 ),
1266 (pdf_names::SUBTYPE.clone(), Object::Name(Name::from("Form"))),
1267 (Name::from("FormType"), Object::Int(1)),
1268 (Name::from("BBox"), Object::Array(rect_array(bbox))),
1269 (pdf_names::RESOURCES.clone(), Object::Dict(resources)),
1270 (
1271 pdf_names::LENGTH.clone(),
1272 Object::Int(i64::try_from(bytes.len()).unwrap_or(0)),
1273 ),
1274 ]);
1275 let object = self.add(Object::Stream(Box::new(Stream::new(dict, bytes.into()))));
1276 Ok(SvgForm { object, bbox })
1277 }
1278}
1279
1280/// A rectangle as the four numbers a `/BBox` holds.
1281#[cfg(feature = "svg-import")]
1282fn rect_array(rect: Rect) -> Array {
1283 Array::of([rect.x0, rect.y0, rect.x1, rect.y1].map(|value| Object::Real(as_f32(value))))
1284}
1285
1286/// The rectangle `path` draws, when it draws exactly one.
1287///
1288/// Four corners, axis-aligned, closed — which is what `Rect::into_path`
1289/// produces and what a caller's own rectangle almost always is. Anything else
1290/// returns `None` and is written segment by segment.
1291///
1292/// The coordinate comparisons are **exact**, deliberately. This is a
1293/// recognizer for a shape the caller built, not a geometric tolerance: a path
1294/// whose corners are a rounding error apart is not the rectangle the caller
1295/// asked for, and writing it as `re` would move an edge. `pdfrum-edit`'s own
1296/// emitter recognizes its rectangles the same way.
1297#[expect(
1298 clippy::float_cmp,
1299 reason = "exact recognition of a caller-built rectangle; a tolerance here would move an edge"
1300)]
1301fn axis_aligned_rect(path: &BezPath) -> Option<Rect> {
1302 let corners: [Point; 4] = match path.elements() {
1303 // A closed four-sided path, with or without the redundant final
1304 // `LineTo` back to the start that some shapes emit before `h`.
1305 [
1306 PathEl::MoveTo(first),
1307 PathEl::LineTo(second),
1308 PathEl::LineTo(third),
1309 PathEl::LineTo(fourth),
1310 PathEl::ClosePath,
1311 ] => [*first, *second, *third, *fourth],
1312 [
1313 PathEl::MoveTo(first),
1314 PathEl::LineTo(second),
1315 PathEl::LineTo(third),
1316 PathEl::LineTo(fourth),
1317 PathEl::LineTo(back),
1318 PathEl::ClosePath,
1319 ] if back == first => [*first, *second, *third, *fourth],
1320 _ => return None,
1321 };
1322 // Axis-aligned means each side shares one coordinate with the next.
1323 for index in 0..4 {
1324 let from = *corners.get(index)?;
1325 let to = *corners.get((index + 1) % 4)?;
1326 if from.x != to.x && from.y != to.y {
1327 return None;
1328 }
1329 }
1330 // And it must be a rectangle rather than a degenerate zig-zag: opposite
1331 // corners differ in both coordinates.
1332 let (origin, opposite) = (*corners.first()?, *corners.get(2)?);
1333 if origin.x == opposite.x || origin.y == opposite.y {
1334 return None;
1335 }
1336 Some(Rect::new(origin.x, origin.y, opposite.x, opposite.y))
1337}
1338
1339/// A colour's alpha, clamped.
1340fn alpha_of(color: Color) -> f64 {
1341 f64::from(color.components[3]).clamp(0.0, 1.0)
1342}
1343
1344/// An `f64` narrowed to the `f32` a PDF number is.
1345#[expect(
1346 clippy::cast_possible_truncation,
1347 reason = "PDF numbers are f32; the geometry vocabulary is f64"
1348)]
1349fn as_f32(value: f64) -> f32 {
1350 value as f32
1351}
1352
1353/// Append an `f64` through the crate-wide number spelling.
1354fn write_f64(out: &mut String, value: f64) {
1355 write_float(out, as_f32(value));
1356}
1357
1358/// The cubic control points equal to the quadratic `previous`-`control`-`end`.
1359fn quad_to_cubic(previous: Point, control: Point, end: Point) -> (Point, Point) {
1360 let third = 2.0 / 3.0;
1361 (
1362 previous + (control - previous) * third,
1363 end + (control - end) * third,
1364 )
1365}
1366
1367/// The paint operator for a paint and a fill rule (ISO 32000-1 table 60).
1368fn paint_operator(paint: &Paint, rule: Fill) -> &'static str {
1369 match (paint, rule) {
1370 (Paint::Fill(_), Fill::NonZero) => " f",
1371 (Paint::Fill(_), Fill::EvenOdd) => " f*",
1372 (Paint::Stroke(_), _) => " S",
1373 (Paint::FillStroke(_, _), Fill::NonZero) => " B",
1374 (Paint::FillStroke(_, _), Fill::EvenOdd) => " B*",
1375 }
1376}
1377
1378/// Append `codes` as a hexadecimal string, `<...>`.
1379///
1380/// Hex rather than a literal `(...)` so that no byte ever needs escaping: a
1381/// composite font's two-byte codes are full of parentheses and backslashes,
1382/// and getting that escaping subtly wrong is how a writer produces a stream
1383/// nothing can read.
1384fn write_hex_string(out: &mut String, codes: &[u8]) {
1385 out.push('<');
1386 for byte in codes {
1387 let _ = write!(out, "{byte:02X}");
1388 }
1389 out.push('>');
1390}
1391
1392impl EditDoc<'_> {
1393 /// Draw on page `index`, appending what `body` draws as one new content
1394 /// stream.
1395 ///
1396 /// The canvas's coordinate space is the page as displayed — see
1397 /// [`Canvas`]. The stream is wrapped in `q`/`Q` and appended to the
1398 /// page's `/Contents` array, so the page's own graphics state cannot leak
1399 /// into the drawing and the drawing's cannot leak into the page. The
1400 /// page's existing streams are **not** rewritten, which is why drawing on
1401 /// a page costs none of the regeneration losses
1402 /// [`PageEdit`](pdfrum_page::PageEdit) documents.
1403 ///
1404 /// # The resource-merging rule
1405 ///
1406 /// Fonts, images and graphics states the drawing used are merged into the
1407 /// page's `/Resources` under names of this crate's own `PdfrumC<n>`
1408 /// series, each checked against the names the page already holds, so a
1409 /// merged name can collide with neither the producer's nor
1410 /// `pdfrum-edit`'s `FX*`. A `/Resources` the page shares with another
1411 /// page is copied before it is written to, so drawing on one page cannot
1412 /// change another.
1413 ///
1414 /// ```
1415 /// use pdfrum::{Color, Document, Point, SaveOptions, StandardFont};
1416 ///
1417 /// let doc = Document::open("tests/fixtures/hello_world.pdf")?;
1418 /// let mut edit = doc.edit();
1419 /// let font = edit.standard_font(StandardFont::Helvetica)?;
1420 /// edit.draw_page(0, |c| {
1421 /// c.text("drawn", &font, 12.0, Point::new(40.0, 40.0), Color::BLACK);
1422 /// })?;
1423 ///
1424 /// let mut bytes = Vec::new();
1425 /// edit.write_to(&mut bytes, &SaveOptions::default())?;
1426 /// let saved = Document::from_bytes(bytes)?;
1427 /// assert!(saved.page(0)?.text().to_string().contains("drawn"));
1428 /// # Ok::<(), pdfrum::Error>(())
1429 /// ```
1430 ///
1431 /// # Errors
1432 ///
1433 /// Whatever `body` refused to draw — a character the font has no glyph
1434 /// for, most often — and [`Error::InlinePage`] for a page with no
1435 /// object of its own. Nothing is written when the drawing failed.
1436 pub fn draw_page(
1437 &mut self,
1438 index: impl Into<PageIndex>,
1439 limits: &Limits,
1440 body: impl FnOnce(&mut Canvas<'_, '_>),
1441 ) -> Result<()> {
1442 self.draw_page_with_fonts(
1443 index,
1444 limits,
1445 #[cfg(feature = "svg-text")]
1446 &crate::svg_text::SvgFonts::new(),
1447 body,
1448 )
1449 }
1450
1451 /// [`EditDoc::draw_page`] with the faces an ingested SVG's `<text>` is set
1452 /// in; without them a `<text>` draws nothing and is reported instead.
1453 ///
1454 /// # Errors
1455 ///
1456 /// As [`EditDoc::draw_page`].
1457 pub fn draw_page_with_fonts(
1458 &mut self,
1459 index: impl Into<PageIndex>,
1460 limits: &Limits,
1461 #[cfg(feature = "svg-text")] fonts: &crate::svg_text::SvgFonts,
1462 body: impl FnOnce(&mut Canvas<'_, '_>),
1463 ) -> Result<()> {
1464 let index = index.into();
1465 let Some((reference, dict, resources)) = self
1466 .page_state(index)
1467 .map_err(|_| Error::PageIndexOutOfRange(index))?
1468 else {
1469 return Err(Error::InlinePage(index));
1470 };
1471 let mut diags = Diagnostics::default();
1472 let (to_page, size) = self.canvas_space(reference, &dict, &mut diags);
1473
1474 let taken = existing_names(&resources, self);
1475 let mut canvas = Canvas {
1476 out: String::new(),
1477 limits: limits.clone(),
1478 #[cfg(feature = "svg-text")]
1479 fonts: fonts.clone(),
1480 edit: self,
1481 added: Vec::new(),
1482 taken,
1483 size,
1484 surface: Surface::Page(index),
1485 failed: None,
1486 };
1487 body(&mut canvas);
1488 if let Some(error) = canvas.failed {
1489 return Err(error);
1490 }
1491 let Canvas { out, added, .. } = canvas;
1492 if out.is_empty() {
1493 return Ok(());
1494 }
1495
1496 // `q` … `Q` around the whole drawing, with the canvas-to-page
1497 // transform inside it, so neither state escapes into the other.
1498 let mut bytes = String::with_capacity(out.len() + 64);
1499 bytes.push_str("q\n");
1500 write_matrix(&mut bytes, to_page);
1501 bytes.push_str(" cm\n");
1502 bytes.push_str(&out);
1503 bytes.push_str("Q\n");
1504
1505 self.append_stream(reference, &dict, &resources, bytes.as_bytes(), &added);
1506 Ok(())
1507 }
1508
1509 /// Draw on every page, one canvas each.
1510 ///
1511 /// The closure runs once per page in order and is handed that page's own
1512 /// canvas, so [`Canvas::size`] and [`Canvas::page`] are the page's. A page
1513 /// written inline in its parent's `/Kids` is skipped rather than refused:
1514 /// a whole-document watermark should not fail because one page of a
1515 /// thousand cannot carry it.
1516 ///
1517 /// ```
1518 /// use pdfrum::{Color, Document, SaveOptions, Stroke, Point};
1519 ///
1520 /// let doc = Document::open("tests/fixtures/hello_world_2_pages.pdf")?;
1521 /// let mut edit = doc.edit();
1522 /// edit.draw_pages(|c| {
1523 /// let y = c.size().height - 40.0;
1524 /// c.line(Point::new(40.0, y), Point::new(c.size().width - 40.0, y),
1525 /// Stroke::new(Color::BLACK, 0.5));
1526 /// })?;
1527 /// let mut bytes = Vec::new();
1528 /// edit.write_to(&mut bytes, &SaveOptions::default())?;
1529 /// assert!(bytes.starts_with(b"%PDF-"));
1530 /// # Ok::<(), pdfrum::Error>(())
1531 /// ```
1532 ///
1533 /// # Errors
1534 ///
1535 /// As [`EditDoc::draw_page`], for the first page whose drawing failed.
1536 pub fn draw_pages(
1537 &mut self,
1538 limits: &Limits,
1539 body: impl FnMut(&mut Canvas<'_, '_>),
1540 ) -> Result<()> {
1541 self.draw_pages_with_fonts(
1542 limits,
1543 #[cfg(feature = "svg-text")]
1544 &crate::svg_text::SvgFonts::new(),
1545 body,
1546 )
1547 }
1548
1549 /// [`EditDoc::draw_pages`] with the faces an ingested SVG's `<text>` is
1550 /// set in.
1551 ///
1552 /// # Errors
1553 ///
1554 /// As [`EditDoc::draw_page`], for the first page whose drawing failed.
1555 pub fn draw_pages_with_fonts(
1556 &mut self,
1557 limits: &Limits,
1558 #[cfg(feature = "svg-text")] fonts: &crate::svg_text::SvgFonts,
1559 mut body: impl FnMut(&mut Canvas<'_, '_>),
1560 ) -> Result<()> {
1561 for index in 0..self.base().page_count() {
1562 let index = PageIndex::from(index);
1563 if self
1564 .page_state(index)
1565 .map_err(|_| Error::PageIndexOutOfRange(index))?
1566 .is_none()
1567 {
1568 continue;
1569 }
1570 self.draw_page_with_fonts(
1571 index,
1572 limits,
1573 #[cfg(feature = "svg-text")]
1574 fonts,
1575 &mut body,
1576 )?;
1577 }
1578 Ok(())
1579 }
1580
1581 /// The canvas-to-page transform and the displayed size for page `index`.
1582 ///
1583 /// The transform is the inverse of the renderer's own display matrix over
1584 /// the crop box, which is the whole of the coordinate-space composition:
1585 /// the crop box's offset and the `/Rotate` quarter turn fall out of it
1586 /// together, and there is nothing else to get right.
1587 fn canvas_space(
1588 &self,
1589 reference: ObjRef,
1590 dict: &Dict,
1591 diags: &mut Diagnostics,
1592 ) -> (Affine, kurbo::Size) {
1593 // The dictionary is the session's, read through the overlay, and the
1594 // inheritance walk goes through the overlay too — so a `/Rotate` or a
1595 // `/CropBox` this same session set is what the canvas is built on,
1596 // rather than the base document's stale one.
1597 let page = pdfrum_parser::PageDict {
1598 dict: dict.clone(),
1599 reference: Some(reference),
1600 };
1601 let (_, crop) =
1602 pdfrum_page::derive_boxes(&page.dict, |key| page.inherited(key, self), self, diags);
1603 let rotate_key = Name::from("Rotate");
1604 let rotate = pdfrum_page::Rotation::from_degrees(
1605 page.dict
1606 .raw(&rotate_key)
1607 .cloned()
1608 .or_else(|| page.inherited(&rotate_key, self))
1609 .and_then(|value| value.resolve(self).ok()?.get().as_int())
1610 .unwrap_or(0),
1611 );
1612 let size = if rotate.quarters().is_multiple_of(2) {
1613 kurbo::Size::new(crop.width(), crop.height())
1614 } else {
1615 kurbo::Size::new(crop.height(), crop.width())
1616 };
1617 (rotate.display_matrix(crop).inverse(), size)
1618 }
1619
1620 /// Append `bytes` as one more content stream of the page `reference`
1621 /// names, merging `added` into its `/Resources`.
1622 fn append_stream(
1623 &mut self,
1624 reference: ObjRef,
1625 dict: &Dict,
1626 resources: &Dict,
1627 bytes: &[u8],
1628 added: &[(&'static Name, Name, Object)],
1629 ) {
1630 let stream = Stream::new(
1631 Dict::from_pairs([(
1632 pdf_names::LENGTH.clone(),
1633 Object::Int(i64::try_from(bytes.len()).unwrap_or(0)),
1634 )]),
1635 ByteSpan::from(bytes.to_vec()),
1636 );
1637 let fresh = (*self).add(Object::Stream(Box::new(stream)));
1638
1639 let shape = ContentsShape::read(dict, self);
1640 let (_, next) = shape.with_added(fresh);
1641 let shared = crate::shared_objects(self);
1642
1643 let mut dict = dict.clone();
1644 // The `/Contents` array: reused when the page owns it outright,
1645 // otherwise a fresh one, exactly as `apply_rewrite` decides it.
1646 let elements = next.elements();
1647 let array = Object::Array(Array::of(elements.iter().map(|e| Object::Ref(*e))));
1648 let reusable = matches!(
1649 dict.raw(pdf_names::CONTENTS),
1650 Some(Object::Ref(r)) if !shared.contains(&r.num) && !elements.contains(r)
1651 );
1652 let contents = match dict.raw(pdf_names::CONTENTS) {
1653 Some(Object::Ref(existing)) if reusable => {
1654 let existing = *existing;
1655 (*self).replace(existing, array);
1656 Object::Ref(existing)
1657 }
1658 _ => Object::Ref((*self).add(array)),
1659 };
1660 dict = with_key(&dict, pdf_names::CONTENTS, contents);
1661
1662 let merged = merge_resources(resources, added);
1663 match dict.raw(pdf_names::RESOURCES) {
1664 // The page reaches its resources through an object it does not
1665 // share: write through it and leave the page's key alone.
1666 Some(Object::Ref(existing)) if !shared.contains(&existing.num) => {
1667 let existing = *existing;
1668 (*self).replace(existing, Object::Dict(merged));
1669 }
1670 // Shared, inline or absent: the page gets its own copy, so
1671 // drawing on one page cannot change another.
1672 _ => dict = with_key(&dict, pdf_names::RESOURCES, Object::Dict(merged)),
1673 }
1674
1675 (*self).replace(reference, Object::Dict(dict));
1676 }
1677}
1678
1679/// Every name the page's `/Resources` already uses, per category, so a fresh
1680/// one is chosen against them rather than merely hoped to differ.
1681fn existing_names(resources: &Dict, r: &impl Resolve) -> Vec<(&'static Name, Name)> {
1682 let mut taken = Vec::new();
1683 for category in [pdf_names::FONT, pdf_names::XOBJECT, pdf_names::EXT_G_STATE] {
1684 let Some(sub) = resources.dict(category, r) else {
1685 continue;
1686 };
1687 for (name, _) in sub.iter() {
1688 taken.push((category, name.clone()));
1689 }
1690 }
1691 taken
1692}
1693
1694/// `resources` with `added` merged in, each under the category it belongs to.
1695///
1696/// Every other key — colour spaces, patterns, `/ProcSet` — is carried through
1697/// untouched: the canvas emits no operator that would name one.
1698fn merge_resources(resources: &Dict, added: &[(&'static Name, Name, Object)]) -> Dict {
1699 let mut out = Dict::new();
1700 for (key, value) in resources.iter() {
1701 let extra: Vec<_> = added
1702 .iter()
1703 .filter(|(category, _, _)| *category == key)
1704 .collect();
1705 if extra.is_empty() {
1706 out.push(key.clone(), value.clone());
1707 continue;
1708 }
1709 // A category the page already has: keep every entry and add ours.
1710 // The sub-dictionary may be indirect; it is inlined here rather than
1711 // written through, because the object could be shared with a page
1712 // this drawing is not touching.
1713 let mut sub = match value {
1714 Object::Dict(dict) => dict.clone(),
1715 _ => Dict::new(),
1716 };
1717 for (_, name, held) in extra {
1718 sub.push(name.clone(), held.clone());
1719 }
1720 out.push(key.clone(), Object::Dict(sub));
1721 }
1722 // The categories the drawing used that the page had none of. Taken from
1723 // `added` rather than from a fixed list of the categories a canvas
1724 // happens to mint today: a drawing that reaches for a new one — `sh`
1725 // brought `/Shading` — must not silently lose its resources, which is a
1726 // resource named in the stream and absent from `/Resources`, and so a
1727 // draw that does nothing at all.
1728 for (category, _, _) in added {
1729 if out.contains_key(category) {
1730 continue;
1731 }
1732 let mut sub = Dict::new();
1733 for (_, name, held) in added.iter().filter(|(cat, _, _)| cat == category) {
1734 sub.push(name.clone(), held.clone());
1735 }
1736 if !sub.is_empty() {
1737 out.push((*category).clone(), Object::Dict(sub));
1738 }
1739 }
1740 out
1741}
1742
1743/// A copy of `dict` with `key` set, keeping every other entry in its place.
1744fn with_key(dict: &Dict, key: &Name, value: Object) -> Dict {
1745 let mut out = Dict::new();
1746 let mut written = false;
1747 for (existing, held) in dict.iter() {
1748 if existing == key {
1749 if !written {
1750 out.push(existing.clone(), value.clone());
1751 written = true;
1752 }
1753 } else {
1754 out.push(existing.clone(), held.clone());
1755 }
1756 }
1757 if !written {
1758 out.push(key.clone(), value);
1759 }
1760 out
1761}