fop_render/pdf/document/page.rs
1//! PDF page operations
2//!
3//! Implements rendering operations on individual PDF pages.
4
5use fop_types::Length;
6
7use crate::pdf::font::FontManager;
8
9use super::types::{LinkAnnotation, LinkDestination, PdfPage};
10
11/// Build PDF character spacing (Tc) and word spacing (Tw) operator strings.
12///
13/// Returns a string with the appropriate operators for inclusion in a BT...ET block.
14/// Non-zero spacing values produce "Tc" or "Tw" lines; zero values are omitted.
15pub(super) fn build_spacing_ops(
16 letter_spacing: Option<Length>,
17 word_spacing: Option<Length>,
18) -> String {
19 let mut ops = String::new();
20 if let Some(ls) = letter_spacing {
21 let pt = ls.to_pt();
22 if pt.abs() > 0.0001 {
23 ops.push_str(&format!(
24 "{:.4} Tc
25",
26 pt
27 ));
28 }
29 }
30 if let Some(ws) = word_spacing {
31 let pt = ws.to_pt();
32 if pt.abs() > 0.0001 {
33 ops.push_str(&format!(
34 "{:.4} Tw
35",
36 pt
37 ));
38 }
39 }
40 ops
41}
42
43impl PdfPage {
44 /// Create a new PDF page
45 pub fn new(width: Length, height: Length) -> Self {
46 Self {
47 width,
48 height,
49 content: Vec::new(),
50 link_annotations: Vec::new(),
51 }
52 }
53
54 /// Add a link annotation to the page
55 ///
56 /// # Arguments
57 /// * `x` - X position (PDF coordinates: bottom-left origin)
58 /// * `y` - Y position (PDF coordinates: bottom-left origin)
59 /// * `width` - Width of the clickable area
60 /// * `height` - Height of the clickable area
61 /// * `destination` - Link destination (external URL or internal ID)
62 pub fn add_link_annotation(
63 &mut self,
64 x: Length,
65 y: Length,
66 width: Length,
67 height: Length,
68 destination: LinkDestination,
69 ) {
70 let rect = [
71 x.to_pt(),
72 y.to_pt(),
73 (x + width).to_pt(),
74 (y + height).to_pt(),
75 ];
76 self.link_annotations
77 .push(LinkAnnotation { rect, destination });
78 }
79
80 /// Encode text for PDF output using UTF-16BE for CID fonts
81 /// Uses UTF-16BE hex strings WITHOUT BOM (matches Java FOP StandardCharsets.UTF_16BE)
82 fn encode_pdf_text(text: &str) -> String {
83 // For CID fonts (Type 0), we use UTF-16BE encoding
84 // Java FOP: text.getBytes(StandardCharsets.UTF_16BE) - NO BOM!
85 // BOM should only be in ToUnicode CMap, not in content streams
86 let mut result = String::from("<");
87
88 // Encode each character as UTF-16BE (without BOM)
89 for c in text.chars() {
90 let code = c as u32;
91 if code <= 0xFFFF {
92 // BMP character (Basic Multilingual Plane)
93 result.push_str(&format!("{:04X}", code));
94 } else {
95 // Surrogate pair for non-BMP characters (above U+FFFF)
96 let code = code - 0x10000;
97 let high = 0xD800 + (code >> 10);
98 let low = 0xDC00 + (code & 0x3FF);
99 result.push_str(&format!("{:04X}{:04X}", high, low));
100 }
101 }
102
103 result.push('>');
104 result
105 }
106
107 /// Add text to the page using the default Helvetica font (F1)
108 pub fn add_text(&mut self, text: &str, x: Length, y: Length, font_size: Length) {
109 self.add_text_with_spacing(text, x, y, font_size, None, None);
110 }
111
112 /// Add text to the page using the default Helvetica font (F1) with optional letter/word spacing
113 ///
114 /// # Arguments
115 /// * `letter_spacing` - Optional character spacing in points (Tc operator)
116 /// * `word_spacing` - Optional word spacing in points (Tw operator)
117 pub fn add_text_with_spacing(
118 &mut self,
119 text: &str,
120 x: Length,
121 y: Length,
122 font_size: Length,
123 letter_spacing: Option<Length>,
124 word_spacing: Option<Length>,
125 ) {
126 // Build optional Tc/Tw spacing operators
127 let spacing_ops = build_spacing_ops(letter_spacing, word_spacing);
128 let ops = format!(
129 "BT\n/F1 {} Tf\n{}{} {} Td\n({}) Tj\nET\n",
130 font_size.to_pt(),
131 spacing_ops,
132 x.to_pt(),
133 y.to_pt(),
134 text
135 );
136 self.content.extend_from_slice(ops.as_bytes());
137 }
138
139 /// Add text to the page using a custom embedded font
140 ///
141 /// # Arguments
142 /// * `text` - The text to display
143 /// * `x` - X position
144 /// * `y` - Y position
145 /// * `font_size` - Font size
146 /// * `font_index` - Index of the embedded font (from `embed_font`)
147 ///
148 /// Note: Character usage tracking must be done separately via FontManager::record_text
149 pub fn add_text_with_font(
150 &mut self,
151 text: &str,
152 x: Length,
153 y: Length,
154 font_size: Length,
155 font_index: usize,
156 ) {
157 self.add_text_with_font_and_spacing(text, x, y, font_size, font_index, None, None);
158 }
159
160 /// Add text to the page using a custom embedded font with optional letter/word spacing
161 ///
162 /// # Arguments
163 /// * `text` - The text to display
164 /// * `x` - X position
165 /// * `y` - Y position
166 /// * `font_size` - Font size
167 /// * `font_index` - Index of the embedded font (from `embed_font`)
168 /// * `letter_spacing` - Optional character spacing in points (Tc operator)
169 /// * `word_spacing` - Optional word spacing in points (Tw operator)
170 #[allow(clippy::too_many_arguments)]
171 pub fn add_text_with_font_and_spacing(
172 &mut self,
173 text: &str,
174 x: Length,
175 y: Length,
176 font_size: Length,
177 font_index: usize,
178 letter_spacing: Option<Length>,
179 word_spacing: Option<Length>,
180 ) {
181 // Custom fonts are F2, F3, F4, etc. (F1 is reserved for Helvetica)
182 let font_name = format!("F{}", font_index + 2);
183
184 // Encode text for PDF - use hex strings for Unicode characters
185 let encoded_text = Self::encode_pdf_text(text);
186
187 // Build optional Tc/Tw spacing operators
188 let spacing_ops = build_spacing_ops(letter_spacing, word_spacing);
189
190 let ops = format!(
191 "BT\n/{} {} Tf\n{}{} {} Td\n{} Tj\nET\n",
192 font_name,
193 font_size.to_pt(),
194 spacing_ops,
195 x.to_pt(),
196 y.to_pt(),
197 encoded_text
198 );
199 self.content.extend_from_slice(ops.as_bytes());
200 }
201
202 /// Add text to the page using a custom embedded font and track character usage
203 ///
204 /// This is a convenience method that both adds the text and records character usage
205 /// for subsetting.
206 ///
207 /// # Arguments
208 /// * `text` - The text to display
209 /// * `x` - X position
210 /// * `y` - Y position
211 /// * `font_size` - Font size
212 /// * `font_index` - Index of the embedded font (from `embed_font`)
213 /// * `font_manager` - FontManager to record character usage
214 pub fn add_text_with_font_tracked(
215 &mut self,
216 text: &str,
217 x: Length,
218 y: Length,
219 font_size: Length,
220 font_index: usize,
221 font_manager: &mut FontManager,
222 ) {
223 // Record character usage for subsetting
224 font_manager.record_text(font_index, text);
225
226 // Add the text to the page
227 self.add_text_with_font(text, x, y, font_size, font_index);
228 }
229
230 /// Add background color to an area
231 pub fn add_background(
232 &mut self,
233 x: Length,
234 y: Length,
235 width: Length,
236 height: Length,
237 color: fop_types::Color,
238 ) {
239 self.add_background_with_radius(x, y, width, height, color, None);
240 }
241
242 /// Add background color to an area with optional rounded corners
243 pub fn add_background_with_radius(
244 &mut self,
245 x: Length,
246 y: Length,
247 width: Length,
248 height: Length,
249 color: fop_types::Color,
250 border_radius: Option<[Length; 4]>,
251 ) {
252 use crate::pdf::graphics::PdfGraphics;
253 let mut graphics = PdfGraphics::new();
254 let _ = graphics.set_fill_color(color);
255 let _ = graphics.fill_rectangle_with_radius(x, y, width, height, border_radius);
256 self.content
257 .extend_from_slice(graphics.content().as_bytes());
258 }
259
260 /// Add background color with opacity to an area with optional rounded corners
261 ///
262 /// # Arguments
263 /// * `x, y` - Bottom-left corner of the area (PDF coordinates)
264 /// * `width, height` - Dimensions of the area
265 /// * `color` - Fill color
266 /// * `border_radius` - Optional corner radii
267 /// * `gs_index` - Index of the ExtGState resource for opacity
268 #[allow(clippy::too_many_arguments)]
269 pub fn add_background_with_opacity(
270 &mut self,
271 x: Length,
272 y: Length,
273 width: Length,
274 height: Length,
275 color: fop_types::Color,
276 border_radius: Option<[Length; 4]>,
277 gs_index: usize,
278 ) {
279 use crate::pdf::graphics::PdfGraphics;
280 let mut graphics = PdfGraphics::new();
281 let _ = graphics.set_opacity(&format!("GS{}", gs_index));
282 let _ = graphics.set_fill_color(color);
283 let _ = graphics.fill_rectangle_with_radius(x, y, width, height, border_radius);
284 self.content
285 .extend_from_slice(graphics.content().as_bytes());
286 }
287
288 /// Add gradient background to an area
289 ///
290 /// # Arguments
291 /// * `x, y` - Bottom-left corner of the area (PDF coordinates)
292 /// * `width, height` - Dimensions of the area
293 /// * `gradient_index` - Index of the gradient in the document's gradient list
294 pub fn add_gradient_background(
295 &mut self,
296 x: Length,
297 y: Length,
298 width: Length,
299 height: Length,
300 gradient_index: usize,
301 ) {
302 self.add_gradient_background_with_radius(x, y, width, height, gradient_index, None);
303 }
304
305 /// Add gradient background to an area with optional rounded corners
306 ///
307 /// # Arguments
308 /// * `x, y` - Bottom-left corner of the area (PDF coordinates)
309 /// * `width, height` - Dimensions of the area
310 /// * `gradient_index` - Index of the gradient in the document's gradient list
311 /// * `border_radius` - Optional corner radii [top-left, top-right, bottom-right, bottom-left]
312 pub fn add_gradient_background_with_radius(
313 &mut self,
314 x: Length,
315 y: Length,
316 width: Length,
317 height: Length,
318 gradient_index: usize,
319 border_radius: Option<[Length; 4]>,
320 ) {
321 use crate::pdf::graphics::PdfGraphics;
322 let mut graphics = PdfGraphics::new();
323 let _ =
324 graphics.fill_gradient_with_radius(x, y, width, height, gradient_index, border_radius);
325 self.content
326 .extend_from_slice(graphics.content().as_bytes());
327 }
328
329 /// Add borders to an area
330 #[allow(clippy::too_many_arguments)]
331 pub fn add_borders(
332 &mut self,
333 x: Length,
334 y: Length,
335 width: Length,
336 height: Length,
337 border_widths: [Length; 4],
338 border_colors: [fop_types::Color; 4],
339 border_styles: [fop_layout::area::BorderStyle; 4],
340 ) {
341 self.add_borders_with_radius(
342 x,
343 y,
344 width,
345 height,
346 border_widths,
347 border_colors,
348 border_styles,
349 None,
350 );
351 }
352
353 /// Add borders to an area with optional rounded corners
354 #[allow(clippy::too_many_arguments)]
355 pub fn add_borders_with_radius(
356 &mut self,
357 x: Length,
358 y: Length,
359 width: Length,
360 height: Length,
361 border_widths: [Length; 4],
362 border_colors: [fop_types::Color; 4],
363 border_styles: [fop_layout::area::BorderStyle; 4],
364 border_radius: Option<[Length; 4]>,
365 ) {
366 use crate::pdf::graphics::PdfGraphics;
367 let mut graphics = PdfGraphics::new();
368 let _ = graphics.draw_borders_with_radius(
369 x,
370 y,
371 width,
372 height,
373 border_widths,
374 border_colors,
375 border_styles,
376 border_radius,
377 );
378 self.content
379 .extend_from_slice(graphics.content().as_bytes());
380 }
381
382 /// Add borders with opacity to an area with optional rounded corners
383 #[allow(clippy::too_many_arguments)]
384 pub fn add_borders_with_opacity(
385 &mut self,
386 x: Length,
387 y: Length,
388 width: Length,
389 height: Length,
390 border_widths: [Length; 4],
391 border_colors: [fop_types::Color; 4],
392 border_styles: [fop_layout::area::BorderStyle; 4],
393 border_radius: Option<[Length; 4]>,
394 gs_index: usize,
395 ) {
396 use crate::pdf::graphics::PdfGraphics;
397 let mut graphics = PdfGraphics::new();
398 let _ = graphics.set_stroke_opacity(&format!("GS{}", gs_index));
399 let _ = graphics.draw_borders_with_radius(
400 x,
401 y,
402 width,
403 height,
404 border_widths,
405 border_colors,
406 border_styles,
407 border_radius,
408 );
409 self.content
410 .extend_from_slice(graphics.content().as_bytes());
411 }
412
413 /// Add an image to the page
414 ///
415 /// # Arguments
416 /// * `image_index` - The index of the image XObject in the document's image_xobjects list
417 /// * `x` - X position in PDF coordinates (bottom-left origin)
418 /// * `y` - Y position in PDF coordinates (bottom-left origin)
419 /// * `width` - Display width
420 /// * `height` - Display height
421 pub fn add_image(
422 &mut self,
423 image_index: usize,
424 x: Length,
425 y: Length,
426 width: Length,
427 height: Length,
428 ) {
429 use std::fmt::Write;
430 let mut ops = String::new();
431
432 // Save graphics state
433 let _ = writeln!(&mut ops, "q");
434
435 // Set up transformation matrix: translate, then scale
436 // PDF images are 1x1 unit square by default, so we scale to width/height
437 let _ = writeln!(
438 &mut ops,
439 "{:.3} 0 0 {:.3} {:.3} {:.3} cm",
440 width.to_pt(),
441 height.to_pt(),
442 x.to_pt(),
443 y.to_pt()
444 );
445
446 // Draw the image
447 let _ = writeln!(&mut ops, "/Im{} Do", image_index);
448
449 // Restore graphics state
450 let _ = writeln!(&mut ops, "Q");
451
452 self.content.extend_from_slice(ops.as_bytes());
453 }
454
455 /// Add a horizontal rule (line) to the page
456 ///
457 /// # Arguments
458 /// * `x` - Left edge x-coordinate
459 /// * `y` - Bottom edge y-coordinate (PDF coordinate system)
460 /// * `width` - Rule width
461 /// * `thickness` - Line thickness
462 /// * `color` - Line color
463 /// * `style` - Line style (solid, dashed, dotted)
464 pub fn add_rule(
465 &mut self,
466 x: Length,
467 y: Length,
468 width: Length,
469 thickness: Length,
470 color: fop_types::Color,
471 style: &str,
472 ) {
473 use std::fmt::Write;
474 let mut ops = String::new();
475
476 // Set stroke color
477 let _ = writeln!(
478 &mut ops,
479 "{:.3} {:.3} {:.3} RG",
480 color.r_f32(),
481 color.g_f32(),
482 color.b_f32()
483 );
484
485 // Set line width
486 let _ = writeln!(&mut ops, "{:.3} w", thickness.to_pt());
487
488 // Set dash pattern based on style
489 match style {
490 "dashed" => {
491 let _ = writeln!(&mut ops, "[6 3] 0 d");
492 }
493 "dotted" => {
494 let _ = writeln!(&mut ops, "[1 2] 0 d");
495 }
496 _ => {
497 // solid or unknown - use solid line
498 let _ = writeln!(&mut ops, "[] 0 d");
499 }
500 }
501
502 // Draw the line (move to start, line to end, stroke)
503 let _ = writeln!(
504 &mut ops,
505 "{:.3} {:.3} m {:.3} {:.3} l S",
506 x.to_pt(),
507 y.to_pt(),
508 (x + width).to_pt(),
509 y.to_pt()
510 );
511
512 self.content.extend_from_slice(ops.as_bytes());
513 }
514
515 /// Save graphics state and set clipping path
516 ///
517 /// This method saves the current graphics state and establishes a rectangular
518 /// clipping path. Content drawn after this call will be clipped to the specified
519 /// rectangle until restore_clip_state() is called.
520 ///
521 /// PDF operators used:
522 /// - q: Save graphics state
523 /// - re: Rectangle path
524 /// - W: Set clipping path (intersect with current path)
525 /// - n: End path without stroking or filling
526 ///
527 /// # Arguments
528 /// * `x, y` - Bottom-left corner of clipping rectangle (PDF coordinates)
529 /// * `width, height` - Dimensions of clipping rectangle
530 ///
531 /// # PDF Reference
532 /// See PDF specification section 8.5 for clipping path details.
533 pub fn save_clip_state(
534 &mut self,
535 x: Length,
536 y: Length,
537 width: Length,
538 height: Length,
539 ) -> fop_types::Result<()> {
540 use std::fmt::Write;
541 let mut ops = String::new();
542
543 // Save graphics state
544 writeln!(&mut ops, "q").map_err(|e| fop_types::FopError::Generic(e.to_string()))?;
545
546 // Define rectangle path and set as clipping path
547 writeln!(
548 &mut ops,
549 "{:.3} {:.3} {:.3} {:.3} re W n",
550 x.to_pt(),
551 y.to_pt(),
552 width.to_pt(),
553 height.to_pt()
554 )
555 .map_err(|e| fop_types::FopError::Generic(e.to_string()))?;
556
557 self.content.extend_from_slice(ops.as_bytes());
558 Ok(())
559 }
560
561 /// Restore graphics state after clipping
562 ///
563 /// This restores the graphics state that was saved by save_clip_state(),
564 /// removing the clipping path.
565 ///
566 /// PDF operator used:
567 /// - Q: Restore graphics state
568 pub fn restore_clip_state(&mut self) -> fop_types::Result<()> {
569 use std::fmt::Write;
570 let mut ops = String::new();
571
572 writeln!(&mut ops, "Q").map_err(|e| fop_types::FopError::Generic(e.to_string()))?;
573
574 self.content.extend_from_slice(ops.as_bytes());
575 Ok(())
576 }
577}