1use crate::error::Result;
14use crate::font_bridge::font_variant_key;
15use std::collections::HashMap;
16use xfa_layout_engine::form::{DrawContent, FieldKind, FormNodeStyle, RichTextSpan};
17use xfa_layout_engine::layout::{LayoutContent, LayoutDom, LayoutNode, LayoutPage};
18use xfa_layout_engine::text::{FontFamily, FontMetrics};
19use xfa_layout_engine::types::{TextAlign, VerticalAlign};
20
21#[derive(Debug, Clone)]
23pub struct XfaRenderConfig {
24 pub default_font: String,
26 pub default_font_size: f64,
28 pub draw_borders: bool,
30 pub border_width: f64,
32 pub border_color: [f64; 3],
34 pub text_color: [f64; 3],
36 pub background_color: Option<[f64; 3]>,
38 pub text_padding: f64,
40 pub font_map: HashMap<String, String>,
42 pub font_metrics_data: HashMap<String, FontMetricsData>,
44 pub check_button_mark: Option<String>,
46 pub field_values_only: bool,
51}
52
53#[derive(Debug, Clone)]
55pub struct FontMetricsData {
56 pub widths: Vec<u16>,
58 pub upem: u16,
60 pub ascender: i16,
62 pub descender: i16,
64 pub font_data: Option<Vec<u8>>,
66 pub face_index: u32,
68 pub simple_unicode_to_code: Option<HashMap<u16, u8>>,
74}
75
76#[derive(Debug, Clone)]
78pub struct ImageInfo {
79 pub name: String,
81 pub data: Vec<u8>,
83 pub mime_type: String,
85}
86
87#[derive(Debug, Clone)]
89pub struct PageOverlay {
90 pub content_stream: Vec<u8>,
92 pub images: Vec<ImageInfo>,
94}
95
96impl Default for XfaRenderConfig {
97 fn default() -> Self {
98 Self {
99 default_font: "Helvetica".to_string(),
100 default_font_size: 10.0,
101 draw_borders: true,
102 border_width: 1.0, border_color: [0.0, 0.0, 0.0],
106 text_color: [0.0, 0.0, 0.0],
107 background_color: None,
108 text_padding: xfa_layout_engine::types::DEFAULT_TEXT_PADDING,
109 font_map: HashMap::new(),
110 font_metrics_data: HashMap::new(),
111 check_button_mark: None,
112 field_values_only: false,
113 }
114 }
115}
116
117pub struct CoordinateMapper {
119 page_height: f64,
120 page_width: f64,
121}
122
123impl CoordinateMapper {
124 pub fn new(page_height: f64, page_width: f64) -> Self {
126 Self {
127 page_height,
128 page_width,
129 }
130 }
131
132 pub fn xfa_to_pdf_y(&self, xfa_y: f64, element_height: f64) -> f64 {
134 self.page_height - xfa_y - element_height
135 }
136
137 pub fn page_width(&self) -> f64 {
139 self.page_width
140 }
141}
142
143fn apply_node_style(config: &XfaRenderConfig, style: &FormNodeStyle) -> XfaRenderConfig {
146 let mut cfg = config.clone();
147
148 if let Some((r, g, b)) = style.bg_color {
149 cfg.background_color = Some([r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0]);
150 }
151
152 cfg.draw_borders = false;
153 if let Some(bw) = effective_border_width(style) {
159 cfg.border_width = bw;
160 cfg.draw_borders = true;
161 if let Some((r, g, b)) = style.border_color {
162 cfg.border_color = [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0];
163 }
164 }
165
166 if let Some((r, g, b)) = style.text_color {
167 cfg.text_color = [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0];
168 }
169
170 if let Some(mark) = &style.check_button_mark {
171 cfg.check_button_mark = Some(mark.clone());
172 }
173
174 cfg
175}
176
177fn effective_border_width(style: &FormNodeStyle) -> Option<f64> {
178 if let Some(bw) = style.border_width_pt.filter(|bw| *bw > 0.0) {
179 return Some(bw);
180 }
181
182 style
183 .border_widths
184 .as_ref()
185 .map(|widths| {
186 widths
187 .iter()
188 .zip(style.border_edges.iter())
189 .filter_map(|(width, visible)| (*visible && *width > 0.0).then_some(*width))
190 .fold(0.0, f64::max)
191 })
192 .filter(|bw| *bw > 0.0)
193}
194
195pub fn generate_page_overlay(page: &LayoutPage, config: &XfaRenderConfig) -> Result<PageOverlay> {
197 let mapper = CoordinateMapper::new(page.height, page.width);
198 let mut ops = Vec::new();
199 let mut images: Vec<ImageInfo> = Vec::new();
200 ops.extend_from_slice(b"q\n");
201 render_nodes(
202 &page.nodes,
203 0.0,
204 0.0,
205 &mapper,
206 config,
207 &mut ops,
208 &mut images,
209 );
210 ops.extend_from_slice(b"Q\n");
211 Ok(PageOverlay {
212 content_stream: ops,
213 images,
214 })
215}
216
217pub fn generate_all_overlays(
219 layout: &LayoutDom,
220 config: &XfaRenderConfig,
221) -> Result<Vec<PageOverlay>> {
222 layout
223 .pages
224 .iter()
225 .map(|page| generate_page_overlay(page, config))
226 .collect()
227}
228
229#[derive(Debug, Clone)]
239pub enum RenderNode {
240 Page {
242 width: f64,
244 height: f64,
246 children: Vec<RenderNode>,
248 },
249 Text {
251 x: f64,
253 y: f64,
255 content: String,
257 font: String,
259 size: f64,
261 },
262 Rect {
264 x: f64,
266 y: f64,
268 width: f64,
270 height: f64,
272 fill: Option<[u8; 3]>,
274 stroke: Option<[u8; 3]>,
276 },
277 Image {
279 x: f64,
281 y: f64,
283 width: f64,
285 height: f64,
287 data_len: usize,
289 },
290 Widget {
292 x: f64,
294 y: f64,
296 width: f64,
298 height: f64,
300 field_name: String,
302 value: String,
304 },
305 Group {
307 children: Vec<RenderNode>,
309 },
310}
311
312impl RenderNode {
313 fn fmt_indented(&self, buf: &mut String, depth: usize) {
315 let indent = " ".repeat(depth);
316 match self {
317 RenderNode::Page {
318 width,
319 height,
320 children,
321 } => {
322 buf.push_str(&format!("{indent}Page({width:.1}x{height:.1})\n"));
323 for c in children {
324 c.fmt_indented(buf, depth + 1);
325 }
326 }
327 RenderNode::Text {
328 x,
329 y,
330 content,
331 font,
332 size,
333 } => {
334 let preview: String = content.chars().take(40).collect();
335 let ellipsis = if content.len() > 40 { "…" } else { "" };
336 buf.push_str(&format!(
337 "{indent}Text({x:.1},{y:.1}) font={font} size={size:.1} \"{preview}{ellipsis}\"\n"
338 ));
339 }
340 RenderNode::Rect {
341 x,
342 y,
343 width,
344 height,
345 fill,
346 stroke,
347 } => {
348 let fill_str = fill
349 .map(|[r, g, b]| format!("fill=#{r:02X}{g:02X}{b:02X}"))
350 .unwrap_or_default();
351 let stroke_str = stroke
352 .map(|[r, g, b]| format!("stroke=#{r:02X}{g:02X}{b:02X}"))
353 .unwrap_or_default();
354 buf.push_str(&format!(
355 "{indent}Rect({x:.1},{y:.1} {width:.1}x{height:.1}) {fill_str} {stroke_str}\n"
356 ));
357 }
358 RenderNode::Image {
359 x,
360 y,
361 width,
362 height,
363 data_len,
364 } => {
365 buf.push_str(&format!(
366 "{indent}Image({x:.1},{y:.1} {width:.1}x{height:.1}) bytes={data_len}\n"
367 ));
368 }
369 RenderNode::Widget {
370 x,
371 y,
372 width,
373 height,
374 field_name,
375 value,
376 } => {
377 let preview: String = value.chars().take(30).collect();
378 buf.push_str(&format!(
379 "{indent}Widget({x:.1},{y:.1} {width:.1}x{height:.1}) name={field_name} value=\"{preview}\"\n"
380 ));
381 }
382 RenderNode::Group { children } => {
383 buf.push_str(&format!("{indent}Group\n"));
384 for c in children {
385 c.fmt_indented(buf, depth + 1);
386 }
387 }
388 }
389 }
390}
391
392#[derive(Debug, Clone)]
397pub struct RenderTree {
398 pub pages: Vec<RenderNode>,
400}
401
402impl RenderTree {
403 pub fn to_debug_string(&self) -> String {
405 let mut buf = String::new();
406 for page in &self.pages {
407 page.fmt_indented(&mut buf, 0);
408 }
409 buf
410 }
411}
412
413pub fn layout_dom_to_render_tree(layout: &LayoutDom, config: &XfaRenderConfig) -> RenderTree {
418 let pages = layout
419 .pages
420 .iter()
421 .map(|page| {
422 let mapper = CoordinateMapper::new(page.height, page.width);
423 let children = render_tree_nodes(&page.nodes, 0.0, 0.0, &mapper, config);
424 RenderNode::Page {
425 width: page.width,
426 height: page.height,
427 children,
428 }
429 })
430 .collect();
431 RenderTree { pages }
432}
433
434fn render_tree_nodes(
435 nodes: &[LayoutNode],
436 parent_x: f64,
437 parent_y: f64,
438 mapper: &CoordinateMapper,
439 config: &XfaRenderConfig,
440) -> Vec<RenderNode> {
441 let mut result = Vec::new();
442 for node in nodes {
443 let abs_x = node.rect.x + parent_x;
444 let abs_y = node.rect.y + parent_y;
445 let w = node.rect.width;
446 let h = node.rect.height;
447 let pdf_y = mapper.xfa_to_pdf_y(abs_y, h);
448 let node_cfg = apply_node_style(config, &node.style);
449
450 let fill = node_cfg.background_color.map(|c| {
452 [
453 (c[0] * 255.0) as u8,
454 (c[1] * 255.0) as u8,
455 (c[2] * 255.0) as u8,
456 ]
457 });
458 let stroke = if node_cfg.draw_borders && node_cfg.border_width > 0.0 {
459 let bc = node_cfg.border_color;
460 Some([
461 (bc[0] * 255.0) as u8,
462 (bc[1] * 255.0) as u8,
463 (bc[2] * 255.0) as u8,
464 ])
465 } else {
466 None
467 };
468 if fill.is_some() || stroke.is_some() {
469 result.push(RenderNode::Rect {
470 x: abs_x,
471 y: pdf_y,
472 width: w,
473 height: h,
474 fill,
475 stroke,
476 });
477 }
478
479 let leaf = match &node.content {
480 LayoutContent::Field {
481 value,
482 field_kind,
483 font_size,
484 font_family,
485 } => {
486 use xfa_layout_engine::form::FieldKind;
487 match field_kind {
488 FieldKind::Checkbox | FieldKind::Radio => Some(RenderNode::Widget {
489 x: abs_x,
490 y: pdf_y,
491 width: w,
492 height: h,
493 field_name: node.name.clone(),
494 value: value.clone(),
495 }),
496 _ => {
497 let font_ref = config
498 .font_map
499 .get(&font_bridge_key_for_tree(*font_family))
500 .cloned()
501 .unwrap_or_else(|| node_cfg.default_font.clone());
502 if !value.is_empty() {
503 Some(RenderNode::Text {
504 x: abs_x,
505 y: pdf_y,
506 content: value.clone(),
507 font: font_ref,
508 size: if *font_size > 0.0 {
509 *font_size
510 } else {
511 node_cfg.default_font_size
512 },
513 })
514 } else {
515 Some(RenderNode::Widget {
516 x: abs_x,
517 y: pdf_y,
518 width: w,
519 height: h,
520 field_name: node.name.clone(),
521 value: value.clone(),
522 })
523 }
524 }
525 }
526 }
527 LayoutContent::Text(text) => {
528 if text.is_empty() {
529 None
530 } else {
531 Some(RenderNode::Text {
532 x: abs_x,
533 y: pdf_y,
534 content: text.clone(),
535 font: node_cfg.default_font.clone(),
536 size: node_cfg.default_font_size,
537 })
538 }
539 }
540 LayoutContent::WrappedText {
541 lines, font_size, ..
542 } => {
543 if lines.is_empty() {
544 None
545 } else {
546 Some(RenderNode::Text {
547 x: abs_x,
548 y: pdf_y,
549 content: lines.join(" "),
550 font: node_cfg.default_font.clone(),
551 size: *font_size,
552 })
553 }
554 }
555 LayoutContent::Image { data, .. } => Some(RenderNode::Image {
556 x: abs_x,
557 y: pdf_y,
558 width: w,
559 height: h,
560 data_len: data.len(),
561 }),
562 LayoutContent::Draw(_) | LayoutContent::None => None,
563 };
564 if let Some(leaf_node) = leaf {
565 result.push(leaf_node);
566 }
567
568 if !node.children.is_empty() {
570 let child_x = abs_x + node.style.inset_left_pt.unwrap_or(0.0);
571 let child_y = abs_y + node.style.inset_top_pt.unwrap_or(0.0);
572 let child_nodes = render_tree_nodes(&node.children, child_x, child_y, mapper, config);
573 if !child_nodes.is_empty() {
574 result.push(RenderNode::Group {
575 children: child_nodes,
576 });
577 }
578 }
579 }
580 result
581}
582
583fn font_bridge_key_for_tree(family: xfa_layout_engine::text::FontFamily) -> String {
585 use xfa_layout_engine::text::FontFamily;
586 match family {
587 FontFamily::SansSerif => "sans-serif".to_string(),
588 FontFamily::Monospace => "monospace".to_string(),
589 FontFamily::Serif => "serif".to_string(),
590 }
591}
592
593pub fn generate_field_values_overlays(
597 layout: &LayoutDom,
598 config: &XfaRenderConfig,
599) -> Result<Vec<PageOverlay>> {
600 let mut fv_config = config.clone();
601 fv_config.field_values_only = true;
602 layout
603 .pages
604 .iter()
605 .map(|page| generate_page_overlay(page, &fv_config))
606 .collect()
607}
608
609fn render_nodes(
610 nodes: &[LayoutNode],
611 parent_x: f64,
612 parent_y: f64,
613 mapper: &CoordinateMapper,
614 config: &XfaRenderConfig,
615 ops: &mut Vec<u8>,
616 images: &mut Vec<ImageInfo>,
617) {
618 for node in nodes {
619 let abs_x = node.rect.x + parent_x;
620 let abs_y = node.rect.y + parent_y;
621 let w = node.rect.width;
622 let h = node.rect.height;
623 let pdf_y = mapper.xfa_to_pdf_y(abs_y, h);
624
625 let node_config = apply_node_style(config, &node.style);
626
627 let inset_l = node.style.inset_left_pt.unwrap_or(0.0);
631 let inset_t = node.style.inset_top_pt.unwrap_or(0.0);
632 let inset_r = node.style.inset_right_pt.unwrap_or(0.0);
633 let inset_b = node.style.inset_bottom_pt.unwrap_or(0.0);
634 let inner_w = (w - inset_l - inset_r).max(0.0);
635 let inner_h = (h - inset_t - inset_b).max(0.0);
636 let (caption_font_size, caption_font_family) =
637 caption_font_for_content(&node.content, &node.style, &node_config);
638 let is_button = matches!(
639 &node.content,
640 LayoutContent::Field {
641 field_kind: FieldKind::Button,
642 ..
643 }
644 );
645 let caption_reserve = if is_button {
646 0.0
647 } else {
648 effective_caption_reserve(
649 &node.style,
650 caption_font_size,
651 caption_font_family,
652 &node_config,
653 )
654 };
655
656 let (cap_dx, cap_dy, val_w, val_h) =
658 caption_value_offset(&node.style, caption_reserve, inner_w, inner_h);
659 let val_x = abs_x + inset_l + cap_dx;
660 let val_y_offset = inset_t + cap_dy;
661 let val_pdf_y = mapper.xfa_to_pdf_y(abs_y + val_y_offset, val_h);
662
663 if !matches!(node.content, LayoutContent::Field { .. }) && !config.field_values_only {
664 let border_radius = node.style.border_radius_pt.unwrap_or(0.0);
665 let border_style = node.style.border_style.as_deref();
666 let (bx, by, bw, bh) = if node.style.caption_text.is_some() {
669 (val_x, val_pdf_y, val_w, val_h)
670 } else {
671 let inner_pdf_y = mapper.xfa_to_pdf_y(abs_y + inset_t, inner_h);
672 (abs_x + inset_l, inner_pdf_y, inner_w, inner_h)
673 };
674 if let Some(bg) = &node_config.background_color {
675 write_ops(
676 ops,
677 format_args!("{:.3} {:.3} {:.3} rg\n", bg[0], bg[1], bg[2]),
678 );
679 emit_rect_path(ops, bx, by, bw, bh, border_radius);
680 ops.extend_from_slice(b"f\n");
681 }
682 if node_config.draw_borders && node_config.border_width > 0.0 && bw > 0.0 && bh > 0.0 {
683 let bwid = node_config.border_width;
684 let bc = node_config.border_color;
685 write_ops(
686 ops,
687 format_args!("{:.2} w\n{:.3} {:.3} {:.3} RG\n", bwid, bc[0], bc[1], bc[2]),
688 );
689 let per_edge = node.style.border_colors.map(|cs| {
690 cs.map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
691 });
692 let per_edge_widths = node.style.border_widths.as_ref();
693 apply_border_dash(ops, border_style);
694 let edges = node.style.border_edges;
695 if per_edge.is_some() || per_edge_widths.is_some() {
696 emit_individual_edges(
697 ops,
698 bx,
699 by,
700 bw,
701 bh,
702 &edges,
703 per_edge.as_ref(),
704 per_edge_widths,
705 bwid,
706 );
707 } else if edges[0] && edges[1] && edges[2] && edges[3] {
708 emit_rect_path(ops, bx, by, bw, bh, border_radius);
709 ops.extend_from_slice(b"S\n");
710 } else {
711 emit_individual_edges(ops, bx, by, bw, bh, &edges, None, None, bwid);
712 }
713 reset_border_dash(ops, border_style);
714 }
715 }
716
717 ops.extend_from_slice(b"q\n");
720 write_ops(
721 ops,
722 format_args!("{:.2} {:.2} {:.2} {:.2} re W n\n", abs_x, pdf_y, w, h),
723 );
724
725 let is_bold = node.style.font_weight.as_deref() == Some("bold");
726
727 let is_field = matches!(&node.content, LayoutContent::Field { .. });
731 let field_caption_needs_post_body_render =
732 is_field && matches!(node.style.caption_placement.as_deref(), Some("top"));
733 let caption_only = match &node.content {
737 LayoutContent::Field { value, .. } => value.is_empty(),
738 _ => false,
739 };
740 if node.style.caption_text.is_some()
741 && !is_button
742 && (!is_field || !field_caption_needs_post_body_render)
743 && !config.field_values_only
744 {
745 render_caption(
746 abs_x + inset_l,
747 mapper.xfa_to_pdf_y(abs_y + inset_t, inner_h),
748 inner_w,
749 inner_h,
750 caption_reserve,
751 caption_font_size,
752 caption_font_family,
753 &node.style,
754 &node_config,
755 ops,
756 caption_only,
757 );
758 }
759
760 match &node.content {
761 LayoutContent::Field {
762 value,
763 field_kind,
764 font_size,
765 font_family,
766 } => {
767 match field_kind {
768 FieldKind::Checkbox => render_checkbox(
769 val_x,
770 val_pdf_y,
771 val_w,
772 val_h,
773 value,
774 &node.style,
775 &node_config,
776 ops,
777 ),
778 FieldKind::Radio => render_radio(
779 val_x,
780 val_pdf_y,
781 val_w,
782 val_h,
783 value,
784 &node.style,
785 &node_config,
786 ops,
787 ),
788 FieldKind::Dropdown => render_dropdown(
789 val_x,
790 val_pdf_y,
791 val_w,
792 val_h,
793 value,
794 *font_size,
795 *font_family,
796 &node.style,
797 &node_config,
798 ops,
799 &node.display_items,
800 &node.save_items,
801 ),
802 FieldKind::Button => {
803 let label = if value.is_empty() {
804 node.style.caption_text.as_deref().unwrap_or("")
805 } else {
806 value
807 };
808 if !label.is_empty() {
809 render_button(
810 val_x,
811 val_pdf_y,
812 val_w,
813 val_h,
814 label,
815 *font_size,
816 *font_family,
817 &node.style,
818 &node_config,
819 ops,
820 )
821 }
822 }
823 FieldKind::PasswordEdit => {
824 let masked_value: String = value.chars().map(|_| '•').collect();
825 render_field(
826 val_x,
827 val_pdf_y,
828 val_w,
829 val_h,
830 &masked_value,
831 *font_size,
832 *font_family,
833 &node.style,
834 &node_config,
835 ops,
836 )
837 }
838 FieldKind::Signature => render_signature(
839 val_x,
840 val_pdf_y,
841 val_w,
842 val_h,
843 value,
844 &node.style,
845 &node_config,
846 ops,
847 ),
848 _ => {
849 let display_val = if node.style.format_pattern.is_some() {
850 crate::appearance_bridge::format_value(
851 value,
852 node.style.format_pattern.as_deref(),
853 )
854 } else if matches!(field_kind, FieldKind::NumericEdit) {
855 crate::appearance_bridge::format_numeric_default(value)
856 } else {
857 value.to_string()
858 };
859 render_field(
860 val_x,
861 val_pdf_y,
862 val_w,
863 val_h,
864 &display_val,
865 *font_size,
866 *font_family,
867 &node.style,
868 &node_config,
869 ops,
870 )
871 }
872 }
873
874 if node.style.caption_text.is_some()
875 && !is_button
876 && field_caption_needs_post_body_render
877 && !config.field_values_only
878 {
879 render_caption(
885 abs_x + inset_l,
886 mapper.xfa_to_pdf_y(abs_y + inset_t, inner_h),
887 inner_w,
888 inner_h,
889 caption_reserve,
890 caption_font_size,
891 caption_font_family,
892 &node.style,
893 &node_config,
894 ops,
895 value.is_empty(),
896 );
897 }
898 }
899 LayoutContent::Text(_) if config.field_values_only => {}
900 LayoutContent::Text(text) => {
901 let inner_pdf_y = mapper.xfa_to_pdf_y(abs_y + inset_t, inner_h);
902 render_text(
903 abs_x + inset_l,
904 inner_pdf_y,
905 inner_w,
906 inner_h,
907 text,
908 &node.style,
909 &node_config,
910 ops,
911 )
912 }
913 LayoutContent::WrappedText {
914 from_field: false, ..
915 } if config.field_values_only => {}
916 LayoutContent::WrappedText {
917 lines,
918 first_line_of_para,
919 font_size,
920 text_align,
921 font_family,
922 ..
923 } => {
924 let use_rich = node.style.rich_text_spans.as_ref().is_some_and(|spans| {
930 spans.len() > 1
931 || spans.iter().any(|s| {
932 s.font_size.is_some()
933 || s.font_family.is_some()
934 || s.font_weight.is_some()
935 || s.font_style.is_some()
936 || s.text_color.is_some()
937 || s.underline
938 })
939 });
940 if use_rich {
941 if let Some(ref spans) = node.style.rich_text_spans {
942 render_rich_multiline(
943 val_x,
944 val_w,
945 val_h,
946 lines,
947 first_line_of_para,
948 spans,
949 *font_size,
950 *text_align,
951 *font_family,
952 mapper,
953 abs_y + val_y_offset,
954 &node.style,
955 &node_config,
956 ops,
957 );
958 }
959 } else {
960 render_multiline(
961 val_x,
962 val_pdf_y,
963 val_w,
964 val_h,
965 lines,
966 first_line_of_para,
967 *font_size,
968 *text_align,
969 *font_family,
970 is_bold,
971 mapper,
972 abs_y + val_y_offset,
973 &node.style,
974 &node_config,
975 ops,
976 );
977 }
978 }
979 LayoutContent::Image { .. } if config.field_values_only => {}
980 LayoutContent::Image { data, mime_type } => {
981 let img_name = format!("XImg{}", images.len());
982 ops.extend(crate::image_bridge::render_image_ops(
983 &img_name, abs_x, pdf_y, w, h,
984 ));
985 images.push(ImageInfo {
986 name: img_name,
987 data: data.clone(),
988 mime_type: mime_type.clone(),
989 });
990 }
991 LayoutContent::Draw(_) if config.field_values_only => {}
992 LayoutContent::Draw(draw_content) => {
993 render_draw(
994 draw_content,
995 abs_x,
996 pdf_y,
997 w,
998 h,
999 &node.style,
1000 &node_config,
1001 ops,
1002 );
1003 }
1004 LayoutContent::None => {}
1005 }
1006
1007 ops.extend_from_slice(b"Q\n");
1009
1010 if !node.children.is_empty() {
1011 let child_origin_x = abs_x + node.style.inset_left_pt.unwrap_or(0.0);
1016 let child_origin_y = abs_y + node.style.inset_top_pt.unwrap_or(0.0);
1017 render_nodes(
1018 &node.children,
1019 child_origin_x,
1020 child_origin_y,
1021 mapper,
1022 config,
1023 ops,
1024 images,
1025 );
1026 }
1027 }
1028}
1029
1030fn emit_rect_path(ops: &mut Vec<u8>, x: f64, y: f64, w: f64, h: f64, radius: f64) {
1032 if radius <= 0.0 {
1033 write_ops(
1034 ops,
1035 format_args!("{:.2} {:.2} {:.2} {:.2} re\n", x, y, w, h),
1036 );
1037 } else {
1038 let r = radius.min(w / 2.0).min(h / 2.0);
1039 let k = r * 0.5522847498;
1040 write_ops(
1041 ops,
1042 format_args!(
1043 "{:.2} {:.2} m\n\
1044 {:.2} {:.2} l\n\
1045 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
1046 {:.2} {:.2} l\n\
1047 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
1048 {:.2} {:.2} l\n\
1049 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
1050 {:.2} {:.2} l\n\
1051 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
1052 h\n",
1053 x,
1054 y + r,
1055 x,
1056 y + h - r,
1057 x,
1058 y + h - r + k,
1059 x + r - k,
1060 y + h,
1061 x + r,
1062 y + h,
1063 x + w - r,
1064 y + h,
1065 x + w - r + k,
1066 y + h,
1067 x + w,
1068 y + h - r + k,
1069 x + w,
1070 y + h - r,
1071 x + w,
1072 y + r,
1073 x + w,
1074 y + r - k,
1075 x + w - r + k,
1076 y,
1077 x + w - r,
1078 y,
1079 x + r,
1080 y,
1081 x + r - k,
1082 y,
1083 x,
1084 y + r - k,
1085 x,
1086 y + r,
1087 ),
1088 );
1089 }
1090}
1091
1092#[allow(clippy::too_many_arguments)]
1094fn emit_individual_edges(
1095 ops: &mut Vec<u8>,
1096 x: f64,
1097 y: f64,
1098 w: f64,
1099 h: f64,
1100 edges: &[bool; 4],
1101 colors: Option<&[[f64; 3]; 4]>,
1102 widths: Option<&[f64; 4]>,
1103 default_width: f64,
1104) {
1105 if edges[0] {
1106 let ww = widths.map(|w| w[0]).unwrap_or(default_width);
1107 if let Some(c) = colors.map(|c| &c[0]) {
1108 write_ops(
1109 ops,
1110 format_args!("{:.2} w\n{:.3} {:.3} {:.3} RG\n", ww, c[0], c[1], c[2]),
1111 );
1112 } else {
1113 write_ops(ops, format_args!("{:.2} w\n", ww));
1114 }
1115 write_ops(
1116 ops,
1117 format_args!("{:.2} {:.2} m {:.2} {:.2} l S\n", x, y + h, x + w, y + h),
1118 );
1119 }
1120 if edges[1] {
1121 let ww = widths.map(|w| w[1]).unwrap_or(default_width);
1122 if let Some(c) = colors.map(|c| &c[1]) {
1123 write_ops(
1124 ops,
1125 format_args!("{:.2} w\n{:.3} {:.3} {:.3} RG\n", ww, c[0], c[1], c[2]),
1126 );
1127 } else {
1128 write_ops(ops, format_args!("{:.2} w\n", ww));
1129 }
1130 write_ops(
1131 ops,
1132 format_args!("{:.2} {:.2} m {:.2} {:.2} l S\n", x + w, y, x + w, y + h),
1133 );
1134 }
1135 if edges[2] {
1136 let ww = widths.map(|w| w[2]).unwrap_or(default_width);
1137 if let Some(c) = colors.map(|c| &c[2]) {
1138 write_ops(
1139 ops,
1140 format_args!("{:.2} w\n{:.3} {:.3} {:.3} RG\n", ww, c[0], c[1], c[2]),
1141 );
1142 } else {
1143 write_ops(ops, format_args!("{:.2} w\n", ww));
1144 }
1145 write_ops(
1146 ops,
1147 format_args!("{:.2} {:.2} m {:.2} {:.2} l S\n", x, y, x + w, y),
1148 );
1149 }
1150 if edges[3] {
1151 let ww = widths.map(|w| w[3]).unwrap_or(default_width);
1152 if let Some(c) = colors.map(|c| &c[3]) {
1153 write_ops(
1154 ops,
1155 format_args!("{:.2} w\n{:.3} {:.3} {:.3} RG\n", ww, c[0], c[1], c[2]),
1156 );
1157 } else {
1158 write_ops(ops, format_args!("{:.2} w\n", ww));
1159 }
1160 write_ops(
1161 ops,
1162 format_args!("{:.2} {:.2} m {:.2} {:.2} l S\n", x, y, x, y + h),
1163 );
1164 }
1165}
1166
1167fn apply_border_dash(ops: &mut Vec<u8>, style: Option<&str>) {
1168 match style {
1169 Some("dashed") => write_ops(ops, format_args!("[3 2] 0 d\n")),
1170 Some("dotted") => write_ops(ops, format_args!("[1 1] 0 d\n")),
1171 _ => {}
1172 }
1173}
1174
1175fn reset_border_dash(ops: &mut Vec<u8>, style: Option<&str>) {
1176 if matches!(style, Some("dashed") | Some("dotted")) {
1177 write_ops(ops, format_args!("[] 0 d\n"));
1178 }
1179}
1180
1181fn emit_3d_border(
1186 ops: &mut Vec<u8>,
1187 x: f64,
1188 y: f64,
1189 w: f64,
1190 h: f64,
1191 line_w: f64,
1192 style: Option<&str>,
1193) {
1194 let dark = [0.502, 0.502, 0.502]; let light = [0.831, 0.831, 0.831]; let (tl, br) = match style {
1197 Some("lowered") => (dark, light),
1198 _ => (light, dark), };
1200 write_ops(ops, format_args!("{:.2} w\n", line_w));
1201 write_ops(
1203 ops,
1204 format_args!(
1205 "{:.3} {:.3} {:.3} RG\n{:.2} {:.2} m {:.2} {:.2} l S\n",
1206 tl[0],
1207 tl[1],
1208 tl[2],
1209 x,
1210 y + h,
1211 x + w,
1212 y + h,
1213 ),
1214 );
1215 write_ops(
1217 ops,
1218 format_args!("{:.2} {:.2} m {:.2} {:.2} l S\n", x, y + h, x, y,),
1219 );
1220 write_ops(
1222 ops,
1223 format_args!(
1224 "{:.3} {:.3} {:.3} RG\n{:.2} {:.2} m {:.2} {:.2} l S\n",
1225 br[0],
1226 br[1],
1227 br[2],
1228 x,
1229 y,
1230 x + w,
1231 y,
1232 ),
1233 );
1234 write_ops(
1236 ops,
1237 format_args!("{:.2} {:.2} m {:.2} {:.2} l S\n", x + w, y, x + w, y + h,),
1238 );
1239}
1240
1241fn resolve_font_ref<'a>(
1246 font_map: &'a HashMap<String, String>,
1247 node_style: &FormNodeStyle,
1248 font_family: FontFamily,
1249) -> &'a str {
1250 if let Some(typeface) = &node_style.font_family {
1251 let vkey = font_variant_key(
1253 typeface,
1254 node_style.font_weight.as_deref(),
1255 node_style.font_style.as_deref(),
1256 );
1257 if let Some(mapped) = font_map.get(&vkey) {
1258 return mapped;
1259 }
1260 if let Some(mapped) = font_map.get(typeface) {
1262 return mapped;
1263 }
1264 }
1265 match font_family {
1266 FontFamily::Serif => "/F1",
1267 FontFamily::SansSerif => "/F2",
1268 FontFamily::Monospace => "/F3",
1269 }
1270}
1271
1272fn emit_text_style_ops(node_style: &FormNodeStyle, ops: &mut Vec<u8>) {
1275 if let Some(h_scale) = node_style.font_horizontal_scale {
1276 if (h_scale - 1.0).abs() > 0.001 {
1277 write_ops(ops, format_args!("{:.1} Tz\n", h_scale * 100.0));
1278 }
1279 }
1280 if let Some(spacing) = node_style.letter_spacing_pt {
1281 if spacing.abs() > 0.001 {
1282 write_ops(ops, format_args!("{:.3} Tc\n", spacing));
1283 }
1284 }
1285}
1286
1287fn is_bold_style(node_style: &FormNodeStyle) -> bool {
1289 node_style.font_weight.as_deref() == Some("bold")
1290}
1291
1292fn style_uses_real_bold_variant(
1295 font_map: &HashMap<String, String>,
1296 node_style: &FormNodeStyle,
1297) -> bool {
1298 if !is_bold_style(node_style) {
1299 return false;
1300 }
1301 let Some(typeface) = node_style.font_family.as_deref() else {
1302 return false;
1303 };
1304 let vkey = font_variant_key(
1305 typeface,
1306 node_style.font_weight.as_deref(),
1307 node_style.font_style.as_deref(),
1308 );
1309 font_map.contains_key(&vkey)
1310}
1311
1312fn emit_synthetic_bold_ops(
1316 font_map: &HashMap<String, String>,
1317 node_style: &FormNodeStyle,
1318 font_size: f64,
1319 text_color: &[f64; 3],
1320 ops: &mut Vec<u8>,
1321) {
1322 if is_bold_style(node_style) && !style_uses_real_bold_variant(font_map, node_style) {
1323 let stroke_w = font_size * 0.03;
1324 write_ops(
1325 ops,
1326 format_args!(
1327 "2 Tr\n{:.4} w\n{:.3} {:.3} {:.3} RG\n",
1328 stroke_w, text_color[0], text_color[1], text_color[2],
1329 ),
1330 );
1331 }
1332}
1333
1334fn reset_synthetic_bold_ops(
1336 font_map: &HashMap<String, String>,
1337 node_style: &FormNodeStyle,
1338 ops: &mut Vec<u8>,
1339) {
1340 if is_bold_style(node_style) && !style_uses_real_bold_variant(font_map, node_style) {
1341 write_ops(ops, format_args!("0 Tr\n"));
1342 }
1343}
1344
1345fn reset_text_style_ops(node_style: &FormNodeStyle, ops: &mut Vec<u8>) {
1347 if node_style
1348 .font_horizontal_scale
1349 .is_some_and(|s| (s - 1.0).abs() > 0.001)
1350 {
1351 write_ops(ops, format_args!("100 Tz\n"));
1352 }
1353 if node_style
1354 .letter_spacing_pt
1355 .is_some_and(|s| s.abs() > 0.001)
1356 {
1357 write_ops(ops, format_args!("0 Tc\n"));
1358 }
1359}
1360
1361fn ascender_pt(font_metrics: &FontMetrics, font_size: f64) -> f64 {
1363 if let (Some(asc), Some(upem)) = (font_metrics.resolved_ascender, font_metrics.resolved_upem) {
1364 if upem > 0 {
1365 return asc as f64 / upem as f64 * font_size;
1366 }
1367 }
1368 font_size
1369}
1370
1371fn build_font_metrics(
1376 font_size: f64,
1377 font_family: FontFamily,
1378 node_style: &FormNodeStyle,
1379 config: &XfaRenderConfig,
1380) -> FontMetrics {
1381 let mut metrics = FontMetrics {
1382 size: font_size,
1383 typeface: font_family,
1384 ..Default::default()
1385 };
1386 if let Some(typeface) = &node_style.font_family {
1387 let vkey = font_variant_key(
1388 typeface,
1389 node_style.font_weight.as_deref(),
1390 node_style.font_style.as_deref(),
1391 );
1392 let data = config
1393 .font_metrics_data
1394 .get(&vkey)
1395 .or_else(|| config.font_metrics_data.get(typeface));
1396 if let Some(data) = data {
1397 metrics.resolved_widths = Some(data.widths.clone());
1398 metrics.resolved_upem = Some(data.upem);
1399 metrics.resolved_ascender = Some(data.ascender);
1400 metrics.resolved_descender = Some(data.descender);
1401 }
1402 }
1403 metrics
1404}
1405
1406fn caption_font_for_content(
1407 content: &LayoutContent,
1408 node_style: &FormNodeStyle,
1409 config: &XfaRenderConfig,
1410) -> (f64, FontFamily) {
1411 match content {
1412 LayoutContent::Field {
1413 font_size,
1414 font_family,
1415 ..
1416 }
1417 | LayoutContent::WrappedText {
1418 font_size,
1419 font_family,
1420 ..
1421 } => (*font_size, *font_family),
1422 _ => (
1423 node_style.font_size.unwrap_or(config.default_font_size),
1424 FontFamily::SansSerif,
1425 ),
1426 }
1427}
1428
1429fn effective_caption_reserve(
1430 style: &FormNodeStyle,
1431 font_size: f64,
1432 font_family: FontFamily,
1433 config: &XfaRenderConfig,
1434) -> f64 {
1435 let caption_text = match style.caption_text.as_deref() {
1436 Some(text) if !text.is_empty() => text,
1437 _ => return 0.0,
1438 };
1439 if let Some(reserve) = style.caption_reserve {
1440 return reserve.max(0.0);
1441 }
1442 let fs = if font_size > 0.0 {
1443 font_size
1444 } else {
1445 config.default_font_size
1446 };
1447 let metrics = build_font_metrics(fs, font_family, style, config);
1448 match style.caption_placement.as_deref().unwrap_or("left") {
1449 "left" | "right" => metrics.measure_width(caption_text),
1450 "top" | "bottom" => metrics.line_height_pt(),
1451 _ => 0.0,
1452 }
1453}
1454
1455fn caption_value_offset(
1460 style: &FormNodeStyle,
1461 reserve: f64,
1462 w: f64,
1463 h: f64,
1464) -> (f64, f64, f64, f64) {
1465 if reserve <= 0.0 || style.caption_text.is_none() {
1466 return (0.0, 0.0, w, h);
1467 }
1468 match style.caption_placement.as_deref().unwrap_or("left") {
1469 "left" => (reserve, 0.0, (w - reserve).max(0.0), h),
1470 "right" => (0.0, 0.0, (w - reserve).max(0.0), h),
1471 "top" => (0.0, reserve, w, (h - reserve).max(0.0)),
1472 "bottom" => (0.0, 0.0, w, (h - reserve).max(0.0)),
1473 _ => (0.0, 0.0, w, h),
1474 }
1475}
1476
1477#[allow(clippy::too_many_arguments)]
1486#[allow(clippy::too_many_arguments)]
1487fn render_caption(
1488 x: f64,
1489 pdf_y: f64,
1490 w: f64,
1491 h: f64,
1492 caption_reserve: f64,
1493 font_size: f64,
1494 font_family: FontFamily,
1495 node_style: &FormNodeStyle,
1496 config: &XfaRenderConfig,
1497 ops: &mut Vec<u8>,
1498 caption_only: bool,
1503) {
1504 let caption_text = match &node_style.caption_text {
1505 Some(t) if !t.is_empty() => t,
1506 _ => return,
1507 };
1508 let caption_placement = node_style.caption_placement.as_deref().unwrap_or("left");
1509 let fs = if font_size > 0.0 {
1510 font_size
1511 } else {
1512 config.default_font_size
1513 };
1514 let metrics = build_font_metrics(fs, font_family, node_style, config);
1515 let font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
1516 let idh_metrics = lookup_font_metrics(node_style, config);
1517
1518 let (cap_x, cap_y, cap_w, cap_h) = match caption_placement {
1520 "left" => (x, pdf_y, caption_reserve, h),
1521 "right" => (x + w - caption_reserve, pdf_y, caption_reserve, h),
1522 "top" => (x, pdf_y + h - caption_reserve, w, caption_reserve),
1523 "bottom" => (x, pdf_y, w, caption_reserve),
1524 _ => (x, pdf_y, caption_reserve, h),
1525 };
1526
1527 let is_multiline = caption_text.contains('\n') || metrics.measure_width(caption_text) > cap_w;
1530
1531 if is_multiline {
1532 let line_height = node_style
1533 .line_height_pt
1534 .unwrap_or_else(|| metrics.line_height_pt());
1535 let pad_left = node_style.margin_left_pt.unwrap_or(0.0);
1536 let pad_right = node_style.margin_right_pt.unwrap_or(0.0);
1537 let text_indent = node_style.text_indent_pt.unwrap_or(0.0);
1538 let usable_w = (cap_w - pad_left - pad_right).max(1.0);
1539
1540 let layout = xfa_layout_engine::text::wrap_text(
1542 caption_text,
1543 usable_w,
1544 &metrics,
1545 text_indent,
1546 node_style.line_height_pt,
1547 );
1548
1549 if layout.lines.is_empty() {
1550 return;
1551 }
1552
1553 let asc_pt = ascender_pt(&metrics, fs);
1554 let space_above = node_style.space_above_pt.unwrap_or(0.0);
1555 let total_text_h = layout.lines.len() as f64 * line_height;
1556
1557 let first_line_pdf_y = match node_style.v_align {
1559 Some(VerticalAlign::Middle) => {
1560 cap_y + cap_h - asc_pt - space_above - (cap_h - space_above - total_text_h) / 2.0
1561 + (cap_h - space_above - total_text_h) / 2.0
1562 }
1563 Some(VerticalAlign::Bottom) => cap_y + total_text_h - asc_pt,
1564 _ => cap_y + cap_h - asc_pt - space_above,
1565 };
1566
1567 let tc = node_style
1568 .text_color
1569 .map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
1570 .unwrap_or(config.text_color);
1571
1572 write_ops(
1573 ops,
1574 format_args!(
1575 "BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
1576 tc[0], tc[1], tc[2], font_ref, fs
1577 ),
1578 );
1579 emit_text_style_ops(node_style, ops);
1580
1581 let text_x_base = cap_x + pad_left;
1582 let mut prev_x = text_x_base;
1583 for (i, line) in layout.lines.iter().enumerate() {
1584 let is_para_start = layout.first_line_of_para.get(i).copied().unwrap_or(false);
1585 let indent = if is_para_start { text_indent } else { 0.0 };
1586 let text_x = text_x_base + indent;
1587 let line_y = first_line_pdf_y - (i as f64 * line_height);
1588 if i == 0 {
1589 write_ops(ops, format_args!("{:.2} {:.2} Td\n", text_x, line_y));
1590 } else {
1591 let dx = text_x - prev_x;
1592 write_ops(ops, format_args!("{:.2} {:.2} Td\n", dx, -line_height));
1593 }
1594 prev_x = text_x;
1595 let encoded = pdf_encode_text(line, idh_metrics);
1596 write_ops(ops, format_args!("{} Tj\n", encoded));
1597 }
1598
1599 reset_text_style_ops(node_style, ops);
1600 ops.extend_from_slice(b"ET\n");
1601 } else {
1602 let asc_pt = ascender_pt(&metrics, fs);
1604 let line_h = metrics.line_height_pt();
1605 let text_y = match node_style.v_align {
1611 Some(VerticalAlign::Middle) if caption_only => {
1612 cap_y + (cap_h - line_h) / 2.0
1613 }
1614 Some(VerticalAlign::Bottom) => cap_y,
1615 _ if caption_only && cap_h > line_h + 1.0 => {
1616 cap_y + (cap_h - line_h) / 2.0
1617 }
1618 _ => cap_y + cap_h - asc_pt,
1619 };
1620
1621 let encoded = pdf_encode_text(caption_text, idh_metrics);
1622 write_ops(
1623 ops,
1624 format_args!(
1625 "BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
1626 config.text_color[0], config.text_color[1], config.text_color[2], font_ref, fs,
1627 ),
1628 );
1629 emit_text_style_ops(node_style, ops);
1630 write_ops(
1631 ops,
1632 format_args!("{:.2} {:.2} Td\n{} Tj\n", cap_x, text_y, encoded),
1633 );
1634 reset_text_style_ops(node_style, ops);
1635 ops.extend_from_slice(b"ET\n");
1636 }
1637}
1638
1639#[allow(clippy::too_many_arguments)]
1640fn render_field(
1641 x: f64,
1642 pdf_y: f64,
1643 w: f64,
1644 h: f64,
1645 value: &str,
1646 font_size: f64,
1647 font_family: FontFamily,
1648 node_style: &FormNodeStyle,
1649 config: &XfaRenderConfig,
1650 ops: &mut Vec<u8>,
1651) {
1652 let border_radius = node_style.border_radius_pt.unwrap_or(0.0);
1653 let border_style = node_style.border_style.as_deref();
1654
1655 if !config.field_values_only {
1656 if let Some(bg) = config.background_color {
1660 write_ops(
1661 ops,
1662 format_args!("{:.3} {:.3} {:.3} rg\n", bg[0], bg[1], bg[2]),
1663 );
1664 emit_rect_path(ops, x, pdf_y, w, h, border_radius);
1665 ops.extend_from_slice(b"f\n");
1666 }
1667 if config.draw_borders && config.border_width > 0.0 {
1668 if matches!(border_style, Some("lowered") | Some("raised")) {
1669 emit_3d_border(ops, x, pdf_y, w, h, config.border_width, border_style);
1670 } else {
1671 write_ops(
1672 ops,
1673 format_args!(
1674 "{:.2} w\n{:.3} {:.3} {:.3} RG\n",
1675 config.border_width,
1676 config.border_color[0],
1677 config.border_color[1],
1678 config.border_color[2],
1679 ),
1680 );
1681 let per_edge = node_style.border_colors.map(|cs| {
1682 cs.map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
1683 });
1684 let per_edge_widths = node_style.border_widths.as_ref();
1685 apply_border_dash(ops, border_style);
1686 let edges = node_style.border_edges;
1687 if per_edge.is_some() || per_edge_widths.is_some() {
1688 emit_individual_edges(
1689 ops,
1690 x,
1691 pdf_y,
1692 w,
1693 h,
1694 &edges,
1695 per_edge.as_ref(),
1696 per_edge_widths,
1697 config.border_width,
1698 );
1699 } else if edges[0] && edges[1] && edges[2] && edges[3] {
1700 emit_rect_path(ops, x, pdf_y, w, h, border_radius);
1701 ops.extend_from_slice(b"S\n");
1702 } else {
1703 emit_individual_edges(
1704 ops,
1705 x,
1706 pdf_y,
1707 w,
1708 h,
1709 &edges,
1710 None,
1711 None,
1712 config.border_width,
1713 );
1714 }
1715 reset_border_dash(ops, border_style);
1716 }
1717 }
1718 } if !value.is_empty() {
1720 let fs = if font_size > 0.0 {
1721 font_size
1722 } else {
1723 config.default_font_size
1724 };
1725 let pad_left = node_style.margin_left_pt.unwrap_or(0.0);
1728 let pad_right = node_style.margin_right_pt.unwrap_or(0.0);
1729 let space_above = node_style.space_above_pt.unwrap_or(0.0);
1730 let content_w = (w - pad_left - pad_right).max(0.0);
1731 let metrics = build_font_metrics(fs, font_family, node_style, config);
1732 let font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
1733 let text_w = metrics.measure_width(value);
1734
1735 let idh_metrics = lookup_font_metrics(node_style, config);
1736
1737 if text_w <= content_w || content_w <= 0.0 {
1738 let line_h = metrics.line_height_pt();
1739 let asc_pt = ascender_pt(&metrics, fs);
1740 let text_y = match node_style.v_align {
1741 Some(VerticalAlign::Middle) => {
1742 pdf_y + space_above + (h - space_above - line_h) / 2.0
1743 }
1744 Some(VerticalAlign::Bottom) => pdf_y + space_above,
1745 _ => pdf_y + h - space_above - asc_pt,
1746 };
1747 let encoded = pdf_encode_text(value, idh_metrics);
1748 write_ops(
1749 ops,
1750 format_args!(
1751 "BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
1752 config.text_color[0], config.text_color[1], config.text_color[2], font_ref, fs,
1753 ),
1754 );
1755 emit_synthetic_bold_ops(&config.font_map, node_style, fs, &config.text_color, ops);
1756 emit_text_style_ops(node_style, ops);
1757 write_ops(
1758 ops,
1759 format_args!("{:.2} {:.2} Td\n{} Tj\n", x + pad_left, text_y, encoded),
1760 );
1761 reset_text_style_ops(node_style, ops);
1762 reset_synthetic_bold_ops(&config.font_map, node_style, ops);
1763 ops.extend_from_slice(b"ET\n");
1764 } else {
1765 let lines = wrap_text(value, content_w, &metrics);
1766 let line_height = metrics.line_height_pt();
1767 let asc_pt = ascender_pt(&metrics, fs);
1768 let total_content_h = lines.len() as f64 * line_height;
1769 let text_start_y = match node_style.v_align {
1770 Some(VerticalAlign::Middle) => {
1771 pdf_y + space_above + (h - space_above - total_content_h) / 2.0
1772 }
1773 Some(VerticalAlign::Bottom) => pdf_y + space_above,
1774 _ => pdf_y + h - space_above - total_content_h,
1775 };
1776 write_ops(
1777 ops,
1778 format_args!(
1779 "BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
1780 config.text_color[0], config.text_color[1], config.text_color[2], font_ref, fs,
1781 ),
1782 );
1783 emit_synthetic_bold_ops(&config.font_map, node_style, fs, &config.text_color, ops);
1784 emit_text_style_ops(node_style, ops);
1785 write_ops(
1786 ops,
1787 format_args!(
1788 "{:.2} {:.2} Td\n",
1789 x + pad_left,
1790 text_start_y + total_content_h - asc_pt,
1791 ),
1792 );
1793 for (i, line) in lines.iter().enumerate() {
1794 if i > 0 {
1795 write_ops(ops, format_args!("0 {:.2} Td\n", -line_height));
1796 }
1797 let line_top = h - space_above - asc_pt - (i as f64 * line_height);
1798 if line_top < 0.0 {
1799 break;
1800 }
1801 let encoded = pdf_encode_text(line, idh_metrics);
1802 write_ops(ops, format_args!("{} Tj\n", encoded));
1803 }
1804 reset_text_style_ops(node_style, ops);
1805 reset_synthetic_bold_ops(&config.font_map, node_style, ops);
1806 ops.extend_from_slice(b"ET\n");
1807 }
1808 }
1809}
1810
1811#[allow(clippy::too_many_arguments)]
1815fn draw_check_mark(
1816 mark: &str,
1817 x: f64,
1818 y: f64,
1819 w: f64,
1820 h: f64,
1821 m: f64,
1822 color: [f64; 3],
1823 ops: &mut Vec<u8>,
1824) {
1825 write_ops(
1826 ops,
1827 format_args!(
1828 "{:.3} {:.3} {:.3} RG\n{:.3} {:.3} {:.3} rg\n",
1829 color[0], color[1], color[2], color[0], color[1], color[2]
1830 ),
1831 );
1832 let cx = x + w / 2.0;
1833 let cy = y + h / 2.0;
1834 match mark {
1835 "check" => {
1836 let lw = (w.min(h) * 0.08).max(0.5);
1838 write_ops(ops, format_args!("{:.2} w\n", lw));
1839 write_ops(
1840 ops,
1841 format_args!(
1842 "{:.2} {:.2} m\n{:.2} {:.2} l\n{:.2} {:.2} l\nS\n",
1843 x + m,
1844 cy,
1845 cx - m * 0.3,
1846 y + m,
1847 x + w - m,
1848 y + h - m,
1849 ),
1850 );
1851 }
1852 "circle" => {
1853 let r = (w.min(h) / 2.0 - m).max(1.0);
1854 let k = r * 0.5523; write_ops(ops, format_args!(
1856 "{:.2} {:.2} m\n{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\nf\n",
1857 cx + r, cy,
1858 cx + r, cy + k, cx + k, cy + r, cx, cy + r,
1859 cx - k, cy + r, cx - r, cy + k, cx - r, cy,
1860 cx - r, cy - k, cx - k, cy - r, cx, cy - r,
1861 cx + k, cy - r, cx + r, cy - k, cx + r, cy,
1862 ));
1863 }
1864 "diamond" => {
1865 let d = (w.min(h) / 2.0 - m).max(1.0);
1866 write_ops(
1867 ops,
1868 format_args!(
1869 "{:.2} {:.2} m\n{:.2} {:.2} l\n{:.2} {:.2} l\n{:.2} {:.2} l\nf\n",
1870 cx,
1871 cy + d,
1872 cx - d,
1873 cy,
1874 cx,
1875 cy - d,
1876 cx + d,
1877 cy,
1878 ),
1879 );
1880 }
1881 "square" => {
1882 let s = (w.min(h) - 2.0 * m).max(1.0);
1883 write_ops(
1884 ops,
1885 format_args!("{:.2} {:.2} {:.2} {:.2} re\nf\n", x + m, y + m, s, s,),
1886 );
1887 }
1888 "star" => {
1889 let lw = (w.min(h) * 0.08).max(0.5);
1891 write_ops(ops, format_args!("{:.2} w\n", lw));
1892 write_ops(
1893 ops,
1894 format_args!(
1895 "{:.2} {:.2} m\n{:.2} {:.2} l\nS\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\n",
1896 x + m,
1897 y + m,
1898 x + w - m,
1899 y + h - m,
1900 x + w - m,
1901 y + m,
1902 x + m,
1903 y + h - m,
1904 ),
1905 );
1906 write_ops(
1907 ops,
1908 format_args!(
1909 "{:.2} {:.2} m\n{:.2} {:.2} l\nS\n",
1910 cx,
1911 y + m * 0.5,
1912 cx,
1913 y + h - m * 0.5,
1914 ),
1915 );
1916 }
1917 _ => {
1918 let lw = (w.min(h) * 0.08).max(0.5);
1920 write_ops(ops, format_args!("{:.2} w\n", lw));
1921 write_ops(
1922 ops,
1923 format_args!(
1924 "{:.2} {:.2} m\n{:.2} {:.2} l\nS\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\n",
1925 x + m,
1926 y + m,
1927 x + w - m,
1928 y + h - m,
1929 x + w - m,
1930 y + m,
1931 x + m,
1932 y + h - m,
1933 ),
1934 );
1935 }
1936 }
1937}
1938
1939#[allow(clippy::too_many_arguments)]
1960fn render_checkbox(
1961 x: f64,
1962 pdf_y: f64,
1963 w: f64,
1964 h: f64,
1965 value: &str,
1966 node_style: &FormNodeStyle,
1967 config: &XfaRenderConfig,
1968 ops: &mut Vec<u8>,
1969) {
1970 let bw = config.border_width;
1971 write_ops(ops, format_args!("q\n"));
1975 if let Some((r_u8, g_u8, b_u8)) = node_style.bg_color {
1976 let bg = [
1977 r_u8 as f64 / 255.0,
1978 g_u8 as f64 / 255.0,
1979 b_u8 as f64 / 255.0,
1980 ];
1981 write_ops(
1982 ops,
1983 format_args!(
1984 "{:.3} {:.3} {:.3} rg\n{:.2} {:.2} {:.2} {:.2} re\nf\n",
1985 bg[0], bg[1], bg[2], x, pdf_y, w, h
1986 ),
1987 );
1988 }
1989 write_ops(
1990 ops,
1991 format_args!(
1992 "{:.2} w\n{:.3} {:.3} {:.3} RG\n{:.2} {:.2} {:.2} {:.2} re\nS\n",
1993 bw,
1994 config.border_color[0],
1995 config.border_color[1],
1996 config.border_color[2],
1997 x,
1998 pdf_y,
1999 w,
2000 h
2001 ),
2002 );
2003 let checked = is_check_button_checked(value, FieldKind::Checkbox, node_style);
2004 if checked {
2005 let mark = config
2006 .check_button_mark
2007 .as_deref()
2008 .unwrap_or(default_check_button_mark(FieldKind::Checkbox));
2009 let m = w.min(h) * 0.15;
2010 let color = config.text_color;
2011 draw_check_mark(mark, x, pdf_y, w, h, m, color, ops);
2012 }
2013 write_ops(ops, format_args!("Q\n"));
2014}
2015
2016#[allow(clippy::too_many_arguments)]
2032fn render_radio(
2033 x: f64,
2034 pdf_y: f64,
2035 w: f64,
2036 h: f64,
2037 value: &str,
2038 node_style: &FormNodeStyle,
2039 config: &XfaRenderConfig,
2040 ops: &mut Vec<u8>,
2041) {
2042 let bw = config.border_width;
2043 let cx = x + w / 2.0;
2044 let cy = pdf_y + h / 2.0;
2045 let r = w.min(h) / 2.0;
2046
2047 let k = 0.5523; let kx = r * k;
2050 let ky = r * k;
2051 write_ops(ops, format_args!("q\n",));
2054 if let Some((r_u8, g_u8, b_u8)) = node_style.bg_color {
2055 let bg = [
2056 r_u8 as f64 / 255.0,
2057 g_u8 as f64 / 255.0,
2058 b_u8 as f64 / 255.0,
2059 ];
2060 write_ops(
2061 ops,
2062 format_args!(
2063 "{:.3} {:.3} {:.3} rg\n\
2064 {:.2} {:.2} m\n\
2065 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2066 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2067 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2068 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2069 f\n",
2070 bg[0],
2071 bg[1],
2072 bg[2],
2073 cx + r,
2074 cy,
2075 cx + r,
2076 cy + ky,
2077 cx + kx,
2078 cy + r,
2079 cx,
2080 cy + r,
2081 cx - kx,
2082 cy + r,
2083 cx - r,
2084 cy + ky,
2085 cx - r,
2086 cy,
2087 cx - r,
2088 cy - ky,
2089 cx - kx,
2090 cy - r,
2091 cx,
2092 cy - r,
2093 cx + kx,
2094 cy - r,
2095 cx + r,
2096 cy - ky,
2097 cx + r,
2098 cy,
2099 ),
2100 );
2101 }
2102 write_ops(
2103 ops,
2104 format_args!(
2105 "{:.2} w\n{:.3} {:.3} {:.3} RG\n",
2106 bw, config.border_color[0], config.border_color[1], config.border_color[2],
2107 ),
2108 );
2109 write_ops(
2110 ops,
2111 format_args!(
2112 "{:.2} {:.2} m\n\
2113 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2114 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2115 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2116 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2117 S\n",
2118 cx + r,
2119 cy,
2120 cx + r,
2121 cy + ky,
2122 cx + kx,
2123 cy + r,
2124 cx,
2125 cy + r,
2126 cx - kx,
2127 cy + r,
2128 cx - r,
2129 cy + ky,
2130 cx - r,
2131 cy,
2132 cx - r,
2133 cy - ky,
2134 cx - kx,
2135 cy - r,
2136 cx,
2137 cy - r,
2138 cx + kx,
2139 cy - r,
2140 cx + r,
2141 cy - ky,
2142 cx + r,
2143 cy,
2144 ),
2145 );
2146
2147 let checked = is_check_button_checked(value, FieldKind::Radio, node_style);
2148 if checked {
2149 let mark = config
2150 .check_button_mark
2151 .as_deref()
2152 .unwrap_or(default_check_button_mark(FieldKind::Radio));
2153 if mark == "circle" {
2154 let ir = r * 0.4;
2157 let ikx = ir * k;
2158 let iky = ir * k;
2159 write_ops(
2160 ops,
2161 format_args!(
2162 "{:.3} {:.3} {:.3} rg\n\
2163 {:.2} {:.2} m\n\
2164 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2165 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2166 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2167 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2168 f\n",
2169 config.text_color[0],
2170 config.text_color[1],
2171 config.text_color[2],
2172 cx + ir,
2173 cy,
2174 cx + ir,
2175 cy + iky,
2176 cx + ikx,
2177 cy + ir,
2178 cx,
2179 cy + ir,
2180 cx - ikx,
2181 cy + ir,
2182 cx - ir,
2183 cy + iky,
2184 cx - ir,
2185 cy,
2186 cx - ir,
2187 cy - iky,
2188 cx - ikx,
2189 cy - ir,
2190 cx,
2191 cy - ir,
2192 cx + ikx,
2193 cy - ir,
2194 cx + ir,
2195 cy - iky,
2196 cx + ir,
2197 cy,
2198 ),
2199 );
2200 } else {
2201 let m = w.min(h) * 0.15;
2202 draw_check_mark(mark, x, pdf_y, w, h, m, config.text_color, ops);
2203 }
2204 }
2205 write_ops(ops, format_args!("Q\n"));
2206}
2207
2208fn default_check_button_mark(field_kind: FieldKind) -> &'static str {
2209 match field_kind {
2210 FieldKind::Radio => "circle",
2211 _ => "cross",
2212 }
2213}
2214
2215fn is_check_button_checked(value: &str, field_kind: FieldKind, node_style: &FormNodeStyle) -> bool {
2216 let on_value = node_style.check_button_on_value.as_deref().unwrap_or("1");
2219 let off_value = node_style.check_button_off_value.as_deref().unwrap_or("");
2220 let neutral_value = node_style
2221 .check_button_neutral_value
2222 .as_deref()
2223 .unwrap_or("");
2224 if value == on_value {
2225 return true;
2226 }
2227 if value == off_value {
2228 return false;
2229 }
2230 if field_kind == FieldKind::Checkbox && value == neutral_value {
2231 return false;
2232 }
2233
2234 false
2235}
2236
2237#[allow(clippy::too_many_arguments)]
2256fn render_dropdown(
2257 x: f64,
2258 pdf_y: f64,
2259 w: f64,
2260 h: f64,
2261 value: &str,
2262 font_size: f64,
2263 font_family: FontFamily,
2264 node_style: &FormNodeStyle,
2265 config: &XfaRenderConfig,
2266 ops: &mut Vec<u8>,
2267 display_items: &[String],
2268 save_items: &[String],
2269) {
2270 let display_value = if let Some(idx) = save_items.iter().position(|s| s == value) {
2273 display_items.get(idx).map(|s| s.as_str()).unwrap_or(value)
2274 } else {
2275 value
2276 };
2277
2278 if display_value.is_empty() {
2280 return;
2281 }
2282 let border_radius = node_style.border_radius_pt.unwrap_or(0.0);
2283
2284 if let Some(bg) = &config.background_color {
2285 write_ops(
2286 ops,
2287 format_args!("{:.3} {:.3} {:.3} rg\n", bg[0], bg[1], bg[2]),
2288 );
2289 emit_rect_path(ops, x, pdf_y, w, h, border_radius);
2290 ops.extend_from_slice(b"f\n");
2291 }
2292
2293 if config.draw_borders && config.border_width > 0.0 {
2294 write_ops(
2295 ops,
2296 format_args!(
2297 "{:.2} w\n{:.3} {:.3} {:.3} RG\n",
2298 config.border_width,
2299 config.border_color[0],
2300 config.border_color[1],
2301 config.border_color[2],
2302 ),
2303 );
2304 emit_rect_path(ops, x, pdf_y, w, h, border_radius);
2305 ops.extend_from_slice(b"S\n");
2306 }
2307
2308 let arrow_w = h.min(12.0);
2309
2310 if !display_value.is_empty() {
2311 let fs = if font_size > 0.0 {
2312 font_size
2313 } else {
2314 config.default_font_size
2315 };
2316 let _metrics = build_font_metrics(fs, font_family, node_style, config);
2317 let font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
2318 let idh_metrics = lookup_font_metrics(node_style, config);
2319
2320 let v_offset = pdf_y + h / 2.0 - fs / 2.0;
2321 let encoded = pdf_encode_text(display_value, idh_metrics);
2322 write_ops(
2323 ops,
2324 format_args!(
2325 "BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
2326 config.text_color[0], config.text_color[1], config.text_color[2], font_ref, fs,
2327 ),
2328 );
2329 emit_synthetic_bold_ops(&config.font_map, node_style, fs, &config.text_color, ops);
2330 emit_text_style_ops(node_style, ops);
2331 write_ops(
2332 ops,
2333 format_args!("{:.2} {:.2} Td\n{} Tj\n", x + 2.0, v_offset, encoded),
2334 );
2335 reset_text_style_ops(node_style, ops);
2336 reset_synthetic_bold_ops(&config.font_map, node_style, ops);
2337 ops.extend_from_slice(b"ET\n");
2338 }
2339
2340 let arrow_x = x + w - arrow_w - 1.0;
2341 let arrow_y_center = pdf_y + h / 2.0;
2342 let arrow_size = arrow_w * 0.6;
2343 let arrow_char = "\u{25BC}";
2344 write_ops(
2345 ops,
2346 format_args!(
2347 "BT\n/F2 {:.1} Tf\n{:.2} {:.2} Td\n({}) Tj\nET\n",
2348 arrow_size,
2349 arrow_x,
2350 arrow_y_center - arrow_size / 2.0,
2351 arrow_char
2352 ),
2353 );
2354}
2355
2356#[allow(clippy::too_many_arguments)]
2357fn render_button(
2358 x: f64,
2359 pdf_y: f64,
2360 w: f64,
2361 h: f64,
2362 value: &str,
2363 font_size: f64,
2364 font_family: FontFamily,
2365 node_style: &FormNodeStyle,
2366 config: &XfaRenderConfig,
2367 ops: &mut Vec<u8>,
2368) {
2369 if value.is_empty() {
2371 return;
2372 }
2373 let border_radius = node_style.border_radius_pt.unwrap_or(0.0);
2374 let bw = config.border_width.max(0.0);
2375
2376 let fill_color = if let Some((r, g, b)) = node_style.bg_color {
2379 [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0]
2380 } else {
2381 [
2382 (config.border_color[0] + 0.3).min(1.0),
2383 (config.border_color[1] + 0.3).min(1.0),
2384 (config.border_color[2] + 0.3).min(1.0),
2385 ]
2386 };
2387 let border_color = node_style
2388 .border_color
2389 .map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
2390 .unwrap_or([
2391 fill_color[0] * 0.6,
2392 fill_color[1] * 0.6,
2393 fill_color[2] * 0.6,
2394 ]);
2395
2396 write_ops(
2397 ops,
2398 format_args!(
2399 "q\n{:.3} {:.3} {:.3} rg\n",
2400 fill_color[0], fill_color[1], fill_color[2]
2401 ),
2402 );
2403 emit_rect_path(ops, x, pdf_y, w, h, border_radius);
2404 ops.extend_from_slice(b"f\n");
2405
2406 write_ops(
2407 ops,
2408 format_args!(
2409 "{:.3} {:.3} {:.3} RG\n{:.2} w\n",
2410 border_color[0], border_color[1], border_color[2], bw
2411 ),
2412 );
2413 emit_rect_path(ops, x, pdf_y, w, h, border_radius);
2414 ops.extend_from_slice(b"S\n");
2415
2416 if !value.is_empty() {
2417 let fs = if font_size > 0.0 {
2418 font_size
2419 } else {
2420 config.default_font_size
2421 };
2422 let metrics = build_font_metrics(fs, font_family, node_style, config);
2423 let font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
2424 let idh_metrics = lookup_font_metrics(node_style, config);
2425 let text_w = metrics.measure_width(value);
2426 let text_x = x + (w - text_w) / 2.0;
2427 let v_offset = pdf_y + h / 2.0 - fs / 2.0;
2428 let encoded = pdf_encode_text(value, idh_metrics);
2429 let tc = node_style
2430 .text_color
2431 .map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
2432 .unwrap_or(config.text_color);
2433 write_ops(
2434 ops,
2435 format_args!(
2436 "BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
2437 tc[0], tc[1], tc[2], font_ref, fs,
2438 ),
2439 );
2440 emit_text_style_ops(node_style, ops);
2441 write_ops(
2442 ops,
2443 format_args!("{:.2} {:.2} Td\n{} Tj\n", text_x, v_offset, encoded),
2444 );
2445 reset_text_style_ops(node_style, ops);
2446 ops.extend_from_slice(b"ET\n");
2447 }
2448 ops.extend_from_slice(b"Q\n");
2455}
2456
2457#[allow(clippy::too_many_arguments)]
2458fn render_signature(
2459 x: f64,
2460 pdf_y: f64,
2461 w: f64,
2462 h: f64,
2463 value: &str,
2464 node_style: &FormNodeStyle,
2465 config: &XfaRenderConfig,
2466 ops: &mut Vec<u8>,
2467) {
2468 if value.is_empty() {
2470 return;
2471 }
2472 let border_radius = node_style.border_radius_pt.unwrap_or(0.0);
2473
2474 if let Some(bg) = &config.background_color {
2475 write_ops(
2476 ops,
2477 format_args!("{:.3} {:.3} {:.3} rg\n", bg[0], bg[1], bg[2]),
2478 );
2479 emit_rect_path(ops, x, pdf_y, w, h, border_radius);
2480 ops.extend_from_slice(b"f\n");
2481 }
2482
2483 write_ops(
2484 ops,
2485 format_args!(
2486 "{:.2} w\n{:.3} {:.3} {:.3} RG\n",
2487 config.border_width,
2488 config.border_color[0],
2489 config.border_color[1],
2490 config.border_color[2],
2491 ),
2492 );
2493 write_ops(ops, format_args!("[4 2] 0 d\n"));
2494 emit_rect_path(ops, x, pdf_y, w, h, border_radius);
2495 ops.extend_from_slice(b"S\n");
2496 write_ops(ops, format_args!("[] 0 d\n"));
2497
2498 if !value.is_empty() {
2499 let fs = node_style.font_size.unwrap_or(config.default_font_size);
2500 let text_x = x + node_style.margin_left_pt.unwrap_or(0.0);
2502 let v_offset = pdf_y + h / 2.0 - fs / 2.0;
2503 write_ops(
2504 ops,
2505 format_args!(
2506 "BT\n{:.3} {:.3} {:.3} rg\n/F1 {:.1} Tf\n{:.2} {:.2} Td\n({}) Tj\nET\n",
2507 config.text_color[0],
2508 config.text_color[1],
2509 config.text_color[2],
2510 fs,
2511 text_x,
2512 v_offset,
2513 pdf_escape(value)
2514 ),
2515 );
2516 }
2517}
2518
2519#[allow(clippy::too_many_arguments)]
2547fn render_text(
2548 x: f64,
2549 pdf_y: f64,
2550 _w: f64,
2551 h: f64,
2552 text: &str,
2553 node_style: &FormNodeStyle,
2554 config: &XfaRenderConfig,
2555 ops: &mut Vec<u8>,
2556) {
2557 if text.is_empty() {
2558 return;
2559 }
2560 let fs = node_style.font_size.unwrap_or(config.default_font_size);
2561 let p = node_style.margin_left_pt.unwrap_or(0.0);
2563 let font_family = match node_style.font_family.as_deref() {
2564 Some(f) if f.contains("Courier") || f.contains("Mono") => FontFamily::Monospace,
2565 Some(f)
2566 if f.contains("Helvetica")
2567 || f.contains("Arial")
2568 || f.contains("Sans")
2569 || f.contains("Myriad") =>
2570 {
2571 FontFamily::SansSerif
2572 }
2573 _ => FontFamily::Serif,
2574 };
2575 let font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
2576 let tc = node_style
2577 .text_color
2578 .map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
2579 .unwrap_or(config.text_color);
2580 let metrics = build_font_metrics(fs, font_family, node_style, config);
2581 let asc_pt = ascender_pt(&metrics, fs);
2582 let line_h = metrics.line_height_pt();
2583 let idh_metrics = lookup_font_metrics(node_style, config);
2584 let encoded = pdf_encode_text(text, idh_metrics);
2585 let desc_pt =
2586 if let (Some(desc), Some(upem)) = (metrics.resolved_descender, metrics.resolved_upem) {
2587 if upem > 0 {
2588 desc as f64 / upem as f64 * fs
2589 } else {
2590 fs * 0.2
2591 }
2592 } else {
2593 fs * 0.2
2594 };
2595 let text_y = match node_style.v_align {
2596 Some(VerticalAlign::Middle) => pdf_y + (h - line_h) / 2.0,
2597 Some(VerticalAlign::Bottom) => pdf_y + desc_pt,
2598 _ => pdf_y + h - p - asc_pt,
2599 };
2600 write_ops(
2601 ops,
2602 format_args!(
2603 "BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
2604 tc[0], tc[1], tc[2], font_ref, fs,
2605 ),
2606 );
2607 emit_synthetic_bold_ops(&config.font_map, node_style, fs, &tc, ops);
2608 write_ops(
2609 ops,
2610 format_args!("{:.2} {:.2} Td\n{} Tj\n", x + p, text_y, encoded),
2611 );
2612 reset_synthetic_bold_ops(&config.font_map, node_style, ops);
2613 ops.extend_from_slice(b"ET\n");
2614 let text_x = x + p;
2615 let text_y = pdf_y + p;
2616 let line_thickness = (fs * 0.05).max(0.5);
2617 if node_style.underline {
2618 let underline_y = text_y - desc_pt;
2619 let text_w = metrics.measure_width(text);
2620 write_ops(
2621 ops,
2622 format_args!(
2623 "BT\n{:.3} w\n{:.3} {:.3} {:.3} RG\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nET\n",
2624 line_thickness,
2625 tc[0],
2626 tc[1],
2627 tc[2],
2628 text_x,
2629 underline_y,
2630 text_x + text_w,
2631 underline_y,
2632 ),
2633 );
2634 }
2635 if node_style.line_through {
2636 let mid_y = text_y + fs * 0.5 - asc_pt * 0.1;
2637 let text_w = metrics.measure_width(text);
2638 write_ops(
2639 ops,
2640 format_args!(
2641 "BT\n{:.3} w\n{:.3} {:.3} {:.3} RG\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nET\n",
2642 line_thickness,
2643 tc[0],
2644 tc[1],
2645 tc[2],
2646 text_x,
2647 mid_y,
2648 text_x + text_w,
2649 mid_y,
2650 ),
2651 );
2652 }
2653}
2654
2655#[allow(clippy::too_many_arguments)]
2680fn render_multiline(
2681 x: f64,
2682 _pdf_y: f64,
2683 container_width: f64,
2684 container_height: f64,
2685 lines: &[String],
2686 first_line_of_para: &[bool],
2687 font_size: f64,
2688 text_align: TextAlign,
2689 font_family: FontFamily,
2690 _is_bold: bool,
2691 mapper: &CoordinateMapper,
2692 abs_y_xfa: f64,
2693 node_style: &FormNodeStyle,
2694 config: &XfaRenderConfig,
2695 ops: &mut Vec<u8>,
2696) {
2697 if lines.is_empty() {
2698 return;
2699 }
2700 let pad_left = node_style.margin_left_pt.unwrap_or(0.0);
2703 let pad_right = node_style.margin_right_pt.unwrap_or(0.0);
2704 let space_above = node_style.space_above_pt.unwrap_or(0.0);
2705 let text_indent = node_style.text_indent_pt.unwrap_or(0.0);
2706 let font_metrics = build_font_metrics(font_size, font_family, node_style, config);
2707 let line_height = node_style
2708 .line_height_pt
2709 .unwrap_or_else(|| font_metrics.line_height_pt());
2710 let font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
2711 let tc = node_style
2712 .text_color
2713 .map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
2714 .unwrap_or(config.text_color);
2715 write_ops(
2716 ops,
2717 format_args!(
2718 "BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
2719 tc[0], tc[1], tc[2], font_ref, font_size
2720 ),
2721 );
2722 emit_synthetic_bold_ops(&config.font_map, node_style, font_size, &tc, ops);
2723 emit_text_style_ops(node_style, ops);
2724 let ascender_pt = if let (Some(asc), Some(upem)) =
2725 (font_metrics.resolved_ascender, font_metrics.resolved_upem)
2726 {
2727 if upem > 0 {
2728 asc as f64 / upem as f64 * font_size
2729 } else {
2730 font_size
2731 }
2732 } else {
2733 font_size
2734 };
2735 let total_text_h = lines.len() as f64 * line_height;
2736 let first_line_y_xfa = match node_style.v_align {
2737 Some(VerticalAlign::Middle) => {
2738 abs_y_xfa
2739 + space_above
2740 + (container_height - space_above - total_text_h) / 2.0
2741 + ascender_pt
2742 }
2743 Some(VerticalAlign::Bottom) => abs_y_xfa + container_height - total_text_h + ascender_pt,
2744 _ => abs_y_xfa + space_above + ascender_pt,
2745 };
2746 let first_line_pdf_y = mapper.xfa_to_pdf_y(first_line_y_xfa, 0.0);
2747 let content_w = (container_width - pad_left - pad_right).max(0.0);
2748 let idh_metrics = lookup_font_metrics(node_style, config);
2749 let mut prev_x = x + pad_left;
2750 for (i, line) in lines.iter().enumerate() {
2751 let is_para_start = first_line_of_para.get(i).copied().unwrap_or(false);
2752 let indent_offset = if is_para_start { text_indent } else { 0.0 };
2753 let line_y = first_line_pdf_y - (i as f64 * line_height);
2754 let line_w = font_metrics.measure_width(line);
2755 let text_x = match text_align {
2756 TextAlign::Center => {
2757 x + pad_left + indent_offset + ((content_w - indent_offset - line_w) / 2.0).max(0.0)
2758 }
2759 TextAlign::Right => x + pad_left + (content_w - line_w).max(0.0),
2760 _ => x + pad_left + indent_offset,
2761 };
2762 if i == 0 {
2763 write_ops(ops, format_args!("{:.2} {:.2} Td\n", text_x, line_y));
2764 } else {
2765 let dx = text_x - prev_x;
2766 write_ops(ops, format_args!("{:.2} {:.2} Td\n", dx, -line_height));
2767 }
2768 prev_x = text_x;
2769 let encoded = pdf_encode_text(line, idh_metrics);
2770 write_ops(ops, format_args!("{} Tj\n", encoded));
2771 }
2772 reset_text_style_ops(node_style, ops);
2773 reset_synthetic_bold_ops(&config.font_map, node_style, ops);
2774 ops.extend_from_slice(b"ET\n");
2775}
2776
2777#[allow(clippy::too_many_arguments)]
2779fn render_rich_multiline(
2780 x: f64,
2781 container_width: f64,
2782 container_height: f64,
2783 lines: &[String],
2784 first_line_of_para: &[bool],
2785 spans: &[RichTextSpan],
2786 font_size: f64,
2787 text_align: TextAlign,
2788 font_family: FontFamily,
2789 mapper: &CoordinateMapper,
2790 abs_y_xfa: f64,
2791 node_style: &FormNodeStyle,
2792 config: &XfaRenderConfig,
2793 ops: &mut Vec<u8>,
2794) {
2795 if lines.is_empty() || spans.is_empty() {
2796 return;
2797 }
2798 let pad_left = node_style.margin_left_pt.unwrap_or(0.0);
2801 let pad_right = node_style.margin_right_pt.unwrap_or(0.0);
2802 let space_above = node_style.space_above_pt.unwrap_or(0.0);
2803 let text_indent = node_style.text_indent_pt.unwrap_or(0.0);
2804 let font_metrics = build_font_metrics(font_size, font_family, node_style, config);
2805 let line_height = node_style
2806 .line_height_pt
2807 .unwrap_or_else(|| font_metrics.line_height_pt());
2808 let asc_pt = if let (Some(asc), Some(upem)) =
2809 (font_metrics.resolved_ascender, font_metrics.resolved_upem)
2810 {
2811 if upem > 0 {
2812 asc as f64 / upem as f64 * font_size
2813 } else {
2814 font_size
2815 }
2816 } else {
2817 font_size
2818 };
2819 let content_w = (container_width - pad_left - pad_right).max(0.0);
2820 let line_segments = map_spans_to_lines(spans, lines);
2821
2822 let leading_ratio = if font_size > 0.0 {
2827 line_height / font_size
2828 } else {
2829 1.0
2830 };
2831 let explicit_line_height = node_style.line_height_pt.is_some();
2832 let line_max_fs: Vec<f64> = lines
2833 .iter()
2834 .enumerate()
2835 .map(|(i, _)| {
2836 let mut max_fs = font_size;
2837 if let Some(segs) = line_segments.get(i) {
2838 for seg in segs {
2839 let span = &spans[seg.span_idx];
2840 let span_fs = span.font_size.unwrap_or(font_size);
2841 if span_fs > max_fs {
2842 max_fs = span_fs;
2843 }
2844 }
2845 }
2846 max_fs
2847 })
2848 .collect();
2849 let line_pair_height = |i: usize| -> f64 {
2850 if explicit_line_height || i == 0 {
2851 line_height
2852 } else {
2853 let adj = line_max_fs[i - 1].max(line_max_fs[i]);
2854 (adj * leading_ratio).max(line_height)
2855 }
2856 };
2857 let total_text_h: f64 = if lines.is_empty() {
2858 0.0
2859 } else {
2860 line_height + (1..lines.len()).map(line_pair_height).sum::<f64>()
2862 };
2863 let first_line_y_xfa = match node_style.v_align {
2864 Some(VerticalAlign::Middle) => {
2865 abs_y_xfa + space_above + (container_height - space_above - total_text_h) / 2.0 + asc_pt
2866 }
2867 Some(VerticalAlign::Bottom) => abs_y_xfa + container_height - total_text_h + asc_pt,
2868 _ => abs_y_xfa + space_above + asc_pt,
2869 };
2870 let first_line_pdf_y = mapper.xfa_to_pdf_y(first_line_y_xfa, 0.0);
2871
2872 ops.extend_from_slice(b"BT\n");
2873 let base_font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
2874 let base_tc = node_style
2875 .text_color
2876 .map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
2877 .unwrap_or(config.text_color);
2878 let idh_metrics = lookup_font_metrics(node_style, config);
2879
2880 let mut cur_font_ref = base_font_ref;
2881 let mut cur_fs = font_size;
2882 let mut cur_tc = base_tc;
2883 write_ops(
2884 ops,
2885 format_args!(
2886 "{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
2887 cur_tc[0], cur_tc[1], cur_tc[2], cur_font_ref, cur_fs,
2888 ),
2889 );
2890 emit_text_style_ops(node_style, ops);
2891
2892 let mut prev_x = x + pad_left;
2893 let mut line_y_cum = first_line_pdf_y;
2894 for (i, line) in lines.iter().enumerate() {
2895 let is_para_start = first_line_of_para.get(i).copied().unwrap_or(false);
2896 let indent_offset = if is_para_start { text_indent } else { 0.0 };
2897 let pair_h = line_pair_height(i);
2898 if i > 0 {
2899 line_y_cum -= pair_h;
2900 }
2901 let line_y = line_y_cum;
2902 let line_w = font_metrics.measure_width(line);
2903 let text_x = match text_align {
2904 TextAlign::Center => {
2905 x + pad_left + indent_offset + ((content_w - indent_offset - line_w) / 2.0).max(0.0)
2906 }
2907 TextAlign::Right => x + pad_left + (content_w - line_w).max(0.0),
2908 _ => x + pad_left + indent_offset,
2909 };
2910 if i == 0 {
2911 write_ops(ops, format_args!("{:.2} {:.2} Td\n", text_x, line_y));
2912 } else {
2913 let dx = text_x - prev_x;
2914 write_ops(ops, format_args!("{:.2} {:.2} Td\n", dx, -pair_h));
2915 }
2916 prev_x = text_x;
2917
2918 if let Some(segs) = line_segments.get(i) {
2919 if segs.is_empty() {
2920 let encoded = pdf_encode_text(line, idh_metrics);
2921 write_ops(ops, format_args!("{} Tj\n", encoded));
2922 continue;
2923 }
2924 for seg in segs {
2925 let span = &spans[seg.span_idx];
2926 let span_family = span
2927 .font_family
2928 .as_deref()
2929 .map(classify_font_family)
2930 .unwrap_or(font_family);
2931 let span_style = span_to_node_style(span, node_style);
2932 let span_font_ref = resolve_font_ref(&config.font_map, &span_style, span_family);
2933 let span_fs = span.font_size.unwrap_or(font_size);
2934 let span_tc = span
2935 .text_color
2936 .map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
2937 .unwrap_or(base_tc);
2938
2939 if span_font_ref != cur_font_ref || (span_fs - cur_fs).abs() > 0.01 {
2940 write_ops(ops, format_args!("{} {:.1} Tf\n", span_font_ref, span_fs));
2941 cur_font_ref = span_font_ref;
2942 cur_fs = span_fs;
2943 }
2944 if (span_tc[0] - cur_tc[0]).abs() > 0.001
2945 || (span_tc[1] - cur_tc[1]).abs() > 0.001
2946 || (span_tc[2] - cur_tc[2]).abs() > 0.001
2947 {
2948 write_ops(
2949 ops,
2950 format_args!("{:.3} {:.3} {:.3} rg\n", span_tc[0], span_tc[1], span_tc[2]),
2951 );
2952 cur_tc = span_tc;
2953 }
2954 let is_span_bold = span.font_weight.as_deref() == Some("bold");
2955 let span_has_real_bold =
2956 style_uses_real_bold_variant(&config.font_map, &span_style);
2957 if is_span_bold && !span_has_real_bold {
2958 let stroke_w = span_fs * 0.03;
2959 write_ops(
2960 ops,
2961 format_args!(
2962 "2 Tr\n{:.4} w\n{:.3} {:.3} {:.3} RG\n",
2963 stroke_w, span_tc[0], span_tc[1], span_tc[2],
2964 ),
2965 );
2966 }
2967 let encoded = pdf_encode_text(&seg.text, idh_metrics);
2968 write_ops(ops, format_args!("{} Tj\n", encoded));
2969 if is_span_bold && !span_has_real_bold {
2970 write_ops(ops, format_args!("0 Tr\n"));
2971 }
2972 }
2973 } else {
2974 let encoded = pdf_encode_text(line, idh_metrics);
2975 write_ops(ops, format_args!("{} Tj\n", encoded));
2976 }
2977 }
2978 reset_text_style_ops(node_style, ops);
2979 ops.extend_from_slice(b"ET\n");
2980}
2981
2982fn span_to_node_style(span: &RichTextSpan, base: &FormNodeStyle) -> FormNodeStyle {
2983 let mut style = base.clone();
2984 if let Some(ref fam) = span.font_family {
2985 style.font_family = Some(fam.clone());
2986 }
2987 if let Some(ref w) = span.font_weight {
2988 style.font_weight = Some(w.clone());
2989 }
2990 if let Some(ref s) = span.font_style {
2991 style.font_style = Some(s.clone());
2992 }
2993 style
2994}
2995
2996fn classify_font_family(name: &str) -> FontFamily {
2997 if name.contains("Courier") || name.contains("Mono") {
2998 FontFamily::Monospace
2999 } else if name.contains("Helvetica")
3000 || name.contains("Arial")
3001 || name.contains("Sans")
3002 || name.contains("Myriad")
3003 {
3004 FontFamily::SansSerif
3005 } else {
3006 FontFamily::Serif
3007 }
3008}
3009
3010struct LineSpanSegment {
3011 text: String,
3012 span_idx: usize,
3013}
3014
3015fn leading_whitespace_len(s: &str) -> usize {
3016 s.char_indices()
3017 .find(|(_, ch)| !ch.is_whitespace())
3018 .map(|(idx, _)| idx)
3019 .unwrap_or(s.len())
3020}
3021
3022fn map_spans_to_lines(spans: &[RichTextSpan], lines: &[String]) -> Vec<Vec<LineSpanSegment>> {
3023 let mut result = Vec::with_capacity(lines.len());
3024 let mut span_idx = 0_usize;
3025 let mut span_off = 0_usize;
3026
3027 for line in lines {
3028 while span_idx < spans.len() {
3029 if spans[span_idx].text == "\n" || span_off >= spans[span_idx].text.len() {
3030 span_idx += 1;
3031 span_off = 0;
3032 } else {
3033 break;
3034 }
3035 }
3036
3037 let mut segs: Vec<LineSpanSegment> = Vec::new();
3038 let mut line_pos = 0_usize;
3039
3040 while line_pos < line.len() && span_idx < spans.len() {
3041 let span = &spans[span_idx];
3042 if span.text == "\n" {
3043 span_idx += 1;
3044 span_off = 0;
3045 continue;
3046 }
3047 let span_rest = &span.text[span_off..];
3048 let line_rest = &line[line_pos..];
3049
3050 let common = line_rest
3051 .chars()
3052 .zip(span_rest.chars())
3053 .take_while(|(a, b)| a == b || (a.is_whitespace() && b.is_whitespace()))
3054 .count();
3055
3056 if common > 0 {
3057 let common_str: String = line_rest.chars().take(common).collect();
3058 let common_line_byte_len = common_str.len();
3059 let common_span_byte_len: usize =
3060 span_rest.chars().take(common).map(char::len_utf8).sum();
3061 segs.push(LineSpanSegment {
3062 text: common_str,
3063 span_idx,
3064 });
3065 line_pos += common_line_byte_len;
3066 span_off += common_span_byte_len;
3067 if span_off >= span.text.len() {
3068 span_idx += 1;
3069 span_off = 0;
3070 }
3071 } else {
3072 let span_skip = leading_whitespace_len(span_rest);
3073 if span_skip > 0 {
3074 span_off += span_skip;
3075 if span_off >= span.text.len() {
3076 span_idx += 1;
3077 span_off = 0;
3078 }
3079 continue;
3080 }
3081
3082 let line_skip = leading_whitespace_len(line_rest);
3083 if line_skip > 0 {
3084 segs.push(LineSpanSegment {
3085 text: line_rest[..line_skip].to_string(),
3086 span_idx,
3087 });
3088 line_pos += line_skip;
3089 } else {
3090 segs.push(LineSpanSegment {
3091 text: line_rest.to_string(),
3092 span_idx: 0,
3093 });
3094 break;
3095 }
3096 }
3097 }
3098
3099 result.push(segs);
3100
3101 while span_idx < spans.len() {
3102 let span = &spans[span_idx];
3103 if span.text == "\n" {
3104 break;
3105 }
3106 let rest = &span.text[span_off..];
3107 let skip = leading_whitespace_len(rest);
3108 if skip > 0 {
3109 span_off += skip;
3110 if span_off >= span.text.len() {
3111 span_idx += 1;
3112 span_off = 0;
3113 }
3114 } else {
3115 break;
3116 }
3117 }
3118 }
3119
3120 result
3121}
3122
3123fn wrap_text(text: &str, max_width: f64, metrics: &FontMetrics) -> Vec<String> {
3124 let mut lines = Vec::new();
3125 let mut current = String::new();
3126 for word in text.split_whitespace() {
3127 if current.is_empty() {
3128 current = word.to_string();
3129 } else {
3130 let candidate = format!("{} {}", current, word);
3131 if metrics.measure_width(&candidate) <= max_width {
3132 current = candidate;
3133 } else {
3134 lines.push(current);
3135 current = word.to_string();
3136 }
3137 }
3138 }
3139 if !current.is_empty() {
3140 lines.push(current);
3141 }
3142 if lines.is_empty() && !text.is_empty() {
3143 lines.push(text.to_string());
3144 }
3145 lines
3146}
3147
3148fn push_pdf_string_byte(out: &mut String, b: u8) {
3149 match b {
3150 b'(' => out.push_str("\\("),
3151 b')' => out.push_str("\\)"),
3152 b'\\' => out.push_str("\\\\"),
3153 0x20..=0x7E => out.push(b as char),
3154 _ => {
3155 use std::fmt::Write;
3156 let _ = write!(out, "\\{:03o}", b);
3157 }
3158 }
3159}
3160
3161fn pdf_escape_with_simple_encoding(s: &str, unicode_to_code: Option<&HashMap<u16, u8>>) -> String {
3162 let mut out = String::with_capacity(s.len());
3163 for c in s.chars() {
3164 if let Some(map) = unicode_to_code {
3165 let mapped = u16::try_from(c as u32)
3166 .ok()
3167 .and_then(|cp| map.get(&cp).copied())
3168 .or_else(|| unicode_to_winansi(c));
3170 if let Some(b) = mapped {
3171 push_pdf_string_byte(&mut out, b);
3172 } else {
3173 out.push('?');
3174 }
3175 continue;
3176 }
3177
3178 match c {
3179 '(' => out.push_str("\\("),
3180 ')' => out.push_str("\\)"),
3181 '\\' => out.push_str("\\\\"),
3182 '\x20'..='\x7e' => out.push(c),
3183 _ => {
3184 if let Some(b) = unicode_to_winansi(c) {
3185 push_pdf_string_byte(&mut out, b);
3186 } else {
3187 out.push('?');
3188 }
3189 }
3190 }
3191 }
3192 out
3193}
3194
3195fn pdf_escape(s: &str) -> String {
3196 pdf_escape_with_simple_encoding(s, None)
3197}
3198
3199fn pdf_encode_text(s: &str, metrics: Option<&FontMetricsData>) -> String {
3202 if let Some(data) = metrics {
3203 if let Some(ref font_bytes) = data.font_data {
3204 if let Ok(face) = ttf_parser::Face::parse(font_bytes, data.face_index) {
3205 let mut hex = String::with_capacity(s.len() * 4 + 2);
3206 hex.push('<');
3207 for ch in s.chars() {
3208 let gid = face.glyph_index(ch).map(|g| g.0).unwrap_or(0);
3209 use std::fmt::Write;
3210 let _ = write!(hex, "{:04X}", gid);
3211 }
3212 hex.push('>');
3213 return hex;
3214 }
3215 }
3216 }
3217 let simple_map = metrics.and_then(|m| m.simple_unicode_to_code.as_ref());
3218 format!("({})", pdf_escape_with_simple_encoding(s, simple_map))
3219}
3220
3221fn lookup_font_metrics<'a>(
3230 node_style: &FormNodeStyle,
3231 config: &'a XfaRenderConfig,
3232) -> Option<&'a FontMetricsData> {
3233 node_style.font_family.as_ref().and_then(|tf| {
3234 let vkey = font_variant_key(
3235 tf,
3236 node_style.font_weight.as_deref(),
3237 node_style.font_style.as_deref(),
3238 );
3239 config
3240 .font_metrics_data
3241 .get(&vkey)
3242 .or_else(|| config.font_metrics_data.get(tf))
3243 })
3244}
3245
3246#[allow(clippy::too_many_arguments)]
3247fn render_draw(
3248 draw_content: &DrawContent,
3249 abs_x: f64,
3250 pdf_y: f64,
3251 _w: f64,
3252 container_h: f64,
3253 node_style: &FormNodeStyle,
3254 config: &XfaRenderConfig,
3255 ops: &mut Vec<u8>,
3256) {
3257 match draw_content {
3258 DrawContent::Text(text) => {
3259 if !text.is_empty() {
3260 let fs = node_style.font_size.unwrap_or(config.default_font_size);
3261 let font_family = match node_style.font_family.as_deref() {
3262 Some(f) if f.contains("Courier") || f.contains("Mono") => FontFamily::Monospace,
3263 Some(f)
3264 if f.contains("Helvetica")
3265 || f.contains("Arial")
3266 || f.contains("Sans")
3267 || f.contains("Myriad") =>
3268 {
3269 FontFamily::SansSerif
3270 }
3271 _ => FontFamily::Serif,
3272 };
3273 let font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
3274 let tc = node_style
3275 .text_color
3276 .map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
3277 .unwrap_or(config.text_color);
3278 let idh_metrics = lookup_font_metrics(node_style, config);
3279 let encoded = pdf_encode_text(text, idh_metrics);
3280 write_ops(
3281 ops,
3282 format_args!(
3283 "BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
3284 tc[0], tc[1], tc[2], font_ref, fs,
3285 ),
3286 );
3287 emit_synthetic_bold_ops(&config.font_map, node_style, fs, &tc, ops);
3288 write_ops(
3289 ops,
3290 format_args!("{:.2} {:.2} Td\n{} Tj\n", abs_x, pdf_y, encoded),
3291 );
3292 reset_synthetic_bold_ops(&config.font_map, node_style, ops);
3293 ops.extend_from_slice(b"ET\n");
3294 }
3295 }
3296 DrawContent::Line { x1, y1, x2, y2 } => {
3297 let start_x = abs_x + x1;
3298 let start_y = pdf_y + container_h - y1;
3299 let end_x = abs_x + x2;
3300 let end_y = pdf_y + container_h - y2;
3301 write_ops(
3302 ops,
3303 format_args!(
3304 "{:.2} {:.2} m\n{:.2} {:.2} l\nS\n",
3305 start_x, start_y, end_x, end_y
3306 ),
3307 );
3308 }
3309 DrawContent::Rectangle { x, y, w, h, radius } => {
3310 let rx = abs_x + x;
3311 let ry = pdf_y + container_h - y - h;
3312 if let Some((r, g, b)) = node_style.border_color {
3314 write_ops(
3315 ops,
3316 format_args!(
3317 "{:.4} {:.4} {:.4} RG\n",
3318 r as f64 / 255.0,
3319 g as f64 / 255.0,
3320 b as f64 / 255.0
3321 ),
3322 );
3323 }
3324 if let Some(w_pt) = node_style.border_width_pt {
3325 write_ops(ops, format_args!("{:.2} w\n", w_pt));
3326 }
3327 if *radius <= 0.0 {
3328 write_ops(
3329 ops,
3330 format_args!("{:.2} {:.2} {:.2} {:.2} re\nS\n", rx, ry, w, h),
3331 );
3332 } else {
3333 let r = radius.min(w / 2.0).min(h / 2.0);
3334 let k = r * 0.5522847498;
3335 write_ops(
3336 ops,
3337 format_args!(
3338 "{:.2} {:.2} m\n\
3339 {:.2} {:.2} l\n\
3340 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
3341 {:.2} {:.2} l\n\
3342 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
3343 {:.2} {:.2} l\n\
3344 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
3345 {:.2} {:.2} l\n\
3346 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
3347 h\nS\n",
3348 rx,
3349 ry + r,
3350 rx,
3351 ry + h - r,
3352 rx,
3353 ry + h - r + k,
3354 rx + r - k,
3355 ry + h,
3356 rx + r,
3357 ry + h,
3358 rx + w - r,
3359 ry + h,
3360 rx + w - r + k,
3361 ry + h,
3362 rx + w,
3363 ry + h - r + k,
3364 rx + w,
3365 ry + h - r,
3366 rx + w,
3367 ry + r,
3368 rx + w,
3369 ry + r - k,
3370 rx + w - r + k,
3371 ry,
3372 rx + w - r,
3373 ry,
3374 rx + r,
3375 ry,
3376 rx + r - k,
3377 ry,
3378 rx,
3379 ry + r - k,
3380 rx,
3381 ry + r,
3382 ),
3383 );
3384 }
3385 }
3386 DrawContent::Arc {
3387 x,
3388 y,
3389 w,
3390 h,
3391 start_angle,
3392 sweep_angle,
3393 } => {
3394 let cx = abs_x + x + w / 2.0;
3395 let cy = pdf_y + container_h - y - h / 2.0;
3396 let rx = w / 2.0;
3397 let ry = h / 2.0;
3398 let start_rad = start_angle.to_radians();
3399 let sweep_rad = sweep_angle.to_radians();
3400 let end_angle = start_rad + sweep_rad;
3401 let k = 0.5522847498;
3402 let cos_start = start_rad.cos();
3403 let sin_start = start_rad.sin();
3404 let cos_end = end_angle.cos();
3405 let sin_end = end_angle.sin();
3406 let p1x = cx + rx * cos_start;
3407 let p1y = cy + ry * sin_start;
3408 let p2x = cx + rx * cos_end;
3409 let p2y = cy + ry * sin_end;
3410 let cp1x = cx - rx * k * sin_start;
3411 let cp1y = cy + ry * k * cos_start;
3412 let cp2x = cx + rx * k * sin_end;
3413 let cp2y = cy - ry * k * cos_end;
3414 write_ops(
3415 ops,
3416 format_args!(
3417 "{:.2} {:.2} m\n{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\nS\n",
3418 p1x, p1y, cp1x, cp1y, cp2x, cp2y, p2x, p2y
3419 ),
3420 );
3421 }
3422 }
3423}
3424
3425pub(crate) fn unicode_to_winansi(c: char) -> Option<u8> {
3426 let cp = c as u32;
3427 if (0xA0..=0xFF).contains(&cp) {
3428 return Some(cp as u8);
3429 }
3430 match c {
3431 '\u{20AC}' => Some(0x80),
3432 '\u{201A}' => Some(0x82),
3433 '\u{0192}' => Some(0x83),
3434 '\u{201E}' => Some(0x84),
3435 '\u{2026}' => Some(0x85),
3436 '\u{2020}' => Some(0x86),
3437 '\u{2021}' => Some(0x87),
3438 '\u{02C6}' => Some(0x88),
3439 '\u{2030}' => Some(0x89),
3440 '\u{0160}' => Some(0x8A),
3441 '\u{2039}' => Some(0x8B),
3442 '\u{0152}' => Some(0x8C),
3443 '\u{017D}' => Some(0x8E),
3444 '\u{2018}' => Some(0x91),
3445 '\u{2019}' => Some(0x92),
3446 '\u{201C}' => Some(0x93),
3447 '\u{201D}' => Some(0x94),
3448 '\u{2022}' => Some(0x95),
3449 '\u{2013}' => Some(0x96),
3450 '\u{2014}' => Some(0x97),
3451 '\u{02DC}' => Some(0x98),
3452 '\u{2122}' => Some(0x99),
3453 '\u{0161}' => Some(0x9A),
3454 '\u{203A}' => Some(0x9B),
3455 '\u{0153}' => Some(0x9C),
3456 '\u{017E}' => Some(0x9E),
3457 '\u{0178}' => Some(0x9F),
3458 _ => None,
3459 }
3460}
3461
3462fn write_ops(buf: &mut Vec<u8>, args: std::fmt::Arguments<'_>) {
3463 use std::io::Write;
3464 let _ = buf.write_fmt(args);
3465}
3466
3467#[cfg(test)]
3468mod tests {
3469 use super::*;
3470 use xfa_layout_engine::form::FormNodeId;
3471 use xfa_layout_engine::types::Rect;
3472
3473 fn make_page(nodes: Vec<LayoutNode>) -> LayoutPage {
3474 LayoutPage {
3475 width: 612.0,
3476 height: 792.0,
3477 nodes,
3478 }
3479 }
3480
3481 fn make_field_node(x: f64, y: f64, w: f64, h: f64, value: &str) -> LayoutNode {
3482 LayoutNode {
3483 form_node: FormNodeId(0),
3484 rect: Rect::new(x, y, w, h),
3485 name: "field1".to_string(),
3486 content: LayoutContent::Field {
3487 value: value.to_string(),
3488 field_kind: FieldKind::Text,
3489 font_size: 0.0,
3490 font_family: FontFamily::Serif,
3491 },
3492 children: vec![],
3493 style: Default::default(),
3494 display_items: vec![],
3495 save_items: vec![],
3496 }
3497 }
3498
3499 fn make_styled_field(
3500 x: f64,
3501 y: f64,
3502 w: f64,
3503 h: f64,
3504 value: &str,
3505 style: FormNodeStyle,
3506 ) -> LayoutNode {
3507 LayoutNode {
3508 form_node: FormNodeId(0),
3509 rect: Rect::new(x, y, w, h),
3510 name: "styled".to_string(),
3511 content: LayoutContent::Field {
3512 value: value.to_string(),
3513 field_kind: FieldKind::Text,
3514 font_size: 10.0,
3515 font_family: FontFamily::Serif,
3516 },
3517 children: vec![],
3518 style,
3519 display_items: vec![],
3520 save_items: vec![],
3521 }
3522 }
3523
3524 fn make_styled_field_kind(
3525 x: f64,
3526 y: f64,
3527 w: f64,
3528 h: f64,
3529 value: &str,
3530 field_kind: FieldKind,
3531 style: FormNodeStyle,
3532 ) -> LayoutNode {
3533 LayoutNode {
3534 form_node: FormNodeId(0),
3535 rect: Rect::new(x, y, w, h),
3536 name: "styled-kind".to_string(),
3537 content: LayoutContent::Field {
3538 value: value.to_string(),
3539 field_kind,
3540 font_size: 10.0,
3541 font_family: FontFamily::Serif,
3542 },
3543 children: vec![],
3544 style,
3545 display_items: vec![],
3546 save_items: vec![],
3547 }
3548 }
3549
3550 fn make_styled_checkbox(
3551 x: f64,
3552 y: f64,
3553 w: f64,
3554 h: f64,
3555 value: &str,
3556 style: FormNodeStyle,
3557 ) -> LayoutNode {
3558 LayoutNode {
3559 form_node: FormNodeId(0),
3560 rect: Rect::new(x, y, w, h),
3561 name: "checkbox".to_string(),
3562 content: LayoutContent::Field {
3563 value: value.to_string(),
3564 field_kind: FieldKind::Checkbox,
3565 font_size: 10.0,
3566 font_family: FontFamily::Serif,
3567 },
3568 children: vec![],
3569 style,
3570 display_items: vec![],
3571 save_items: vec![],
3572 }
3573 }
3574
3575 fn make_styled_radio(
3576 x: f64,
3577 y: f64,
3578 w: f64,
3579 h: f64,
3580 value: &str,
3581 style: FormNodeStyle,
3582 ) -> LayoutNode {
3583 LayoutNode {
3584 form_node: FormNodeId(0),
3585 rect: Rect::new(x, y, w, h),
3586 name: "radio".to_string(),
3587 content: LayoutContent::Field {
3588 value: value.to_string(),
3589 field_kind: FieldKind::Radio,
3590 font_size: 10.0,
3591 font_family: FontFamily::Serif,
3592 },
3593 children: vec![],
3594 style,
3595 display_items: vec![],
3596 save_items: vec![],
3597 }
3598 }
3599
3600 fn make_styled_button(
3601 x: f64,
3602 y: f64,
3603 w: f64,
3604 h: f64,
3605 value: &str,
3606 style: FormNodeStyle,
3607 ) -> LayoutNode {
3608 LayoutNode {
3609 form_node: FormNodeId(0),
3610 rect: Rect::new(x, y, w, h),
3611 name: "button".to_string(),
3612 content: LayoutContent::Field {
3613 value: value.to_string(),
3614 field_kind: FieldKind::Button,
3615 font_size: 10.0,
3616 font_family: FontFamily::Serif,
3617 },
3618 children: vec![],
3619 style,
3620 display_items: vec![],
3621 save_items: vec![],
3622 }
3623 }
3624
3625 #[test]
3626 fn coordinate_mapping() {
3627 let mapper = CoordinateMapper::new(792.0, 612.0);
3628 assert!((mapper.xfa_to_pdf_y(0.0, 20.0) - 772.0).abs() < 0.001);
3629 }
3630
3631 fn overlay_str(page: &LayoutPage) -> String {
3632 let o = generate_page_overlay(page, &XfaRenderConfig::default()).unwrap();
3633 String::from_utf8_lossy(&o.content_stream).into_owned()
3634 }
3635
3636 #[test]
3637 fn empty_page_overlay() {
3638 let s = overlay_str(&make_page(vec![]));
3639 assert!(s.starts_with("q\n") && s.ends_with("Q\n"));
3640 }
3641
3642 #[test]
3643 fn field_renders_text() {
3644 let s = overlay_str(&make_page(vec![make_field_node(
3645 10.0, 10.0, 100.0, 20.0, "Hello",
3646 )]));
3647 assert!(s.contains("(Hello) Tj") && s.contains("BT") && s.contains("ET"));
3648 }
3649
3650 #[test]
3651 fn empty_field_no_text() {
3652 let s = overlay_str(&make_page(vec![make_field_node(
3653 10.0, 10.0, 100.0, 20.0, "",
3654 )]));
3655 assert!(!s.contains("BT"));
3656 }
3657
3658 #[test]
3659 fn dropdown_renders_display_item_for_matching_save_value() {
3660 let node = LayoutNode {
3661 form_node: FormNodeId(0),
3662 rect: Rect::new(10.0, 10.0, 100.0, 20.0),
3663 name: "choice".to_string(),
3664 content: LayoutContent::Field {
3665 value: "CA".to_string(),
3666 field_kind: FieldKind::Dropdown,
3667 font_size: 10.0,
3668 font_family: FontFamily::Serif,
3669 },
3670 children: vec![],
3671 style: Default::default(),
3672 display_items: vec!["California".to_string(), "Nevada".to_string()],
3673 save_items: vec!["CA".to_string(), "NV".to_string()],
3674 };
3675
3676 let s = overlay_str(&make_page(vec![node]));
3677 assert!(
3678 s.contains("(California) Tj"),
3679 "dropdown should render display item: {s}"
3680 );
3681 assert!(
3682 !s.contains("(CA) Tj"),
3683 "dropdown should not render raw save value: {s}"
3684 );
3685 }
3686
3687 #[test]
3688 fn all_overlays() {
3689 let layout = LayoutDom {
3690 pages: vec![
3691 make_page(vec![make_field_node(0.0, 0.0, 50.0, 20.0, "P1")]),
3692 make_page(vec![make_field_node(0.0, 0.0, 50.0, 20.0, "P2")]),
3693 ],
3694 };
3695 assert_eq!(
3696 generate_all_overlays(&layout, &XfaRenderConfig::default())
3697 .unwrap()
3698 .len(),
3699 2
3700 );
3701 }
3702
3703 #[test]
3704 fn pdf_escape_winansi_encoding() {
3705 assert_eq!(pdf_escape("Hello"), "Hello");
3706 assert_eq!(pdf_escape("a(b)c\\d"), "a\\(b\\)c\\\\d");
3707 assert_eq!(pdf_escape("\u{2013}"), "\\226");
3708 assert_eq!(pdf_escape("\u{2022}"), "\\225");
3709 assert_eq!(pdf_escape("\u{00A9}"), "\\251");
3710 assert_eq!(pdf_escape("\u{4E16}"), "?");
3711 }
3712
3713 fn styled_overlay_str(node: LayoutNode) -> String {
3714 let o = generate_page_overlay(&make_page(vec![node]), &XfaRenderConfig::default()).unwrap();
3715 String::from_utf8_lossy(&o.content_stream).into_owned()
3716 }
3717
3718 fn styled_overlay_str_with_config(node: LayoutNode, config: XfaRenderConfig) -> String {
3719 let o = generate_page_overlay(&make_page(vec![node]), &config).unwrap();
3720 String::from_utf8_lossy(&o.content_stream).into_owned()
3721 }
3722
3723 #[test]
3724 fn rounded_border_emits_bezier() {
3725 let style = FormNodeStyle {
3726 border_width_pt: Some(1.0),
3727 border_radius_pt: Some(5.0),
3728 ..Default::default()
3729 };
3730 let s = styled_overlay_str(make_styled_field(10.0, 10.0, 100.0, 20.0, "Hi", style));
3731 assert!(s.contains(" c\n"), "expected Bezier");
3732 assert!(s.contains("h\n"), "expected close-path");
3733 }
3734
3735 #[test]
3736 fn button_default_border_radius_is_zero() {
3737 let s = styled_overlay_str(make_styled_button(
3738 10.0,
3739 10.0,
3740 100.0,
3741 20.0,
3742 "Click",
3743 FormNodeStyle::default(),
3744 ));
3745 assert!(
3746 !s.contains(" c\n"),
3747 "default button border radius should stay square: {s}"
3748 );
3749 }
3750
3751 #[test]
3752 fn button_with_caption_renders_even_when_value_is_empty() {
3753 let style = FormNodeStyle {
3754 caption_text: Some("Click".to_string()),
3755 ..Default::default()
3756 };
3757 let s = styled_overlay_str(make_styled_button(10.0, 10.0, 100.0, 20.0, "", style));
3758 assert!(
3759 s.contains("(Click) Tj"),
3760 "button caption should render as label: {s}"
3761 );
3762 }
3763
3764 #[test]
3765 fn dashed_border_emits_dash_pattern() {
3766 let style = FormNodeStyle {
3767 border_width_pt: Some(1.0),
3768 border_style: Some("dashed".to_string()),
3769 ..Default::default()
3770 };
3771 let s = styled_overlay_str(make_styled_field(10.0, 10.0, 100.0, 20.0, "Hi", style));
3772 assert!(s.contains("[3 2] 0 d"), "expected dash");
3773 assert!(s.contains("[] 0 d"), "expected reset");
3774 }
3775
3776 #[test]
3777 fn field_per_edge_widths_render_without_uniform_border_width() {
3778 let style = FormNodeStyle {
3779 border_widths: Some([1.0, 2.0, 1.0, 3.0]),
3780 border_edges: [false, true, false, true],
3781 ..Default::default()
3782 };
3783 let s = styled_overlay_str(make_styled_field(10.0, 10.0, 100.0, 20.0, "", style));
3784 assert!(s.contains("2.00 w"), "right edge width should be used: {s}");
3785 assert!(s.contains("3.00 w"), "left edge width should be used: {s}");
3786 assert!(
3787 s.contains("110.00 762.00 m 110.00 782.00 l S"),
3788 "right edge should render even without border_width_pt: {s}"
3789 );
3790 assert!(
3791 s.contains("10.00 762.00 m 10.00 782.00 l S"),
3792 "left edge should render even without border_width_pt: {s}"
3793 );
3794 }
3795
3796 #[test]
3797 fn container_per_edge_widths_render_without_uniform_border_width() {
3798 let node = LayoutNode {
3799 form_node: FormNodeId(0),
3800 rect: Rect::new(10.0, 10.0, 100.0, 20.0),
3801 name: "box".to_string(),
3802 content: LayoutContent::None,
3803 children: vec![],
3804 style: FormNodeStyle {
3805 border_widths: Some([1.0, 2.0, 1.0, 3.0]),
3806 border_edges: [false, true, false, true],
3807 ..Default::default()
3808 },
3809 display_items: vec![],
3810 save_items: vec![],
3811 };
3812 let s = styled_overlay_str(node);
3813 assert!(s.contains("2.00 w"), "right edge width should be used: {s}");
3814 assert!(s.contains("3.00 w"), "left edge width should be used: {s}");
3815 assert!(
3816 s.contains("110.00 762.00 m 110.00 782.00 l S"),
3817 "right edge should render for non-field nodes: {s}"
3818 );
3819 assert!(
3820 s.contains("10.00 762.00 m 10.00 782.00 l S"),
3821 "left edge should render for non-field nodes: {s}"
3822 );
3823 }
3824
3825 #[test]
3826 fn para_margins_applied() {
3827 let style = FormNodeStyle {
3828 margin_left_pt: Some(5.0),
3829 margin_right_pt: Some(3.0),
3830 space_above_pt: Some(2.0),
3831 ..Default::default()
3832 };
3833 let s = styled_overlay_str(make_styled_field(10.0, 10.0, 200.0, 30.0, "Test", style));
3834 assert!(s.contains("15.00"), "expected margin_left offset 10+5=15");
3835 }
3836
3837 #[test]
3838 fn top_caption_renders_after_field_fill() {
3839 let style = FormNodeStyle {
3840 caption_text: Some("PROJECT INFORMATION/NAME".to_string()),
3841 caption_placement: Some("top".to_string()),
3842 caption_reserve: Some(12.0),
3843 bg_color: Some((12, 34, 56)),
3844 ..Default::default()
3845 };
3846 let s = styled_overlay_str(make_styled_field(10.0, 100.0, 200.0, 30.0, "", style));
3847 let fill_idx = s
3848 .find("0.047 0.133 0.220 rg")
3849 .expect("explicit field fill should be present");
3850 let caption_idx = s
3851 .find("(PROJECT INFORMATION/NAME) Tj")
3852 .expect("caption text should render");
3853 assert!(
3854 caption_idx > fill_idx,
3855 "caption should render after the field fill so it stays visible: {s}"
3856 );
3857 }
3858
3859 #[test]
3860 fn left_caption_stays_in_pre_body_render_path() {
3861 let style = FormNodeStyle {
3862 caption_text: Some("Field 1".to_string()),
3863 caption_placement: Some("left".to_string()),
3864 bg_color: Some((12, 34, 56)),
3865 ..Default::default()
3866 };
3867 let s = styled_overlay_str(make_styled_field(10.0, 100.0, 200.0, 30.0, "", style));
3868 let caption_idx = s.find("(Field 1) Tj").expect("caption text should render");
3869 let fill_idx = s
3870 .find("0.047 0.133 0.220 rg")
3871 .expect("explicit field fill should be present");
3872 assert!(
3873 caption_idx < fill_idx,
3874 "left captions should keep the legacy pre-body ordering: {s}"
3875 );
3876 }
3877
3878 #[test]
3879 fn left_caption_without_explicit_reserve_shifts_field_body() {
3880 let style = FormNodeStyle {
3881 caption_text: Some("Field 1".to_string()),
3882 caption_placement: Some("left".to_string()),
3883 bg_color: Some((12, 34, 56)),
3884 ..Default::default()
3885 };
3886 let config = XfaRenderConfig::default();
3887 let reserve = effective_caption_reserve(&style, 10.0, FontFamily::Serif, &config);
3888 let metrics = build_font_metrics(10.0, FontFamily::Serif, &style, &config);
3889 assert!(
3890 (reserve - metrics.measure_width("Field 1")).abs() < 0.01,
3891 "auto reserve should match caption width for simple left captions"
3892 );
3893
3894 let s = styled_overlay_str(make_styled_field(10.0, 100.0, 200.0, 30.0, "", style));
3895 let mapper = CoordinateMapper::new(792.0, 612.0);
3896 let expected_fill = format!(
3897 "{:.2} {:.2} {:.2} 30.00 re",
3898 10.0 + reserve,
3899 mapper.xfa_to_pdf_y(100.0, 30.0),
3900 200.0 - reserve
3901 );
3902 assert!(
3903 s.contains(&expected_fill),
3904 "field body should be shifted right by the caption reserve: {s}"
3905 );
3906 }
3907
3908 #[test]
3909 fn button_caption_does_not_shrink_button_body() {
3910 let style = FormNodeStyle {
3911 caption_text: Some("Click".to_string()),
3912 caption_placement: Some("left".to_string()),
3913 bg_color: Some((12, 34, 56)),
3914 ..Default::default()
3915 };
3916 let config = XfaRenderConfig::default();
3917 let reserve = effective_caption_reserve(&style, 10.0, FontFamily::Serif, &config);
3918 assert!(
3919 reserve > 0.0,
3920 "button caption should still have measurable text"
3921 );
3922
3923 let s = styled_overlay_str(make_styled_button(10.0, 100.0, 200.0, 30.0, "", style));
3924 let mapper = CoordinateMapper::new(792.0, 612.0);
3925 let expected_fill = format!(
3926 "{:.2} {:.2} 200.00 30.00 re",
3927 10.0,
3928 mapper.xfa_to_pdf_y(100.0, 30.0)
3929 );
3930 assert!(
3931 s.contains(&expected_fill),
3932 "button body should keep the full field width because its caption is rendered internally: {s}"
3933 );
3934 }
3935
3936 #[test]
3937 fn top_caption_uses_full_inner_rect_height() {
3938 let style = FormNodeStyle {
3939 caption_text: Some("TOP CAPTION".to_string()),
3940 caption_placement: Some("top".to_string()),
3941 caption_reserve: Some(12.0),
3942 ..Default::default()
3943 };
3944 let s = styled_overlay_str(make_styled_field(
3945 10.0,
3946 100.0,
3947 200.0,
3948 30.0,
3949 "",
3950 style.clone(),
3951 ));
3952 let config = XfaRenderConfig::default();
3953 let metrics = build_font_metrics(10.0, FontFamily::Serif, &style, &config);
3954 let asc_pt = ascender_pt(&metrics, 10.0);
3955 let mapper = CoordinateMapper::new(792.0, 612.0);
3956 let inner_pdf_y = mapper.xfa_to_pdf_y(100.0, 30.0);
3957 let expected = format!(
3958 "{:.2} {:.2} Td\n(TOP CAPTION) Tj",
3959 10.0,
3960 inner_pdf_y + 30.0 - asc_pt
3961 );
3962 assert!(
3963 s.contains(&expected),
3964 "top caption should be positioned against the full inner rect, not the value area: {s}"
3965 );
3966 }
3967
3968 #[test]
3969 fn v_align_middle() {
3970 let style = FormNodeStyle {
3971 v_align: Some(VerticalAlign::Middle),
3972 ..Default::default()
3973 };
3974 let s = styled_overlay_str(make_styled_field(0.0, 0.0, 200.0, 40.0, "Mid", style));
3975 assert!(s.contains("(Mid) Tj"));
3976 }
3977
3978 #[test]
3979 fn text_field_without_explicit_fill_has_no_default_background() {
3980 let s = styled_overlay_str(make_styled_field(
3981 10.0,
3982 10.0,
3983 100.0,
3984 20.0,
3985 "Hi",
3986 FormNodeStyle::default(),
3987 ));
3988 assert!(
3989 !s.contains("0.949 0.949 0.949 rg"),
3990 "flatten output should not synthesize an interactive default field fill: {s}"
3991 );
3992 }
3993
3994 #[test]
3995 fn numeric_field_without_explicit_fill_has_no_default_background() {
3996 let s = styled_overlay_str(make_styled_field_kind(
3997 10.0,
3998 10.0,
3999 100.0,
4000 20.0,
4001 "42",
4002 FieldKind::NumericEdit,
4003 FormNodeStyle::default(),
4004 ));
4005 assert!(
4006 !s.contains("0.949 0.949 0.949 rg"),
4007 "numeric fields should also require explicit template fill to paint a background: {s}"
4008 );
4009 }
4010
4011 #[test]
4012 fn password_field_masks_plaintext_value() {
4013 let s = styled_overlay_str(make_styled_field_kind(
4014 10.0,
4015 10.0,
4016 100.0,
4017 20.0,
4018 "secret",
4019 FieldKind::PasswordEdit,
4020 FormNodeStyle::default(),
4021 ));
4022 assert!(
4023 !s.contains("(secret) Tj"),
4024 "password fields must not emit plaintext into the content stream: {s}"
4025 );
4026 assert!(
4027 s.contains("(\\225\\225\\225\\225\\225\\225) Tj"),
4028 "password fields should render bullet masking instead of plaintext: {s}"
4029 );
4030 }
4031
4032 #[test]
4033 fn explicit_white_field_background_is_preserved() {
4034 let s = styled_overlay_str(make_styled_field(
4035 10.0,
4036 10.0,
4037 100.0,
4038 20.0,
4039 "Hi",
4040 FormNodeStyle {
4041 bg_color: Some((255, 255, 255)),
4042 ..Default::default()
4043 },
4044 ));
4045 assert!(
4046 s.contains("1.000 1.000 1.000 rg"),
4047 "explicit white field fills should stay white: {s}"
4048 );
4049 }
4050
4051 #[test]
4052 fn checkbox_does_not_use_edit_field_default_background() {
4053 let s = styled_overlay_str(make_styled_checkbox(
4054 10.0,
4055 10.0,
4056 20.0,
4057 20.0,
4058 "0",
4059 FormNodeStyle {
4060 border_width_pt: Some(0.25),
4061 ..Default::default()
4062 },
4063 ));
4064 assert!(
4065 !s.contains("0.949 0.949 0.949 rg"),
4066 "non-edit widgets should not inherit the text field gray fill: {s}"
4067 );
4068 }
4069
4070 #[test]
4071 fn checkbox_explicit_background_fill_is_rendered() {
4072 let s = styled_overlay_str(make_styled_checkbox(
4073 10.0,
4074 10.0,
4075 20.0,
4076 20.0,
4077 "0",
4078 FormNodeStyle {
4079 bg_color: Some((255, 255, 255)),
4080 ..Default::default()
4081 },
4082 ));
4083 assert!(
4084 s.contains("1.000 1.000 1.000 rg"),
4085 "checkbox fill color should be emitted when bg_color is present: {s}"
4086 );
4087 assert!(
4088 s.contains("20.00 20.00 re\nf"),
4089 "checkbox background should be painted as a filled rectangle before the border: {s}"
4090 );
4091 }
4092
4093 #[test]
4094 fn radio_explicit_background_fill_is_rendered() {
4095 let s = styled_overlay_str(make_styled_radio(
4096 10.0,
4097 10.0,
4098 20.0,
4099 20.0,
4100 "N",
4101 FormNodeStyle {
4102 bg_color: Some((255, 255, 255)),
4103 check_button_on_value: Some("Y".to_string()),
4104 check_button_off_value: Some("N".to_string()),
4105 ..Default::default()
4106 },
4107 ));
4108 assert!(
4109 s.contains("1.000 1.000 1.000 rg"),
4110 "radio fill color should be emitted when bg_color is present: {s}"
4111 );
4112 assert!(
4113 s.contains(" c\n") && s.contains("\nf\n"),
4114 "radio background should be painted as a filled circle path before the border: {s}"
4115 );
4116 }
4117
4118 #[test]
4119 fn checkbox_ignores_global_background_without_explicit_fill() {
4120 let mut config = XfaRenderConfig::default();
4121 config.background_color = Some([0.949, 0.949, 0.949]);
4122 let s = styled_overlay_str_with_config(
4123 make_styled_checkbox(10.0, 10.0, 20.0, 20.0, "0", FormNodeStyle::default()),
4124 config,
4125 );
4126 assert!(
4127 !s.contains("0.949 0.949 0.949 rg"),
4128 "checkbox should only fill from explicit style.bg_color: {s}"
4129 );
4130 }
4131
4132 #[test]
4133 fn radio_ignores_global_background_without_explicit_fill() {
4134 let mut config = XfaRenderConfig::default();
4135 config.background_color = Some([0.949, 0.949, 0.949]);
4136 let s = styled_overlay_str_with_config(
4137 make_styled_radio(
4138 10.0,
4139 10.0,
4140 20.0,
4141 20.0,
4142 "N",
4143 FormNodeStyle {
4144 check_button_on_value: Some("Y".to_string()),
4145 check_button_off_value: Some("N".to_string()),
4146 ..Default::default()
4147 },
4148 ),
4149 config,
4150 );
4151 assert!(
4152 !s.contains("0.949 0.949 0.949 rg"),
4153 "radio should only fill from explicit style.bg_color: {s}"
4154 );
4155 }
4156
4157 #[test]
4158 fn pdf_escape_polish_chars_fallback() {
4159 assert_eq!(pdf_escape("łżść"), "????");
4161 }
4162
4163 #[test]
4164 fn pdf_encode_text_winansi_fallback() {
4165 let encoded = pdf_encode_text("Hello", None);
4167 assert_eq!(encoded, "(Hello)");
4168 }
4169
4170 #[test]
4171 fn pdf_encode_text_identity_h() {
4172 let metrics = FontMetricsData {
4174 widths: vec![500; 256],
4175 upem: 1000,
4176 ascender: 800,
4177 descender: -200,
4178 font_data: None,
4179 face_index: 0,
4180 simple_unicode_to_code: None,
4181 };
4182 let encoded = pdf_encode_text("AB", Some(&metrics));
4184 assert_eq!(encoded, "(AB)");
4185 }
4186
4187 #[test]
4188 fn pdf_encode_text_simple_encoding_fallback() {
4189 let mut custom_map = HashMap::new();
4191 custom_map.insert(0x0163, 0x80);
4192 let metrics = FontMetricsData {
4193 widths: vec![500; 256],
4194 upem: 1000,
4195 ascender: 800,
4196 descender: -200,
4197 font_data: None,
4198 face_index: 0,
4199 simple_unicode_to_code: Some(custom_map),
4200 };
4201 let encoded = pdf_encode_text("ţ", Some(&metrics));
4202 assert_eq!(encoded, "(\\200)");
4203 }
4204
4205 #[test]
4206 fn rich_text_span_mapping_preserves_space_after_bold_label() {
4207 let spans = vec![
4208 RichTextSpan {
4209 text: "Instructions:".to_string(),
4210 font_size: None,
4211 font_family: None,
4212 font_weight: Some("bold".to_string()),
4213 font_style: None,
4214 text_color: None,
4215 underline: false,
4216 line_through: false,
4217 },
4218 RichTextSpan {
4219 text: "This form is for your use.".to_string(),
4220 font_size: None,
4221 font_family: None,
4222 font_weight: Some("normal".to_string()),
4223 font_style: None,
4224 text_color: None,
4225 underline: false,
4226 line_through: false,
4227 },
4228 ];
4229 let lines = vec!["Instructions: This form is for your use.".to_string()];
4230
4231 let mapped = map_spans_to_lines(&spans, &lines);
4232
4233 assert_eq!(mapped.len(), 1);
4234 assert_eq!(mapped[0].len(), 3);
4235 assert_eq!(mapped[0][0].text, "Instructions:");
4236 assert_eq!(mapped[0][0].span_idx, 0);
4237 assert_eq!(mapped[0][1].text, " ");
4238 assert_eq!(mapped[0][1].span_idx, 1);
4239 assert_eq!(mapped[0][2].text, "This form is for your use.");
4240 assert_eq!(mapped[0][2].span_idx, 1);
4241 }
4242
4243 #[test]
4244 fn rich_text_span_mapping_treats_nbsp_spaceruns_as_normal_spaces() {
4245 let spans = vec![
4246 RichTextSpan {
4247 text: "Instructions:".to_string(),
4248 font_size: None,
4249 font_family: None,
4250 font_weight: Some("bold".to_string()),
4251 font_style: None,
4252 text_color: None,
4253 underline: false,
4254 line_through: false,
4255 },
4256 RichTextSpan {
4257 text: "This form is for your use.".to_string(),
4258 font_size: None,
4259 font_family: None,
4260 font_weight: Some("normal".to_string()),
4261 font_style: None,
4262 text_color: None,
4263 underline: false,
4264 line_through: false,
4265 },
4266 RichTextSpan {
4267 text: "\u{00A0}\u{00A0}".to_string(),
4268 font_size: None,
4269 font_family: None,
4270 font_weight: Some("normal".to_string()),
4271 font_style: None,
4272 text_color: None,
4273 underline: false,
4274 line_through: false,
4275 },
4276 RichTextSpan {
4277 text: "Mail in at least 14 days before".to_string(),
4278 font_size: None,
4279 font_family: None,
4280 font_weight: Some("normal".to_string()),
4281 font_style: None,
4282 text_color: None,
4283 underline: false,
4284 line_through: false,
4285 },
4286 ];
4287 let lines = vec![
4288 "Instructions: This form is for your use.".to_string(),
4289 "Mail in at least 14 days before".to_string(),
4290 ];
4291
4292 let mapped = map_spans_to_lines(&spans, &lines);
4293
4294 assert_eq!(mapped.len(), 2);
4295 assert_eq!(mapped[0][0].span_idx, 0);
4296 assert_eq!(mapped[0][2].span_idx, 1);
4297 assert_eq!(mapped[1].len(), 1);
4298 assert_eq!(mapped[1][0].text, "Mail in at least 14 days before");
4299 assert_eq!(mapped[1][0].span_idx, 3);
4300 }
4301
4302 #[test]
4303 fn real_bold_font_variant_skips_synthetic_bold_stroke() {
4304 let mut config = XfaRenderConfig::default();
4305 config
4306 .font_map
4307 .insert("Arial_Bold_Normal".to_string(), "/XFA_Fbold".to_string());
4308
4309 let s = styled_overlay_str_with_config(
4310 make_styled_field(
4311 10.0,
4312 10.0,
4313 200.0,
4314 20.0,
4315 "Bold",
4316 FormNodeStyle {
4317 font_family: Some("Arial".to_string()),
4318 font_weight: Some("bold".to_string()),
4319 ..Default::default()
4320 },
4321 ),
4322 config,
4323 );
4324
4325 assert!(
4326 !s.contains("2 Tr"),
4327 "actual bold variants should not get synthetic stroke bolding: {s}"
4328 );
4329 assert!(
4330 s.contains("/XFA_Fbold 10.0 Tf"),
4331 "expected real bold resource: {s}"
4332 );
4333 }
4334
4335 #[test]
4336 fn container_insets_offset_children() {
4337 let child = LayoutNode {
4340 form_node: FormNodeId(1),
4341 rect: Rect::new(0.0, 0.0, 50.0, 20.0),
4342 name: "child".to_string(),
4343 content: LayoutContent::Field {
4344 value: "Test".to_string(),
4345 field_kind: FieldKind::Text,
4346 font_size: 10.0,
4347 font_family: FontFamily::Serif,
4348 },
4349 children: vec![],
4350 style: Default::default(),
4351 display_items: vec![],
4352 save_items: vec![],
4353 };
4354 let parent = LayoutNode {
4355 form_node: FormNodeId(0),
4356 rect: Rect::new(100.0, 200.0, 200.0, 100.0),
4357 name: "parent".to_string(),
4358 content: LayoutContent::None,
4359 children: vec![child],
4360 style: FormNodeStyle {
4361 inset_left_pt: Some(10.0),
4362 inset_top_pt: Some(5.0),
4363 ..Default::default()
4364 },
4365 display_items: vec![],
4366 save_items: vec![],
4367 };
4368 let s = overlay_str(&make_page(vec![parent]));
4369 assert!(
4372 s.contains("110.00"),
4373 "child x should include parent left inset offset: {s}"
4374 );
4375 }
4376
4377 #[test]
4378 fn field_insets_reduce_text_wrap_width() {
4379 let node = LayoutNode {
4382 form_node: FormNodeId(0),
4383 rect: Rect::new(10.0, 10.0, 100.0, 30.0),
4384 name: "field".to_string(),
4385 content: LayoutContent::Field {
4386 value: "Hello".to_string(),
4387 field_kind: FieldKind::Text,
4388 font_size: 10.0,
4389 font_family: FontFamily::Serif,
4390 },
4391 children: vec![],
4392 style: FormNodeStyle {
4393 inset_left_pt: Some(8.0),
4394 inset_right_pt: Some(8.0),
4395 ..Default::default()
4396 },
4397 display_items: vec![],
4398 save_items: vec![],
4399 };
4400 let s = overlay_str(&make_page(vec![node]));
4401 assert!(
4403 s.contains("18.00"),
4404 "text x should include field left inset: {s}"
4405 );
4406 }
4407
4408 #[test]
4409 fn checkbox_border_width_respects_style() {
4410 let s = styled_overlay_str(make_styled_checkbox(
4411 10.0,
4412 10.0,
4413 20.0,
4414 20.0,
4415 "0",
4416 FormNodeStyle {
4417 border_width_pt: Some(0.25),
4418 ..Default::default()
4419 },
4420 ));
4421 assert!(
4422 s.contains("\n0.25 w\n"),
4423 "checkbox should use styled border width: {s}"
4424 );
4425 assert!(
4426 !s.contains("\n0.50 w\n"),
4427 "checkbox should not fall back to default 0.5pt border width: {s}"
4428 );
4429 assert!(
4430 !s.contains("\n1.00 w\n"),
4431 "checkbox should not clamp to 1pt border width: {s}"
4432 );
4433 }
4434
4435 #[test]
4436 fn container_children_y_offset_includes_inset() {
4437 let child = LayoutNode {
4438 form_node: FormNodeId(1),
4439 rect: Rect::new(0.0, 0.0, 50.0, 20.0),
4440 name: "child-box".to_string(),
4441 content: LayoutContent::None,
4442 children: vec![],
4443 style: FormNodeStyle {
4444 border_width_pt: Some(1.0),
4445 ..Default::default()
4446 },
4447 display_items: vec![],
4448 save_items: vec![],
4449 };
4450 let parent = LayoutNode {
4451 form_node: FormNodeId(0),
4452 rect: Rect::new(100.0, 200.0, 200.0, 100.0),
4453 name: "parent".to_string(),
4454 content: LayoutContent::None,
4455 children: vec![child],
4456 style: FormNodeStyle {
4457 inset_top_pt: Some(10.0),
4458 ..Default::default()
4459 },
4460 display_items: vec![],
4461 save_items: vec![],
4462 };
4463 let s = overlay_str(&make_page(vec![parent]));
4464 assert!(
4465 s.contains("100.00 562.00 50.00 20.00 re"),
4466 "child y should include parent inset_top offset: {s}"
4467 );
4468 assert!(
4469 !s.contains("100.00 572.00 50.00 20.00 re"),
4470 "child y should no longer ignore parent inset_top offset: {s}"
4471 );
4472 }
4473
4474 #[test]
4475 fn checkbox_mark_style_controls_rendered_symbol() {
4476 let default_overlay = styled_overlay_str(make_styled_checkbox(
4477 10.0,
4478 10.0,
4479 20.0,
4480 20.0,
4481 "1",
4482 FormNodeStyle::default(),
4483 ));
4484 let circle_overlay = styled_overlay_str(make_styled_checkbox(
4485 10.0,
4486 10.0,
4487 20.0,
4488 20.0,
4489 "1",
4490 FormNodeStyle {
4491 check_button_mark: Some("circle".to_string()),
4492 ..Default::default()
4493 },
4494 ));
4495
4496 assert!(
4497 !default_overlay.contains(" c\n"),
4498 "default checkbox mark should not emit Bezier circle commands: {default_overlay}"
4499 );
4500 assert!(
4501 circle_overlay.contains(" c\n"),
4502 "circle checkbox mark should emit Bezier circle commands: {circle_overlay}"
4503 );
4504 }
4505
4506 #[test]
4507 fn unchecked_checkbox_with_empty_value_still_draws_outline() {
4508 let overlay = styled_overlay_str(make_styled_checkbox(
4509 10.0,
4510 10.0,
4511 20.0,
4512 20.0,
4513 "",
4514 FormNodeStyle::default(),
4515 ));
4516
4517 assert!(
4518 overlay.contains("10.00 762.00 20.00 20.00 re"),
4519 "unchecked checkbox should still render its outline: {overlay}"
4520 );
4521 }
4522
4523 #[test]
4524 fn checkbox_checked_state_uses_template_item_values() {
4525 let checked_overlay = styled_overlay_str(make_styled_checkbox(
4526 10.0,
4527 10.0,
4528 20.0,
4529 20.0,
4530 "Yes",
4531 FormNodeStyle {
4532 check_button_on_value: Some("Yes".to_string()),
4533 check_button_off_value: Some("No".to_string()),
4534 ..Default::default()
4535 },
4536 ));
4537 let unchecked_overlay = styled_overlay_str(make_styled_checkbox(
4538 10.0,
4539 10.0,
4540 20.0,
4541 20.0,
4542 "No",
4543 FormNodeStyle {
4544 check_button_on_value: Some("Yes".to_string()),
4545 check_button_off_value: Some("No".to_string()),
4546 ..Default::default()
4547 },
4548 ));
4549
4550 assert!(
4551 checked_overlay.matches(" l\nS\n").count() >= 2,
4552 "asserted template on-value should render the check/cross mark: {checked_overlay}"
4553 );
4554 assert!(
4555 unchecked_overlay.matches(" l\nS\n").count() < 3,
4556 "template off-value should not render the asserted mark: {unchecked_overlay}"
4557 );
4558 }
4559
4560 #[test]
4561 fn radio_explicit_mark_overrides_default_circle() {
4562 let default_overlay = styled_overlay_str(make_styled_radio(
4563 10.0,
4564 10.0,
4565 20.0,
4566 20.0,
4567 "Y",
4568 FormNodeStyle {
4569 check_button_on_value: Some("Y".to_string()),
4570 check_button_off_value: Some("N".to_string()),
4571 ..Default::default()
4572 },
4573 ));
4574 let cross_overlay = styled_overlay_str(make_styled_radio(
4575 10.0,
4576 10.0,
4577 20.0,
4578 20.0,
4579 "Y",
4580 FormNodeStyle {
4581 check_button_mark: Some("cross".to_string()),
4582 check_button_on_value: Some("Y".to_string()),
4583 check_button_off_value: Some("N".to_string()),
4584 ..Default::default()
4585 },
4586 ));
4587
4588 assert!(
4589 default_overlay.matches(" c\n").count() >= 8,
4590 "default radio should render outer circle plus filled inner circle: {default_overlay}"
4591 );
4592 assert!(
4593 cross_overlay.matches(" l\nS\n").count() >= 2,
4594 "explicit radio mark should render the requested symbol: {cross_overlay}"
4595 );
4596 }
4597
4598 #[test]
4601 fn render_tree_page_dimensions() {
4602 let page = make_page(vec![]);
4603 let layout = LayoutDom { pages: vec![page] };
4604 let tree = layout_dom_to_render_tree(&layout, &XfaRenderConfig::default());
4605 assert_eq!(tree.pages.len(), 1);
4606 match &tree.pages[0] {
4607 RenderNode::Page { width, height, .. } => {
4608 assert!((*width - 612.0).abs() < 0.01, "width should be 612pt");
4609 assert!((*height - 792.0).abs() < 0.01, "height should be 792pt");
4610 }
4611 other => panic!("expected Page node, got {other:?}"),
4612 }
4613 }
4614
4615 #[test]
4616 fn render_tree_text_node_captured() {
4617 let node = LayoutNode {
4618 form_node: xfa_layout_engine::form::FormNodeId(0),
4619 rect: xfa_layout_engine::types::Rect::new(10.0, 20.0, 100.0, 15.0),
4620 name: "lbl".to_string(),
4621 content: LayoutContent::Text("Hello tree".to_string()),
4622 children: vec![],
4623 style: Default::default(),
4624 display_items: vec![],
4625 save_items: vec![],
4626 };
4627 let layout = LayoutDom {
4628 pages: vec![LayoutPage {
4629 width: 612.0,
4630 height: 792.0,
4631 nodes: vec![node],
4632 }],
4633 };
4634 let tree = layout_dom_to_render_tree(&layout, &XfaRenderConfig::default());
4635 let debug = tree.to_debug_string();
4636 assert!(
4637 debug.contains("Hello tree"),
4638 "debug string should contain text content: {debug}"
4639 );
4640 assert!(
4641 debug.contains("Text("),
4642 "debug string should contain Text node: {debug}"
4643 );
4644 }
4645
4646 #[test]
4649 fn text_node_emits_correct_font_and_size_operators() {
4650 let node = LayoutNode {
4653 form_node: xfa_layout_engine::form::FormNodeId(0),
4654 rect: xfa_layout_engine::types::Rect::new(10.0, 10.0, 100.0, 20.0),
4655 name: "f".to_string(),
4656 content: LayoutContent::Field {
4657 value: "test value".to_string(),
4658 field_kind: FieldKind::Text,
4659 font_size: 12.0,
4660 font_family: xfa_layout_engine::text::FontFamily::Serif,
4661 },
4662 children: vec![],
4663 style: Default::default(),
4664 display_items: vec![],
4665 save_items: vec![],
4666 };
4667 let s = overlay_str(&make_page(vec![node]));
4668 assert!(
4670 s.contains("Tf"),
4671 "should contain Tf font-select operator: {s}"
4672 );
4673 assert!(
4674 s.contains("12.0 Tf"),
4675 "should use specified font size 12.0: {s}"
4676 );
4677 assert!(
4678 s.contains("(test value) Tj"),
4679 "should render the value: {s}"
4680 );
4681 }
4682
4683 #[test]
4684 fn multiline_text_has_correct_td_offsets() {
4685 let node = LayoutNode {
4687 form_node: xfa_layout_engine::form::FormNodeId(0),
4688 rect: xfa_layout_engine::types::Rect::new(10.0, 10.0, 100.0, 40.0),
4689 name: "ml".to_string(),
4690 content: LayoutContent::WrappedText {
4691 lines: vec!["line one".to_string(), "line two".to_string()],
4692 first_line_of_para: vec![true, false],
4693 font_size: 10.0,
4694 text_align: xfa_layout_engine::types::TextAlign::Left,
4695 font_family: xfa_layout_engine::text::FontFamily::Serif,
4696 space_above_pt: None,
4697 space_below_pt: None,
4698 from_field: false,
4700 },
4701 children: vec![],
4702 style: Default::default(),
4703 display_items: vec![],
4704 save_items: vec![],
4705 };
4706 let s = overlay_str(&make_page(vec![node]));
4707 assert!(s.contains("(line one) Tj"), "first line missing: {s}");
4709 assert!(s.contains("(line two) Tj"), "second line missing: {s}");
4710 let td_count = s.matches(" Td\n").count();
4712 assert!(td_count >= 2, "expected ≥2 Td operators for two lines: {s}");
4713 }
4714
4715 #[test]
4718 fn checkbox_checked_renders_nonempty_stream() {
4719 let node = make_styled_checkbox(
4720 10.0,
4721 10.0,
4722 20.0,
4723 20.0,
4724 "1",
4725 FormNodeStyle {
4726 check_button_on_value: Some("1".to_string()),
4727 check_button_off_value: Some("0".to_string()),
4728 border_width_pt: Some(0.5),
4729 ..Default::default()
4730 },
4731 );
4732 let s = overlay_str(&make_page(vec![node]));
4733 assert!(!s.is_empty(), "checked checkbox overlay must not be empty");
4734 assert!(s.contains("re"), "checkbox must emit a rectangle: {s}");
4735 }
4736
4737 #[test]
4738 fn radio_selected_renders_nonempty_stream() {
4739 let node = make_styled_radio(
4740 10.0,
4741 10.0,
4742 20.0,
4743 20.0,
4744 "yes",
4745 FormNodeStyle {
4746 check_button_on_value: Some("yes".to_string()),
4747 check_button_off_value: Some("no".to_string()),
4748 ..Default::default()
4749 },
4750 );
4751 let s = overlay_str(&make_page(vec![node]));
4752 assert!(!s.is_empty(), "selected radio overlay must not be empty");
4753 assert!(
4755 s.contains(" c\n"),
4756 "selected radio must render circular fill: {s}"
4757 );
4758 }
4759
4760 #[test]
4761 fn dropdown_selected_value_renders_nonempty_stream() {
4762 let node = LayoutNode {
4763 form_node: xfa_layout_engine::form::FormNodeId(0),
4764 rect: xfa_layout_engine::types::Rect::new(10.0, 10.0, 100.0, 20.0),
4765 name: "dd".to_string(),
4766 content: LayoutContent::Field {
4767 value: "opt1".to_string(),
4768 field_kind: FieldKind::Dropdown,
4769 font_size: 10.0,
4770 font_family: xfa_layout_engine::text::FontFamily::Serif,
4771 },
4772 children: vec![],
4773 style: Default::default(),
4774 display_items: vec!["Option 1".to_string()],
4775 save_items: vec!["opt1".to_string()],
4776 };
4777 let s = overlay_str(&make_page(vec![node]));
4778 assert!(!s.is_empty(), "dropdown overlay must not be empty");
4779 assert!(
4780 s.contains("(Option 1) Tj"),
4781 "dropdown must render display label: {s}"
4782 );
4783 }
4784
4785 #[test]
4788 fn text_field_renders_bound_value() {
4789 let node = LayoutNode {
4790 form_node: xfa_layout_engine::form::FormNodeId(0),
4791 rect: xfa_layout_engine::types::Rect::new(10.0, 10.0, 100.0, 20.0),
4792 name: "name_field".to_string(),
4793 content: LayoutContent::Field {
4794 value: "John Doe".to_string(),
4795 field_kind: FieldKind::Text,
4796 font_size: 10.0,
4797 font_family: xfa_layout_engine::text::FontFamily::Serif,
4798 },
4799 children: vec![],
4800 style: Default::default(),
4801 display_items: vec![],
4802 save_items: vec![],
4803 };
4804 let s = overlay_str(&make_page(vec![node]));
4805 assert!(
4806 s.contains("(John Doe) Tj"),
4807 "text field must render its bound value: {s}"
4808 );
4809 }
4810
4811 #[test]
4812 fn numeric_field_formats_value_with_default_pattern() {
4813 let node = LayoutNode {
4815 form_node: xfa_layout_engine::form::FormNodeId(0),
4816 rect: xfa_layout_engine::types::Rect::new(10.0, 10.0, 80.0, 20.0),
4817 name: "amount".to_string(),
4818 content: LayoutContent::Field {
4819 value: "42.00000000".to_string(),
4820 field_kind: FieldKind::NumericEdit,
4821 font_size: 10.0,
4822 font_family: xfa_layout_engine::text::FontFamily::Serif,
4823 },
4824 children: vec![],
4825 style: Default::default(),
4826 display_items: vec![],
4827 save_items: vec![],
4828 };
4829 let s = overlay_str(&make_page(vec![node]));
4830 assert!(
4832 s.contains("(42) Tj"),
4833 "numeric field should render cleaned value: {s}"
4834 );
4835 }
4836
4837 #[test]
4838 fn date_field_renders_value_with_format_pattern() {
4839 let node = LayoutNode {
4842 form_node: xfa_layout_engine::form::FormNodeId(0),
4843 rect: xfa_layout_engine::types::Rect::new(10.0, 10.0, 100.0, 20.0),
4844 name: "date_field".to_string(),
4845 content: LayoutContent::Field {
4846 value: "2024-01-15".to_string(),
4847 field_kind: FieldKind::DateTimePicker,
4848 font_size: 10.0,
4849 font_family: xfa_layout_engine::text::FontFamily::Serif,
4850 },
4851 children: vec![],
4852 style: Default::default(),
4853 display_items: vec![],
4854 save_items: vec![],
4855 };
4856 let s = overlay_str(&make_page(vec![node]));
4857 assert!(
4858 s.contains("(2024-01-15) Tj"),
4859 "date field must render its value: {s}"
4860 );
4861 }
4862}