use crate::error::Result;
use crate::font_bridge::font_variant_key;
use std::collections::HashMap;
use xfa_layout_engine::form::{DrawContent, FieldKind, FormNodeStyle, RichTextSpan};
use xfa_layout_engine::layout::{LayoutContent, LayoutDom, LayoutNode, LayoutPage};
use xfa_layout_engine::text::{FontFamily, FontMetrics};
use xfa_layout_engine::types::{TextAlign, VerticalAlign};
#[derive(Debug, Clone)]
pub struct XfaRenderConfig {
pub default_font: String,
pub default_font_size: f64,
pub draw_borders: bool,
pub border_width: f64,
pub border_color: [f64; 3],
pub text_color: [f64; 3],
pub background_color: Option<[f64; 3]>,
pub text_padding: f64,
pub font_map: HashMap<String, String>,
pub font_metrics_data: HashMap<String, FontMetricsData>,
pub check_button_mark: Option<String>,
pub field_values_only: bool,
}
#[derive(Debug, Clone)]
pub struct FontMetricsData {
pub widths: Vec<u16>,
pub upem: u16,
pub ascender: i16,
pub descender: i16,
pub font_data: Option<Vec<u8>>,
pub face_index: u32,
pub simple_unicode_to_code: Option<HashMap<u16, u8>>,
}
#[derive(Debug, Clone)]
pub struct ImageInfo {
pub name: String,
pub data: Vec<u8>,
pub mime_type: String,
}
#[derive(Debug, Clone)]
pub struct PageOverlay {
pub content_stream: Vec<u8>,
pub images: Vec<ImageInfo>,
}
impl Default for XfaRenderConfig {
fn default() -> Self {
Self {
default_font: "Helvetica".to_string(),
default_font_size: 10.0,
draw_borders: true,
border_width: 1.0, border_color: [0.0, 0.0, 0.0],
text_color: [0.0, 0.0, 0.0],
background_color: None,
text_padding: xfa_layout_engine::types::DEFAULT_TEXT_PADDING,
font_map: HashMap::new(),
font_metrics_data: HashMap::new(),
check_button_mark: None,
field_values_only: false,
}
}
}
pub struct CoordinateMapper {
page_height: f64,
page_width: f64,
}
impl CoordinateMapper {
pub fn new(page_height: f64, page_width: f64) -> Self {
Self {
page_height,
page_width,
}
}
pub fn xfa_to_pdf_y(&self, xfa_y: f64, element_height: f64) -> f64 {
self.page_height - xfa_y - element_height
}
pub fn page_width(&self) -> f64 {
self.page_width
}
}
fn apply_node_style(config: &XfaRenderConfig, style: &FormNodeStyle) -> XfaRenderConfig {
let mut cfg = config.clone();
if let Some((r, g, b)) = style.bg_color {
cfg.background_color = Some([r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0]);
}
cfg.draw_borders = false;
if let Some(bw) = effective_border_width(style) {
cfg.border_width = bw;
cfg.draw_borders = true;
if let Some((r, g, b)) = style.border_color {
cfg.border_color = [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0];
}
}
if let Some((r, g, b)) = style.text_color {
cfg.text_color = [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0];
}
if let Some(mark) = &style.check_button_mark {
cfg.check_button_mark = Some(mark.clone());
}
cfg
}
fn effective_border_width(style: &FormNodeStyle) -> Option<f64> {
if let Some(bw) = style.border_width_pt.filter(|bw| *bw > 0.0) {
return Some(bw);
}
style
.border_widths
.as_ref()
.map(|widths| {
widths
.iter()
.zip(style.border_edges.iter())
.filter_map(|(width, visible)| (*visible && *width > 0.0).then_some(*width))
.fold(0.0, f64::max)
})
.filter(|bw| *bw > 0.0)
}
pub fn generate_page_overlay(page: &LayoutPage, config: &XfaRenderConfig) -> Result<PageOverlay> {
let mapper = CoordinateMapper::new(page.height, page.width);
let mut ops = Vec::new();
let mut images: Vec<ImageInfo> = Vec::new();
ops.extend_from_slice(b"q\n");
render_nodes(
&page.nodes,
0.0,
0.0,
&mapper,
config,
&mut ops,
&mut images,
);
ops.extend_from_slice(b"Q\n");
Ok(PageOverlay {
content_stream: ops,
images,
})
}
pub fn generate_all_overlays(
layout: &LayoutDom,
config: &XfaRenderConfig,
) -> Result<Vec<PageOverlay>> {
layout
.pages
.iter()
.map(|page| generate_page_overlay(page, config))
.collect()
}
#[derive(Debug, Clone)]
pub enum RenderNode {
Page {
width: f64,
height: f64,
children: Vec<RenderNode>,
},
Text {
x: f64,
y: f64,
content: String,
font: String,
size: f64,
},
Rect {
x: f64,
y: f64,
width: f64,
height: f64,
fill: Option<[u8; 3]>,
stroke: Option<[u8; 3]>,
},
Image {
x: f64,
y: f64,
width: f64,
height: f64,
data_len: usize,
},
Widget {
x: f64,
y: f64,
width: f64,
height: f64,
field_name: String,
value: String,
},
Group {
children: Vec<RenderNode>,
},
}
impl RenderNode {
fn fmt_indented(&self, buf: &mut String, depth: usize) {
let indent = " ".repeat(depth);
match self {
RenderNode::Page {
width,
height,
children,
} => {
buf.push_str(&format!("{indent}Page({width:.1}x{height:.1})\n"));
for c in children {
c.fmt_indented(buf, depth + 1);
}
}
RenderNode::Text {
x,
y,
content,
font,
size,
} => {
let preview: String = content.chars().take(40).collect();
let ellipsis = if content.len() > 40 { "…" } else { "" };
buf.push_str(&format!(
"{indent}Text({x:.1},{y:.1}) font={font} size={size:.1} \"{preview}{ellipsis}\"\n"
));
}
RenderNode::Rect {
x,
y,
width,
height,
fill,
stroke,
} => {
let fill_str = fill
.map(|[r, g, b]| format!("fill=#{r:02X}{g:02X}{b:02X}"))
.unwrap_or_default();
let stroke_str = stroke
.map(|[r, g, b]| format!("stroke=#{r:02X}{g:02X}{b:02X}"))
.unwrap_or_default();
buf.push_str(&format!(
"{indent}Rect({x:.1},{y:.1} {width:.1}x{height:.1}) {fill_str} {stroke_str}\n"
));
}
RenderNode::Image {
x,
y,
width,
height,
data_len,
} => {
buf.push_str(&format!(
"{indent}Image({x:.1},{y:.1} {width:.1}x{height:.1}) bytes={data_len}\n"
));
}
RenderNode::Widget {
x,
y,
width,
height,
field_name,
value,
} => {
let preview: String = value.chars().take(30).collect();
buf.push_str(&format!(
"{indent}Widget({x:.1},{y:.1} {width:.1}x{height:.1}) name={field_name} value=\"{preview}\"\n"
));
}
RenderNode::Group { children } => {
buf.push_str(&format!("{indent}Group\n"));
for c in children {
c.fmt_indented(buf, depth + 1);
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct RenderTree {
pub pages: Vec<RenderNode>,
}
impl RenderTree {
pub fn to_debug_string(&self) -> String {
let mut buf = String::new();
for page in &self.pages {
page.fmt_indented(&mut buf, 0);
}
buf
}
}
pub fn layout_dom_to_render_tree(layout: &LayoutDom, config: &XfaRenderConfig) -> RenderTree {
let pages = layout
.pages
.iter()
.map(|page| {
let mapper = CoordinateMapper::new(page.height, page.width);
let children = render_tree_nodes(&page.nodes, 0.0, 0.0, &mapper, config);
RenderNode::Page {
width: page.width,
height: page.height,
children,
}
})
.collect();
RenderTree { pages }
}
fn render_tree_nodes(
nodes: &[LayoutNode],
parent_x: f64,
parent_y: f64,
mapper: &CoordinateMapper,
config: &XfaRenderConfig,
) -> Vec<RenderNode> {
let mut result = Vec::new();
for node in nodes {
let abs_x = node.rect.x + parent_x;
let abs_y = node.rect.y + parent_y;
let w = node.rect.width;
let h = node.rect.height;
let pdf_y = mapper.xfa_to_pdf_y(abs_y, h);
let node_cfg = apply_node_style(config, &node.style);
let fill = node_cfg.background_color.map(|c| {
[
(c[0] * 255.0) as u8,
(c[1] * 255.0) as u8,
(c[2] * 255.0) as u8,
]
});
let stroke = if node_cfg.draw_borders && node_cfg.border_width > 0.0 {
let bc = node_cfg.border_color;
Some([
(bc[0] * 255.0) as u8,
(bc[1] * 255.0) as u8,
(bc[2] * 255.0) as u8,
])
} else {
None
};
if fill.is_some() || stroke.is_some() {
result.push(RenderNode::Rect {
x: abs_x,
y: pdf_y,
width: w,
height: h,
fill,
stroke,
});
}
let leaf = match &node.content {
LayoutContent::Field {
value,
field_kind,
font_size,
font_family,
} => {
use xfa_layout_engine::form::FieldKind;
match field_kind {
FieldKind::Checkbox | FieldKind::Radio => Some(RenderNode::Widget {
x: abs_x,
y: pdf_y,
width: w,
height: h,
field_name: node.name.clone(),
value: value.clone(),
}),
_ => {
let font_ref = config
.font_map
.get(&font_bridge_key_for_tree(*font_family))
.cloned()
.unwrap_or_else(|| node_cfg.default_font.clone());
if !value.is_empty() {
Some(RenderNode::Text {
x: abs_x,
y: pdf_y,
content: value.clone(),
font: font_ref,
size: if *font_size > 0.0 {
*font_size
} else {
node_cfg.default_font_size
},
})
} else {
Some(RenderNode::Widget {
x: abs_x,
y: pdf_y,
width: w,
height: h,
field_name: node.name.clone(),
value: value.clone(),
})
}
}
}
}
LayoutContent::Text(text) => {
if text.is_empty() {
None
} else {
Some(RenderNode::Text {
x: abs_x,
y: pdf_y,
content: text.clone(),
font: node_cfg.default_font.clone(),
size: node_cfg.default_font_size,
})
}
}
LayoutContent::WrappedText {
lines, font_size, ..
} => {
if lines.is_empty() {
None
} else {
Some(RenderNode::Text {
x: abs_x,
y: pdf_y,
content: lines.join(" "),
font: node_cfg.default_font.clone(),
size: *font_size,
})
}
}
LayoutContent::Image { data, .. } => Some(RenderNode::Image {
x: abs_x,
y: pdf_y,
width: w,
height: h,
data_len: data.len(),
}),
LayoutContent::Draw(_) | LayoutContent::None => None,
};
if let Some(leaf_node) = leaf {
result.push(leaf_node);
}
if !node.children.is_empty() {
let child_x = abs_x + node.style.inset_left_pt.unwrap_or(0.0);
let child_y = abs_y + node.style.inset_top_pt.unwrap_or(0.0);
let child_nodes = render_tree_nodes(&node.children, child_x, child_y, mapper, config);
if !child_nodes.is_empty() {
result.push(RenderNode::Group {
children: child_nodes,
});
}
}
}
result
}
fn font_bridge_key_for_tree(family: xfa_layout_engine::text::FontFamily) -> String {
use xfa_layout_engine::text::FontFamily;
match family {
FontFamily::SansSerif => "sans-serif".to_string(),
FontFamily::Monospace => "monospace".to_string(),
FontFamily::Serif => "serif".to_string(),
}
}
pub fn generate_field_values_overlays(
layout: &LayoutDom,
config: &XfaRenderConfig,
) -> Result<Vec<PageOverlay>> {
let mut fv_config = config.clone();
fv_config.field_values_only = true;
layout
.pages
.iter()
.map(|page| generate_page_overlay(page, &fv_config))
.collect()
}
fn render_nodes(
nodes: &[LayoutNode],
parent_x: f64,
parent_y: f64,
mapper: &CoordinateMapper,
config: &XfaRenderConfig,
ops: &mut Vec<u8>,
images: &mut Vec<ImageInfo>,
) {
for node in nodes {
let abs_x = node.rect.x + parent_x;
let abs_y = node.rect.y + parent_y;
let w = node.rect.width;
let h = node.rect.height;
let pdf_y = mapper.xfa_to_pdf_y(abs_y, h);
let node_config = apply_node_style(config, &node.style);
let inset_l = node.style.inset_left_pt.unwrap_or(0.0);
let inset_t = node.style.inset_top_pt.unwrap_or(0.0);
let inset_r = node.style.inset_right_pt.unwrap_or(0.0);
let inset_b = node.style.inset_bottom_pt.unwrap_or(0.0);
let inner_w = (w - inset_l - inset_r).max(0.0);
let inner_h = (h - inset_t - inset_b).max(0.0);
let (caption_font_size, caption_font_family) =
caption_font_for_content(&node.content, &node.style, &node_config);
let is_button = matches!(
&node.content,
LayoutContent::Field {
field_kind: FieldKind::Button,
..
}
);
let caption_reserve = if is_button {
0.0
} else {
effective_caption_reserve(
&node.style,
caption_font_size,
caption_font_family,
&node_config,
)
};
let (cap_dx, cap_dy, val_w, val_h) =
caption_value_offset(&node.style, caption_reserve, inner_w, inner_h);
let val_x = abs_x + inset_l + cap_dx;
let val_y_offset = inset_t + cap_dy;
let val_pdf_y = mapper.xfa_to_pdf_y(abs_y + val_y_offset, val_h);
if !matches!(node.content, LayoutContent::Field { .. }) && !config.field_values_only {
let border_radius = node.style.border_radius_pt.unwrap_or(0.0);
let border_style = node.style.border_style.as_deref();
let (bx, by, bw, bh) = if node.style.caption_text.is_some() {
(val_x, val_pdf_y, val_w, val_h)
} else {
let inner_pdf_y = mapper.xfa_to_pdf_y(abs_y + inset_t, inner_h);
(abs_x + inset_l, inner_pdf_y, inner_w, inner_h)
};
if let Some(bg) = &node_config.background_color {
write_ops(
ops,
format_args!("{:.3} {:.3} {:.3} rg\n", bg[0], bg[1], bg[2]),
);
emit_rect_path(ops, bx, by, bw, bh, border_radius);
ops.extend_from_slice(b"f\n");
}
if node_config.draw_borders && node_config.border_width > 0.0 && bw > 0.0 && bh > 0.0 {
let bwid = node_config.border_width;
let bc = node_config.border_color;
write_ops(
ops,
format_args!("{:.2} w\n{:.3} {:.3} {:.3} RG\n", bwid, bc[0], bc[1], bc[2]),
);
let per_edge = node.style.border_colors.map(|cs| {
cs.map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
});
let per_edge_widths = node.style.border_widths.as_ref();
apply_border_dash(ops, border_style);
let edges = node.style.border_edges;
if per_edge.is_some() || per_edge_widths.is_some() {
emit_individual_edges(
ops,
bx,
by,
bw,
bh,
&edges,
per_edge.as_ref(),
per_edge_widths,
bwid,
);
} else if edges[0] && edges[1] && edges[2] && edges[3] {
emit_rect_path(ops, bx, by, bw, bh, border_radius);
ops.extend_from_slice(b"S\n");
} else {
emit_individual_edges(ops, bx, by, bw, bh, &edges, None, None, bwid);
}
reset_border_dash(ops, border_style);
}
}
ops.extend_from_slice(b"q\n");
write_ops(
ops,
format_args!("{:.2} {:.2} {:.2} {:.2} re W n\n", abs_x, pdf_y, w, h),
);
let is_bold = node.style.font_weight.as_deref() == Some("bold");
let is_field = matches!(&node.content, LayoutContent::Field { .. });
let field_caption_needs_post_body_render =
is_field && matches!(node.style.caption_placement.as_deref(), Some("top"));
let caption_only = match &node.content {
LayoutContent::Field { value, .. } => value.is_empty(),
_ => false,
};
if node.style.caption_text.is_some()
&& !is_button
&& (!is_field || !field_caption_needs_post_body_render)
&& !config.field_values_only
{
render_caption(
abs_x + inset_l,
mapper.xfa_to_pdf_y(abs_y + inset_t, inner_h),
inner_w,
inner_h,
caption_reserve,
caption_font_size,
caption_font_family,
&node.style,
&node_config,
ops,
caption_only,
);
}
match &node.content {
LayoutContent::Field {
value,
field_kind,
font_size,
font_family,
} => {
match field_kind {
FieldKind::Checkbox => render_checkbox(
val_x,
val_pdf_y,
val_w,
val_h,
value,
&node.style,
&node_config,
ops,
),
FieldKind::Radio => render_radio(
val_x,
val_pdf_y,
val_w,
val_h,
value,
&node.style,
&node_config,
ops,
),
FieldKind::Dropdown => render_dropdown(
val_x,
val_pdf_y,
val_w,
val_h,
value,
*font_size,
*font_family,
&node.style,
&node_config,
ops,
&node.display_items,
&node.save_items,
),
FieldKind::Button => {
let label = if value.is_empty() {
node.style.caption_text.as_deref().unwrap_or("")
} else {
value
};
if !label.is_empty() {
render_button(
val_x,
val_pdf_y,
val_w,
val_h,
label,
*font_size,
*font_family,
&node.style,
&node_config,
ops,
)
}
}
FieldKind::PasswordEdit => {
let masked_value: String = value.chars().map(|_| '•').collect();
render_field(
val_x,
val_pdf_y,
val_w,
val_h,
&masked_value,
*font_size,
*font_family,
&node.style,
&node_config,
ops,
)
}
FieldKind::Signature => render_signature(
val_x,
val_pdf_y,
val_w,
val_h,
value,
&node.style,
&node_config,
ops,
),
_ => {
let display_val = if node.style.format_pattern.is_some() {
crate::appearance_bridge::format_value(
value,
node.style.format_pattern.as_deref(),
)
} else if matches!(field_kind, FieldKind::NumericEdit) {
crate::appearance_bridge::format_numeric_default(value)
} else {
value.to_string()
};
render_field(
val_x,
val_pdf_y,
val_w,
val_h,
&display_val,
*font_size,
*font_family,
&node.style,
&node_config,
ops,
)
}
}
if node.style.caption_text.is_some()
&& !is_button
&& field_caption_needs_post_body_render
&& !config.field_values_only
{
render_caption(
abs_x + inset_l,
mapper.xfa_to_pdf_y(abs_y + inset_t, inner_h),
inner_w,
inner_h,
caption_reserve,
caption_font_size,
caption_font_family,
&node.style,
&node_config,
ops,
value.is_empty(),
);
}
}
LayoutContent::Text(_) if config.field_values_only => {}
LayoutContent::Text(text) => {
let inner_pdf_y = mapper.xfa_to_pdf_y(abs_y + inset_t, inner_h);
render_text(
abs_x + inset_l,
inner_pdf_y,
inner_w,
inner_h,
text,
&node.style,
&node_config,
ops,
)
}
LayoutContent::WrappedText {
from_field: false, ..
} if config.field_values_only => {}
LayoutContent::WrappedText {
lines,
first_line_of_para,
font_size,
text_align,
font_family,
..
} => {
let use_rich = node.style.rich_text_spans.as_ref().is_some_and(|spans| {
spans.len() > 1
|| spans.iter().any(|s| {
s.font_size.is_some()
|| s.font_family.is_some()
|| s.font_weight.is_some()
|| s.font_style.is_some()
|| s.text_color.is_some()
|| s.underline
})
});
if use_rich {
if let Some(ref spans) = node.style.rich_text_spans {
render_rich_multiline(
val_x,
val_w,
val_h,
lines,
first_line_of_para,
spans,
*font_size,
*text_align,
*font_family,
mapper,
abs_y + val_y_offset,
&node.style,
&node_config,
ops,
);
}
} else {
render_multiline(
val_x,
val_pdf_y,
val_w,
val_h,
lines,
first_line_of_para,
*font_size,
*text_align,
*font_family,
is_bold,
mapper,
abs_y + val_y_offset,
&node.style,
&node_config,
ops,
);
}
}
LayoutContent::Image { .. } if config.field_values_only => {}
LayoutContent::Image { data, mime_type } => {
let img_name = format!("XImg{}", images.len());
ops.extend(crate::image_bridge::render_image_ops(
&img_name, abs_x, pdf_y, w, h,
));
images.push(ImageInfo {
name: img_name,
data: data.clone(),
mime_type: mime_type.clone(),
});
}
LayoutContent::Draw(_) if config.field_values_only => {}
LayoutContent::Draw(draw_content) => {
render_draw(
draw_content,
abs_x,
pdf_y,
w,
h,
&node.style,
&node_config,
ops,
);
}
LayoutContent::None => {}
}
ops.extend_from_slice(b"Q\n");
if !node.children.is_empty() {
let child_origin_x = abs_x + node.style.inset_left_pt.unwrap_or(0.0);
let child_origin_y = abs_y + node.style.inset_top_pt.unwrap_or(0.0);
render_nodes(
&node.children,
child_origin_x,
child_origin_y,
mapper,
config,
ops,
images,
);
}
}
}
fn emit_rect_path(ops: &mut Vec<u8>, x: f64, y: f64, w: f64, h: f64, radius: f64) {
if radius <= 0.0 {
write_ops(
ops,
format_args!("{:.2} {:.2} {:.2} {:.2} re\n", x, y, w, h),
);
} else {
let r = radius.min(w / 2.0).min(h / 2.0);
let k = r * 0.5522847498;
write_ops(
ops,
format_args!(
"{:.2} {:.2} m\n\
{:.2} {:.2} l\n\
{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
{:.2} {:.2} l\n\
{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
{:.2} {:.2} l\n\
{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
{:.2} {:.2} l\n\
{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
h\n",
x,
y + r,
x,
y + h - r,
x,
y + h - r + k,
x + r - k,
y + h,
x + r,
y + h,
x + w - r,
y + h,
x + w - r + k,
y + h,
x + w,
y + h - r + k,
x + w,
y + h - r,
x + w,
y + r,
x + w,
y + r - k,
x + w - r + k,
y,
x + w - r,
y,
x + r,
y,
x + r - k,
y,
x,
y + r - k,
x,
y + r,
),
);
}
}
#[allow(clippy::too_many_arguments)]
fn emit_individual_edges(
ops: &mut Vec<u8>,
x: f64,
y: f64,
w: f64,
h: f64,
edges: &[bool; 4],
colors: Option<&[[f64; 3]; 4]>,
widths: Option<&[f64; 4]>,
default_width: f64,
) {
if edges[0] {
let ww = widths.map(|w| w[0]).unwrap_or(default_width);
if let Some(c) = colors.map(|c| &c[0]) {
write_ops(
ops,
format_args!("{:.2} w\n{:.3} {:.3} {:.3} RG\n", ww, c[0], c[1], c[2]),
);
} else {
write_ops(ops, format_args!("{:.2} w\n", ww));
}
write_ops(
ops,
format_args!("{:.2} {:.2} m {:.2} {:.2} l S\n", x, y + h, x + w, y + h),
);
}
if edges[1] {
let ww = widths.map(|w| w[1]).unwrap_or(default_width);
if let Some(c) = colors.map(|c| &c[1]) {
write_ops(
ops,
format_args!("{:.2} w\n{:.3} {:.3} {:.3} RG\n", ww, c[0], c[1], c[2]),
);
} else {
write_ops(ops, format_args!("{:.2} w\n", ww));
}
write_ops(
ops,
format_args!("{:.2} {:.2} m {:.2} {:.2} l S\n", x + w, y, x + w, y + h),
);
}
if edges[2] {
let ww = widths.map(|w| w[2]).unwrap_or(default_width);
if let Some(c) = colors.map(|c| &c[2]) {
write_ops(
ops,
format_args!("{:.2} w\n{:.3} {:.3} {:.3} RG\n", ww, c[0], c[1], c[2]),
);
} else {
write_ops(ops, format_args!("{:.2} w\n", ww));
}
write_ops(
ops,
format_args!("{:.2} {:.2} m {:.2} {:.2} l S\n", x, y, x + w, y),
);
}
if edges[3] {
let ww = widths.map(|w| w[3]).unwrap_or(default_width);
if let Some(c) = colors.map(|c| &c[3]) {
write_ops(
ops,
format_args!("{:.2} w\n{:.3} {:.3} {:.3} RG\n", ww, c[0], c[1], c[2]),
);
} else {
write_ops(ops, format_args!("{:.2} w\n", ww));
}
write_ops(
ops,
format_args!("{:.2} {:.2} m {:.2} {:.2} l S\n", x, y, x, y + h),
);
}
}
fn apply_border_dash(ops: &mut Vec<u8>, style: Option<&str>) {
match style {
Some("dashed") => write_ops(ops, format_args!("[3 2] 0 d\n")),
Some("dotted") => write_ops(ops, format_args!("[1 1] 0 d\n")),
_ => {}
}
}
fn reset_border_dash(ops: &mut Vec<u8>, style: Option<&str>) {
if matches!(style, Some("dashed") | Some("dotted")) {
write_ops(ops, format_args!("[] 0 d\n"));
}
}
fn emit_3d_border(
ops: &mut Vec<u8>,
x: f64,
y: f64,
w: f64,
h: f64,
line_w: f64,
style: Option<&str>,
) {
let dark = [0.502, 0.502, 0.502]; let light = [0.831, 0.831, 0.831]; let (tl, br) = match style {
Some("lowered") => (dark, light),
_ => (light, dark), };
write_ops(ops, format_args!("{:.2} w\n", line_w));
write_ops(
ops,
format_args!(
"{:.3} {:.3} {:.3} RG\n{:.2} {:.2} m {:.2} {:.2} l S\n",
tl[0],
tl[1],
tl[2],
x,
y + h,
x + w,
y + h,
),
);
write_ops(
ops,
format_args!("{:.2} {:.2} m {:.2} {:.2} l S\n", x, y + h, x, y,),
);
write_ops(
ops,
format_args!(
"{:.3} {:.3} {:.3} RG\n{:.2} {:.2} m {:.2} {:.2} l S\n",
br[0],
br[1],
br[2],
x,
y,
x + w,
y,
),
);
write_ops(
ops,
format_args!("{:.2} {:.2} m {:.2} {:.2} l S\n", x + w, y, x + w, y + h,),
);
}
fn resolve_font_ref<'a>(
font_map: &'a HashMap<String, String>,
node_style: &FormNodeStyle,
font_family: FontFamily,
) -> &'a str {
if let Some(typeface) = &node_style.font_family {
let vkey = font_variant_key(
typeface,
node_style.font_weight.as_deref(),
node_style.font_style.as_deref(),
);
if let Some(mapped) = font_map.get(&vkey) {
return mapped;
}
if let Some(mapped) = font_map.get(typeface) {
return mapped;
}
}
match font_family {
FontFamily::Serif => "/F1",
FontFamily::SansSerif => "/F2",
FontFamily::Monospace => "/F3",
}
}
fn emit_text_style_ops(node_style: &FormNodeStyle, ops: &mut Vec<u8>) {
if let Some(h_scale) = node_style.font_horizontal_scale {
if (h_scale - 1.0).abs() > 0.001 {
write_ops(ops, format_args!("{:.1} Tz\n", h_scale * 100.0));
}
}
if let Some(spacing) = node_style.letter_spacing_pt {
if spacing.abs() > 0.001 {
write_ops(ops, format_args!("{:.3} Tc\n", spacing));
}
}
}
fn is_bold_style(node_style: &FormNodeStyle) -> bool {
node_style.font_weight.as_deref() == Some("bold")
}
fn style_uses_real_bold_variant(
font_map: &HashMap<String, String>,
node_style: &FormNodeStyle,
) -> bool {
if !is_bold_style(node_style) {
return false;
}
let Some(typeface) = node_style.font_family.as_deref() else {
return false;
};
let vkey = font_variant_key(
typeface,
node_style.font_weight.as_deref(),
node_style.font_style.as_deref(),
);
font_map.contains_key(&vkey)
}
fn emit_synthetic_bold_ops(
font_map: &HashMap<String, String>,
node_style: &FormNodeStyle,
font_size: f64,
text_color: &[f64; 3],
ops: &mut Vec<u8>,
) {
if is_bold_style(node_style) && !style_uses_real_bold_variant(font_map, node_style) {
let stroke_w = font_size * 0.03;
write_ops(
ops,
format_args!(
"2 Tr\n{:.4} w\n{:.3} {:.3} {:.3} RG\n",
stroke_w, text_color[0], text_color[1], text_color[2],
),
);
}
}
fn reset_synthetic_bold_ops(
font_map: &HashMap<String, String>,
node_style: &FormNodeStyle,
ops: &mut Vec<u8>,
) {
if is_bold_style(node_style) && !style_uses_real_bold_variant(font_map, node_style) {
write_ops(ops, format_args!("0 Tr\n"));
}
}
fn reset_text_style_ops(node_style: &FormNodeStyle, ops: &mut Vec<u8>) {
if node_style
.font_horizontal_scale
.is_some_and(|s| (s - 1.0).abs() > 0.001)
{
write_ops(ops, format_args!("100 Tz\n"));
}
if node_style
.letter_spacing_pt
.is_some_and(|s| s.abs() > 0.001)
{
write_ops(ops, format_args!("0 Tc\n"));
}
}
fn ascender_pt(font_metrics: &FontMetrics, font_size: f64) -> f64 {
if let (Some(asc), Some(upem)) = (font_metrics.resolved_ascender, font_metrics.resolved_upem) {
if upem > 0 {
return asc as f64 / upem as f64 * font_size;
}
}
font_size
}
fn build_font_metrics(
font_size: f64,
font_family: FontFamily,
node_style: &FormNodeStyle,
config: &XfaRenderConfig,
) -> FontMetrics {
let mut metrics = FontMetrics {
size: font_size,
typeface: font_family,
..Default::default()
};
if let Some(typeface) = &node_style.font_family {
let vkey = font_variant_key(
typeface,
node_style.font_weight.as_deref(),
node_style.font_style.as_deref(),
);
let data = config
.font_metrics_data
.get(&vkey)
.or_else(|| config.font_metrics_data.get(typeface));
if let Some(data) = data {
metrics.resolved_widths = Some(data.widths.clone());
metrics.resolved_upem = Some(data.upem);
metrics.resolved_ascender = Some(data.ascender);
metrics.resolved_descender = Some(data.descender);
}
}
metrics
}
fn caption_font_for_content(
content: &LayoutContent,
node_style: &FormNodeStyle,
config: &XfaRenderConfig,
) -> (f64, FontFamily) {
match content {
LayoutContent::Field {
font_size,
font_family,
..
}
| LayoutContent::WrappedText {
font_size,
font_family,
..
} => (*font_size, *font_family),
_ => (
node_style.font_size.unwrap_or(config.default_font_size),
FontFamily::SansSerif,
),
}
}
fn effective_caption_reserve(
style: &FormNodeStyle,
font_size: f64,
font_family: FontFamily,
config: &XfaRenderConfig,
) -> f64 {
let caption_text = match style.caption_text.as_deref() {
Some(text) if !text.is_empty() => text,
_ => return 0.0,
};
if let Some(reserve) = style.caption_reserve {
return reserve.max(0.0);
}
let fs = if font_size > 0.0 {
font_size
} else {
config.default_font_size
};
let metrics = build_font_metrics(fs, font_family, style, config);
match style.caption_placement.as_deref().unwrap_or("left") {
"left" | "right" => metrics.measure_width(caption_text),
"top" | "bottom" => metrics.line_height_pt(),
_ => 0.0,
}
}
fn caption_value_offset(
style: &FormNodeStyle,
reserve: f64,
w: f64,
h: f64,
) -> (f64, f64, f64, f64) {
if reserve <= 0.0 || style.caption_text.is_none() {
return (0.0, 0.0, w, h);
}
match style.caption_placement.as_deref().unwrap_or("left") {
"left" => (reserve, 0.0, (w - reserve).max(0.0), h),
"right" => (0.0, 0.0, (w - reserve).max(0.0), h),
"top" => (0.0, reserve, w, (h - reserve).max(0.0)),
"bottom" => (0.0, 0.0, w, (h - reserve).max(0.0)),
_ => (0.0, 0.0, w, h),
}
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
fn render_caption(
x: f64,
pdf_y: f64,
w: f64,
h: f64,
caption_reserve: f64,
font_size: f64,
font_family: FontFamily,
node_style: &FormNodeStyle,
config: &XfaRenderConfig,
ops: &mut Vec<u8>,
caption_only: bool,
) {
let caption_text = match &node_style.caption_text {
Some(t) if !t.is_empty() => t,
_ => return,
};
let caption_placement = node_style.caption_placement.as_deref().unwrap_or("left");
let fs = if font_size > 0.0 {
font_size
} else {
config.default_font_size
};
let metrics = build_font_metrics(fs, font_family, node_style, config);
let font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
let idh_metrics = lookup_font_metrics(node_style, config);
let (cap_x, cap_y, cap_w, cap_h) = match caption_placement {
"left" => (x, pdf_y, caption_reserve, h),
"right" => (x + w - caption_reserve, pdf_y, caption_reserve, h),
"top" => (x, pdf_y + h - caption_reserve, w, caption_reserve),
"bottom" => (x, pdf_y, w, caption_reserve),
_ => (x, pdf_y, caption_reserve, h),
};
let is_multiline = caption_text.contains('\n') || metrics.measure_width(caption_text) > cap_w;
if is_multiline {
let line_height = node_style
.line_height_pt
.unwrap_or_else(|| metrics.line_height_pt());
let pad_left = node_style.margin_left_pt.unwrap_or(0.0);
let pad_right = node_style.margin_right_pt.unwrap_or(0.0);
let text_indent = node_style.text_indent_pt.unwrap_or(0.0);
let usable_w = (cap_w - pad_left - pad_right).max(1.0);
let layout = xfa_layout_engine::text::wrap_text(
caption_text,
usable_w,
&metrics,
text_indent,
node_style.line_height_pt,
);
if layout.lines.is_empty() {
return;
}
let asc_pt = ascender_pt(&metrics, fs);
let space_above = node_style.space_above_pt.unwrap_or(0.0);
let total_text_h = layout.lines.len() as f64 * line_height;
let first_line_pdf_y = match node_style.v_align {
Some(VerticalAlign::Middle) => {
cap_y + cap_h - asc_pt - space_above - (cap_h - space_above - total_text_h) / 2.0
+ (cap_h - space_above - total_text_h) / 2.0
}
Some(VerticalAlign::Bottom) => cap_y + total_text_h - asc_pt,
_ => cap_y + cap_h - asc_pt - space_above,
};
let tc = node_style
.text_color
.map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
.unwrap_or(config.text_color);
write_ops(
ops,
format_args!(
"BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
tc[0], tc[1], tc[2], font_ref, fs
),
);
emit_text_style_ops(node_style, ops);
let text_x_base = cap_x + pad_left;
let mut prev_x = text_x_base;
for (i, line) in layout.lines.iter().enumerate() {
let is_para_start = layout.first_line_of_para.get(i).copied().unwrap_or(false);
let indent = if is_para_start { text_indent } else { 0.0 };
let text_x = text_x_base + indent;
let line_y = first_line_pdf_y - (i as f64 * line_height);
if i == 0 {
write_ops(ops, format_args!("{:.2} {:.2} Td\n", text_x, line_y));
} else {
let dx = text_x - prev_x;
write_ops(ops, format_args!("{:.2} {:.2} Td\n", dx, -line_height));
}
prev_x = text_x;
let encoded = pdf_encode_text(line, idh_metrics);
write_ops(ops, format_args!("{} Tj\n", encoded));
}
reset_text_style_ops(node_style, ops);
ops.extend_from_slice(b"ET\n");
} else {
let asc_pt = ascender_pt(&metrics, fs);
let line_h = metrics.line_height_pt();
let text_y = match node_style.v_align {
Some(VerticalAlign::Middle) if caption_only => {
cap_y + (cap_h - line_h) / 2.0
}
Some(VerticalAlign::Bottom) => cap_y,
_ if caption_only && cap_h > line_h + 1.0 => {
cap_y + (cap_h - line_h) / 2.0
}
_ => cap_y + cap_h - asc_pt,
};
let encoded = pdf_encode_text(caption_text, idh_metrics);
write_ops(
ops,
format_args!(
"BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
config.text_color[0], config.text_color[1], config.text_color[2], font_ref, fs,
),
);
emit_text_style_ops(node_style, ops);
write_ops(
ops,
format_args!("{:.2} {:.2} Td\n{} Tj\n", cap_x, text_y, encoded),
);
reset_text_style_ops(node_style, ops);
ops.extend_from_slice(b"ET\n");
}
}
#[allow(clippy::too_many_arguments)]
fn render_field(
x: f64,
pdf_y: f64,
w: f64,
h: f64,
value: &str,
font_size: f64,
font_family: FontFamily,
node_style: &FormNodeStyle,
config: &XfaRenderConfig,
ops: &mut Vec<u8>,
) {
let border_radius = node_style.border_radius_pt.unwrap_or(0.0);
let border_style = node_style.border_style.as_deref();
if !config.field_values_only {
if let Some(bg) = config.background_color {
write_ops(
ops,
format_args!("{:.3} {:.3} {:.3} rg\n", bg[0], bg[1], bg[2]),
);
emit_rect_path(ops, x, pdf_y, w, h, border_radius);
ops.extend_from_slice(b"f\n");
}
if config.draw_borders && config.border_width > 0.0 {
if matches!(border_style, Some("lowered") | Some("raised")) {
emit_3d_border(ops, x, pdf_y, w, h, config.border_width, border_style);
} else {
write_ops(
ops,
format_args!(
"{:.2} w\n{:.3} {:.3} {:.3} RG\n",
config.border_width,
config.border_color[0],
config.border_color[1],
config.border_color[2],
),
);
let per_edge = node_style.border_colors.map(|cs| {
cs.map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
});
let per_edge_widths = node_style.border_widths.as_ref();
apply_border_dash(ops, border_style);
let edges = node_style.border_edges;
if per_edge.is_some() || per_edge_widths.is_some() {
emit_individual_edges(
ops,
x,
pdf_y,
w,
h,
&edges,
per_edge.as_ref(),
per_edge_widths,
config.border_width,
);
} else if edges[0] && edges[1] && edges[2] && edges[3] {
emit_rect_path(ops, x, pdf_y, w, h, border_radius);
ops.extend_from_slice(b"S\n");
} else {
emit_individual_edges(
ops,
x,
pdf_y,
w,
h,
&edges,
None,
None,
config.border_width,
);
}
reset_border_dash(ops, border_style);
}
}
} if !value.is_empty() {
let fs = if font_size > 0.0 {
font_size
} else {
config.default_font_size
};
let pad_left = node_style.margin_left_pt.unwrap_or(0.0);
let pad_right = node_style.margin_right_pt.unwrap_or(0.0);
let space_above = node_style.space_above_pt.unwrap_or(0.0);
let content_w = (w - pad_left - pad_right).max(0.0);
let metrics = build_font_metrics(fs, font_family, node_style, config);
let font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
let text_w = metrics.measure_width(value);
let idh_metrics = lookup_font_metrics(node_style, config);
if text_w <= content_w || content_w <= 0.0 {
let line_h = metrics.line_height_pt();
let asc_pt = ascender_pt(&metrics, fs);
let text_y = match node_style.v_align {
Some(VerticalAlign::Middle) => {
pdf_y + space_above + (h - space_above - line_h) / 2.0
}
Some(VerticalAlign::Bottom) => pdf_y + space_above,
_ => pdf_y + h - space_above - asc_pt,
};
let encoded = pdf_encode_text(value, idh_metrics);
write_ops(
ops,
format_args!(
"BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
config.text_color[0], config.text_color[1], config.text_color[2], font_ref, fs,
),
);
emit_synthetic_bold_ops(&config.font_map, node_style, fs, &config.text_color, ops);
emit_text_style_ops(node_style, ops);
write_ops(
ops,
format_args!("{:.2} {:.2} Td\n{} Tj\n", x + pad_left, text_y, encoded),
);
reset_text_style_ops(node_style, ops);
reset_synthetic_bold_ops(&config.font_map, node_style, ops);
ops.extend_from_slice(b"ET\n");
} else {
let lines = wrap_text(value, content_w, &metrics);
let line_height = metrics.line_height_pt();
let asc_pt = ascender_pt(&metrics, fs);
let total_content_h = lines.len() as f64 * line_height;
let text_start_y = match node_style.v_align {
Some(VerticalAlign::Middle) => {
pdf_y + space_above + (h - space_above - total_content_h) / 2.0
}
Some(VerticalAlign::Bottom) => pdf_y + space_above,
_ => pdf_y + h - space_above - total_content_h,
};
write_ops(
ops,
format_args!(
"BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
config.text_color[0], config.text_color[1], config.text_color[2], font_ref, fs,
),
);
emit_synthetic_bold_ops(&config.font_map, node_style, fs, &config.text_color, ops);
emit_text_style_ops(node_style, ops);
write_ops(
ops,
format_args!(
"{:.2} {:.2} Td\n",
x + pad_left,
text_start_y + total_content_h - asc_pt,
),
);
for (i, line) in lines.iter().enumerate() {
if i > 0 {
write_ops(ops, format_args!("0 {:.2} Td\n", -line_height));
}
let line_top = h - space_above - asc_pt - (i as f64 * line_height);
if line_top < 0.0 {
break;
}
let encoded = pdf_encode_text(line, idh_metrics);
write_ops(ops, format_args!("{} Tj\n", encoded));
}
reset_text_style_ops(node_style, ops);
reset_synthetic_bold_ops(&config.font_map, node_style, ops);
ops.extend_from_slice(b"ET\n");
}
}
}
#[allow(clippy::too_many_arguments)]
fn draw_check_mark(
mark: &str,
x: f64,
y: f64,
w: f64,
h: f64,
m: f64,
color: [f64; 3],
ops: &mut Vec<u8>,
) {
write_ops(
ops,
format_args!(
"{:.3} {:.3} {:.3} RG\n{:.3} {:.3} {:.3} rg\n",
color[0], color[1], color[2], color[0], color[1], color[2]
),
);
let cx = x + w / 2.0;
let cy = y + h / 2.0;
match mark {
"check" => {
let lw = (w.min(h) * 0.08).max(0.5);
write_ops(ops, format_args!("{:.2} w\n", lw));
write_ops(
ops,
format_args!(
"{:.2} {:.2} m\n{:.2} {:.2} l\n{:.2} {:.2} l\nS\n",
x + m,
cy,
cx - m * 0.3,
y + m,
x + w - m,
y + h - m,
),
);
}
"circle" => {
let r = (w.min(h) / 2.0 - m).max(1.0);
let k = r * 0.5523; write_ops(ops, format_args!(
"{:.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",
cx + r, cy,
cx + r, cy + k, cx + k, cy + r, cx, cy + r,
cx - k, cy + r, cx - r, cy + k, cx - r, cy,
cx - r, cy - k, cx - k, cy - r, cx, cy - r,
cx + k, cy - r, cx + r, cy - k, cx + r, cy,
));
}
"diamond" => {
let d = (w.min(h) / 2.0 - m).max(1.0);
write_ops(
ops,
format_args!(
"{:.2} {:.2} m\n{:.2} {:.2} l\n{:.2} {:.2} l\n{:.2} {:.2} l\nf\n",
cx,
cy + d,
cx - d,
cy,
cx,
cy - d,
cx + d,
cy,
),
);
}
"square" => {
let s = (w.min(h) - 2.0 * m).max(1.0);
write_ops(
ops,
format_args!("{:.2} {:.2} {:.2} {:.2} re\nf\n", x + m, y + m, s, s,),
);
}
"star" => {
let lw = (w.min(h) * 0.08).max(0.5);
write_ops(ops, format_args!("{:.2} w\n", lw));
write_ops(
ops,
format_args!(
"{:.2} {:.2} m\n{:.2} {:.2} l\nS\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\n",
x + m,
y + m,
x + w - m,
y + h - m,
x + w - m,
y + m,
x + m,
y + h - m,
),
);
write_ops(
ops,
format_args!(
"{:.2} {:.2} m\n{:.2} {:.2} l\nS\n",
cx,
y + m * 0.5,
cx,
y + h - m * 0.5,
),
);
}
_ => {
let lw = (w.min(h) * 0.08).max(0.5);
write_ops(ops, format_args!("{:.2} w\n", lw));
write_ops(
ops,
format_args!(
"{:.2} {:.2} m\n{:.2} {:.2} l\nS\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\n",
x + m,
y + m,
x + w - m,
y + h - m,
x + w - m,
y + m,
x + m,
y + h - m,
),
);
}
}
}
#[allow(clippy::too_many_arguments)]
fn render_checkbox(
x: f64,
pdf_y: f64,
w: f64,
h: f64,
value: &str,
node_style: &FormNodeStyle,
config: &XfaRenderConfig,
ops: &mut Vec<u8>,
) {
let bw = config.border_width;
write_ops(ops, format_args!("q\n"));
if let Some((r_u8, g_u8, b_u8)) = node_style.bg_color {
let bg = [
r_u8 as f64 / 255.0,
g_u8 as f64 / 255.0,
b_u8 as f64 / 255.0,
];
write_ops(
ops,
format_args!(
"{:.3} {:.3} {:.3} rg\n{:.2} {:.2} {:.2} {:.2} re\nf\n",
bg[0], bg[1], bg[2], x, pdf_y, w, h
),
);
}
write_ops(
ops,
format_args!(
"{:.2} w\n{:.3} {:.3} {:.3} RG\n{:.2} {:.2} {:.2} {:.2} re\nS\n",
bw,
config.border_color[0],
config.border_color[1],
config.border_color[2],
x,
pdf_y,
w,
h
),
);
let checked = is_check_button_checked(value, FieldKind::Checkbox, node_style);
if checked {
let mark = config
.check_button_mark
.as_deref()
.unwrap_or(default_check_button_mark(FieldKind::Checkbox));
let m = w.min(h) * 0.15;
let color = config.text_color;
draw_check_mark(mark, x, pdf_y, w, h, m, color, ops);
}
write_ops(ops, format_args!("Q\n"));
}
#[allow(clippy::too_many_arguments)]
fn render_radio(
x: f64,
pdf_y: f64,
w: f64,
h: f64,
value: &str,
node_style: &FormNodeStyle,
config: &XfaRenderConfig,
ops: &mut Vec<u8>,
) {
let bw = config.border_width;
let cx = x + w / 2.0;
let cy = pdf_y + h / 2.0;
let r = w.min(h) / 2.0;
let k = 0.5523; let kx = r * k;
let ky = r * k;
write_ops(ops, format_args!("q\n",));
if let Some((r_u8, g_u8, b_u8)) = node_style.bg_color {
let bg = [
r_u8 as f64 / 255.0,
g_u8 as f64 / 255.0,
b_u8 as f64 / 255.0,
];
write_ops(
ops,
format_args!(
"{:.3} {:.3} {:.3} rg\n\
{:.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\n\
f\n",
bg[0],
bg[1],
bg[2],
cx + r,
cy,
cx + r,
cy + ky,
cx + kx,
cy + r,
cx,
cy + r,
cx - kx,
cy + r,
cx - r,
cy + ky,
cx - r,
cy,
cx - r,
cy - ky,
cx - kx,
cy - r,
cx,
cy - r,
cx + kx,
cy - r,
cx + r,
cy - ky,
cx + r,
cy,
),
);
}
write_ops(
ops,
format_args!(
"{:.2} w\n{:.3} {:.3} {:.3} RG\n",
bw, config.border_color[0], config.border_color[1], config.border_color[2],
),
);
write_ops(
ops,
format_args!(
"{:.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\n\
S\n",
cx + r,
cy,
cx + r,
cy + ky,
cx + kx,
cy + r,
cx,
cy + r,
cx - kx,
cy + r,
cx - r,
cy + ky,
cx - r,
cy,
cx - r,
cy - ky,
cx - kx,
cy - r,
cx,
cy - r,
cx + kx,
cy - r,
cx + r,
cy - ky,
cx + r,
cy,
),
);
let checked = is_check_button_checked(value, FieldKind::Radio, node_style);
if checked {
let mark = config
.check_button_mark
.as_deref()
.unwrap_or(default_check_button_mark(FieldKind::Radio));
if mark == "circle" {
let ir = r * 0.4;
let ikx = ir * k;
let iky = ir * k;
write_ops(
ops,
format_args!(
"{:.3} {:.3} {:.3} rg\n\
{:.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\n\
f\n",
config.text_color[0],
config.text_color[1],
config.text_color[2],
cx + ir,
cy,
cx + ir,
cy + iky,
cx + ikx,
cy + ir,
cx,
cy + ir,
cx - ikx,
cy + ir,
cx - ir,
cy + iky,
cx - ir,
cy,
cx - ir,
cy - iky,
cx - ikx,
cy - ir,
cx,
cy - ir,
cx + ikx,
cy - ir,
cx + ir,
cy - iky,
cx + ir,
cy,
),
);
} else {
let m = w.min(h) * 0.15;
draw_check_mark(mark, x, pdf_y, w, h, m, config.text_color, ops);
}
}
write_ops(ops, format_args!("Q\n"));
}
fn default_check_button_mark(field_kind: FieldKind) -> &'static str {
match field_kind {
FieldKind::Radio => "circle",
_ => "cross",
}
}
fn is_check_button_checked(value: &str, field_kind: FieldKind, node_style: &FormNodeStyle) -> bool {
let on_value = node_style.check_button_on_value.as_deref().unwrap_or("1");
let off_value = node_style.check_button_off_value.as_deref().unwrap_or("");
let neutral_value = node_style
.check_button_neutral_value
.as_deref()
.unwrap_or("");
if value == on_value {
return true;
}
if value == off_value {
return false;
}
if field_kind == FieldKind::Checkbox && value == neutral_value {
return false;
}
false
}
#[allow(clippy::too_many_arguments)]
fn render_dropdown(
x: f64,
pdf_y: f64,
w: f64,
h: f64,
value: &str,
font_size: f64,
font_family: FontFamily,
node_style: &FormNodeStyle,
config: &XfaRenderConfig,
ops: &mut Vec<u8>,
display_items: &[String],
save_items: &[String],
) {
let display_value = if let Some(idx) = save_items.iter().position(|s| s == value) {
display_items.get(idx).map(|s| s.as_str()).unwrap_or(value)
} else {
value
};
if display_value.is_empty() {
return;
}
let border_radius = node_style.border_radius_pt.unwrap_or(0.0);
if let Some(bg) = &config.background_color {
write_ops(
ops,
format_args!("{:.3} {:.3} {:.3} rg\n", bg[0], bg[1], bg[2]),
);
emit_rect_path(ops, x, pdf_y, w, h, border_radius);
ops.extend_from_slice(b"f\n");
}
if config.draw_borders && config.border_width > 0.0 {
write_ops(
ops,
format_args!(
"{:.2} w\n{:.3} {:.3} {:.3} RG\n",
config.border_width,
config.border_color[0],
config.border_color[1],
config.border_color[2],
),
);
emit_rect_path(ops, x, pdf_y, w, h, border_radius);
ops.extend_from_slice(b"S\n");
}
let arrow_w = h.min(12.0);
if !display_value.is_empty() {
let fs = if font_size > 0.0 {
font_size
} else {
config.default_font_size
};
let _metrics = build_font_metrics(fs, font_family, node_style, config);
let font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
let idh_metrics = lookup_font_metrics(node_style, config);
let v_offset = pdf_y + h / 2.0 - fs / 2.0;
let encoded = pdf_encode_text(display_value, idh_metrics);
write_ops(
ops,
format_args!(
"BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
config.text_color[0], config.text_color[1], config.text_color[2], font_ref, fs,
),
);
emit_synthetic_bold_ops(&config.font_map, node_style, fs, &config.text_color, ops);
emit_text_style_ops(node_style, ops);
write_ops(
ops,
format_args!("{:.2} {:.2} Td\n{} Tj\n", x + 2.0, v_offset, encoded),
);
reset_text_style_ops(node_style, ops);
reset_synthetic_bold_ops(&config.font_map, node_style, ops);
ops.extend_from_slice(b"ET\n");
}
let arrow_x = x + w - arrow_w - 1.0;
let arrow_y_center = pdf_y + h / 2.0;
let arrow_size = arrow_w * 0.6;
let arrow_char = "\u{25BC}";
write_ops(
ops,
format_args!(
"BT\n/F2 {:.1} Tf\n{:.2} {:.2} Td\n({}) Tj\nET\n",
arrow_size,
arrow_x,
arrow_y_center - arrow_size / 2.0,
arrow_char
),
);
}
#[allow(clippy::too_many_arguments)]
fn render_button(
x: f64,
pdf_y: f64,
w: f64,
h: f64,
value: &str,
font_size: f64,
font_family: FontFamily,
node_style: &FormNodeStyle,
config: &XfaRenderConfig,
ops: &mut Vec<u8>,
) {
if value.is_empty() {
return;
}
let border_radius = node_style.border_radius_pt.unwrap_or(0.0);
let bw = config.border_width.max(0.0);
let fill_color = if let Some((r, g, b)) = node_style.bg_color {
[r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0]
} else {
[
(config.border_color[0] + 0.3).min(1.0),
(config.border_color[1] + 0.3).min(1.0),
(config.border_color[2] + 0.3).min(1.0),
]
};
let border_color = node_style
.border_color
.map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
.unwrap_or([
fill_color[0] * 0.6,
fill_color[1] * 0.6,
fill_color[2] * 0.6,
]);
write_ops(
ops,
format_args!(
"q\n{:.3} {:.3} {:.3} rg\n",
fill_color[0], fill_color[1], fill_color[2]
),
);
emit_rect_path(ops, x, pdf_y, w, h, border_radius);
ops.extend_from_slice(b"f\n");
write_ops(
ops,
format_args!(
"{:.3} {:.3} {:.3} RG\n{:.2} w\n",
border_color[0], border_color[1], border_color[2], bw
),
);
emit_rect_path(ops, x, pdf_y, w, h, border_radius);
ops.extend_from_slice(b"S\n");
if !value.is_empty() {
let fs = if font_size > 0.0 {
font_size
} else {
config.default_font_size
};
let metrics = build_font_metrics(fs, font_family, node_style, config);
let font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
let idh_metrics = lookup_font_metrics(node_style, config);
let text_w = metrics.measure_width(value);
let text_x = x + (w - text_w) / 2.0;
let v_offset = pdf_y + h / 2.0 - fs / 2.0;
let encoded = pdf_encode_text(value, idh_metrics);
let tc = node_style
.text_color
.map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
.unwrap_or(config.text_color);
write_ops(
ops,
format_args!(
"BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
tc[0], tc[1], tc[2], font_ref, fs,
),
);
emit_text_style_ops(node_style, ops);
write_ops(
ops,
format_args!("{:.2} {:.2} Td\n{} Tj\n", text_x, v_offset, encoded),
);
reset_text_style_ops(node_style, ops);
ops.extend_from_slice(b"ET\n");
}
ops.extend_from_slice(b"Q\n");
}
#[allow(clippy::too_many_arguments)]
fn render_signature(
x: f64,
pdf_y: f64,
w: f64,
h: f64,
value: &str,
node_style: &FormNodeStyle,
config: &XfaRenderConfig,
ops: &mut Vec<u8>,
) {
if value.is_empty() {
return;
}
let border_radius = node_style.border_radius_pt.unwrap_or(0.0);
if let Some(bg) = &config.background_color {
write_ops(
ops,
format_args!("{:.3} {:.3} {:.3} rg\n", bg[0], bg[1], bg[2]),
);
emit_rect_path(ops, x, pdf_y, w, h, border_radius);
ops.extend_from_slice(b"f\n");
}
write_ops(
ops,
format_args!(
"{:.2} w\n{:.3} {:.3} {:.3} RG\n",
config.border_width,
config.border_color[0],
config.border_color[1],
config.border_color[2],
),
);
write_ops(ops, format_args!("[4 2] 0 d\n"));
emit_rect_path(ops, x, pdf_y, w, h, border_radius);
ops.extend_from_slice(b"S\n");
write_ops(ops, format_args!("[] 0 d\n"));
if !value.is_empty() {
let fs = node_style.font_size.unwrap_or(config.default_font_size);
let text_x = x + node_style.margin_left_pt.unwrap_or(0.0);
let v_offset = pdf_y + h / 2.0 - fs / 2.0;
write_ops(
ops,
format_args!(
"BT\n{:.3} {:.3} {:.3} rg\n/F1 {:.1} Tf\n{:.2} {:.2} Td\n({}) Tj\nET\n",
config.text_color[0],
config.text_color[1],
config.text_color[2],
fs,
text_x,
v_offset,
pdf_escape(value)
),
);
}
}
#[allow(clippy::too_many_arguments)]
fn render_text(
x: f64,
pdf_y: f64,
_w: f64,
h: f64,
text: &str,
node_style: &FormNodeStyle,
config: &XfaRenderConfig,
ops: &mut Vec<u8>,
) {
if text.is_empty() {
return;
}
let fs = node_style.font_size.unwrap_or(config.default_font_size);
let p = node_style.margin_left_pt.unwrap_or(0.0);
let font_family = match node_style.font_family.as_deref() {
Some(f) if f.contains("Courier") || f.contains("Mono") => FontFamily::Monospace,
Some(f)
if f.contains("Helvetica")
|| f.contains("Arial")
|| f.contains("Sans")
|| f.contains("Myriad") =>
{
FontFamily::SansSerif
}
_ => FontFamily::Serif,
};
let font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
let tc = node_style
.text_color
.map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
.unwrap_or(config.text_color);
let metrics = build_font_metrics(fs, font_family, node_style, config);
let asc_pt = ascender_pt(&metrics, fs);
let line_h = metrics.line_height_pt();
let idh_metrics = lookup_font_metrics(node_style, config);
let encoded = pdf_encode_text(text, idh_metrics);
let desc_pt =
if let (Some(desc), Some(upem)) = (metrics.resolved_descender, metrics.resolved_upem) {
if upem > 0 {
desc as f64 / upem as f64 * fs
} else {
fs * 0.2
}
} else {
fs * 0.2
};
let text_y = match node_style.v_align {
Some(VerticalAlign::Middle) => pdf_y + (h - line_h) / 2.0,
Some(VerticalAlign::Bottom) => pdf_y + desc_pt,
_ => pdf_y + h - p - asc_pt,
};
write_ops(
ops,
format_args!(
"BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
tc[0], tc[1], tc[2], font_ref, fs,
),
);
emit_synthetic_bold_ops(&config.font_map, node_style, fs, &tc, ops);
write_ops(
ops,
format_args!("{:.2} {:.2} Td\n{} Tj\n", x + p, text_y, encoded),
);
reset_synthetic_bold_ops(&config.font_map, node_style, ops);
ops.extend_from_slice(b"ET\n");
let text_x = x + p;
let text_y = pdf_y + p;
let line_thickness = (fs * 0.05).max(0.5);
if node_style.underline {
let underline_y = text_y - desc_pt;
let text_w = metrics.measure_width(text);
write_ops(
ops,
format_args!(
"BT\n{:.3} w\n{:.3} {:.3} {:.3} RG\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nET\n",
line_thickness,
tc[0],
tc[1],
tc[2],
text_x,
underline_y,
text_x + text_w,
underline_y,
),
);
}
if node_style.line_through {
let mid_y = text_y + fs * 0.5 - asc_pt * 0.1;
let text_w = metrics.measure_width(text);
write_ops(
ops,
format_args!(
"BT\n{:.3} w\n{:.3} {:.3} {:.3} RG\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nET\n",
line_thickness,
tc[0],
tc[1],
tc[2],
text_x,
mid_y,
text_x + text_w,
mid_y,
),
);
}
}
#[allow(clippy::too_many_arguments)]
fn render_multiline(
x: f64,
_pdf_y: f64,
container_width: f64,
container_height: f64,
lines: &[String],
first_line_of_para: &[bool],
font_size: f64,
text_align: TextAlign,
font_family: FontFamily,
_is_bold: bool,
mapper: &CoordinateMapper,
abs_y_xfa: f64,
node_style: &FormNodeStyle,
config: &XfaRenderConfig,
ops: &mut Vec<u8>,
) {
if lines.is_empty() {
return;
}
let pad_left = node_style.margin_left_pt.unwrap_or(0.0);
let pad_right = node_style.margin_right_pt.unwrap_or(0.0);
let space_above = node_style.space_above_pt.unwrap_or(0.0);
let text_indent = node_style.text_indent_pt.unwrap_or(0.0);
let font_metrics = build_font_metrics(font_size, font_family, node_style, config);
let line_height = node_style
.line_height_pt
.unwrap_or_else(|| font_metrics.line_height_pt());
let font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
let tc = node_style
.text_color
.map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
.unwrap_or(config.text_color);
write_ops(
ops,
format_args!(
"BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
tc[0], tc[1], tc[2], font_ref, font_size
),
);
emit_synthetic_bold_ops(&config.font_map, node_style, font_size, &tc, ops);
emit_text_style_ops(node_style, ops);
let ascender_pt = if let (Some(asc), Some(upem)) =
(font_metrics.resolved_ascender, font_metrics.resolved_upem)
{
if upem > 0 {
asc as f64 / upem as f64 * font_size
} else {
font_size
}
} else {
font_size
};
let total_text_h = lines.len() as f64 * line_height;
let first_line_y_xfa = match node_style.v_align {
Some(VerticalAlign::Middle) => {
abs_y_xfa
+ space_above
+ (container_height - space_above - total_text_h) / 2.0
+ ascender_pt
}
Some(VerticalAlign::Bottom) => abs_y_xfa + container_height - total_text_h + ascender_pt,
_ => abs_y_xfa + space_above + ascender_pt,
};
let first_line_pdf_y = mapper.xfa_to_pdf_y(first_line_y_xfa, 0.0);
let content_w = (container_width - pad_left - pad_right).max(0.0);
let idh_metrics = lookup_font_metrics(node_style, config);
let mut prev_x = x + pad_left;
for (i, line) in lines.iter().enumerate() {
let is_para_start = first_line_of_para.get(i).copied().unwrap_or(false);
let indent_offset = if is_para_start { text_indent } else { 0.0 };
let line_y = first_line_pdf_y - (i as f64 * line_height);
let line_w = font_metrics.measure_width(line);
let text_x = match text_align {
TextAlign::Center => {
x + pad_left + indent_offset + ((content_w - indent_offset - line_w) / 2.0).max(0.0)
}
TextAlign::Right => x + pad_left + (content_w - line_w).max(0.0),
_ => x + pad_left + indent_offset,
};
if i == 0 {
write_ops(ops, format_args!("{:.2} {:.2} Td\n", text_x, line_y));
} else {
let dx = text_x - prev_x;
write_ops(ops, format_args!("{:.2} {:.2} Td\n", dx, -line_height));
}
prev_x = text_x;
let encoded = pdf_encode_text(line, idh_metrics);
write_ops(ops, format_args!("{} Tj\n", encoded));
}
reset_text_style_ops(node_style, ops);
reset_synthetic_bold_ops(&config.font_map, node_style, ops);
ops.extend_from_slice(b"ET\n");
}
#[allow(clippy::too_many_arguments)]
fn render_rich_multiline(
x: f64,
container_width: f64,
container_height: f64,
lines: &[String],
first_line_of_para: &[bool],
spans: &[RichTextSpan],
font_size: f64,
text_align: TextAlign,
font_family: FontFamily,
mapper: &CoordinateMapper,
abs_y_xfa: f64,
node_style: &FormNodeStyle,
config: &XfaRenderConfig,
ops: &mut Vec<u8>,
) {
if lines.is_empty() || spans.is_empty() {
return;
}
let pad_left = node_style.margin_left_pt.unwrap_or(0.0);
let pad_right = node_style.margin_right_pt.unwrap_or(0.0);
let space_above = node_style.space_above_pt.unwrap_or(0.0);
let text_indent = node_style.text_indent_pt.unwrap_or(0.0);
let font_metrics = build_font_metrics(font_size, font_family, node_style, config);
let line_height = node_style
.line_height_pt
.unwrap_or_else(|| font_metrics.line_height_pt());
let asc_pt = if let (Some(asc), Some(upem)) =
(font_metrics.resolved_ascender, font_metrics.resolved_upem)
{
if upem > 0 {
asc as f64 / upem as f64 * font_size
} else {
font_size
}
} else {
font_size
};
let content_w = (container_width - pad_left - pad_right).max(0.0);
let line_segments = map_spans_to_lines(spans, lines);
let leading_ratio = if font_size > 0.0 {
line_height / font_size
} else {
1.0
};
let explicit_line_height = node_style.line_height_pt.is_some();
let line_max_fs: Vec<f64> = lines
.iter()
.enumerate()
.map(|(i, _)| {
let mut max_fs = font_size;
if let Some(segs) = line_segments.get(i) {
for seg in segs {
let span = &spans[seg.span_idx];
let span_fs = span.font_size.unwrap_or(font_size);
if span_fs > max_fs {
max_fs = span_fs;
}
}
}
max_fs
})
.collect();
let line_pair_height = |i: usize| -> f64 {
if explicit_line_height || i == 0 {
line_height
} else {
let adj = line_max_fs[i - 1].max(line_max_fs[i]);
(adj * leading_ratio).max(line_height)
}
};
let total_text_h: f64 = if lines.is_empty() {
0.0
} else {
line_height + (1..lines.len()).map(line_pair_height).sum::<f64>()
};
let first_line_y_xfa = match node_style.v_align {
Some(VerticalAlign::Middle) => {
abs_y_xfa + space_above + (container_height - space_above - total_text_h) / 2.0 + asc_pt
}
Some(VerticalAlign::Bottom) => abs_y_xfa + container_height - total_text_h + asc_pt,
_ => abs_y_xfa + space_above + asc_pt,
};
let first_line_pdf_y = mapper.xfa_to_pdf_y(first_line_y_xfa, 0.0);
ops.extend_from_slice(b"BT\n");
let base_font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
let base_tc = node_style
.text_color
.map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
.unwrap_or(config.text_color);
let idh_metrics = lookup_font_metrics(node_style, config);
let mut cur_font_ref = base_font_ref;
let mut cur_fs = font_size;
let mut cur_tc = base_tc;
write_ops(
ops,
format_args!(
"{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
cur_tc[0], cur_tc[1], cur_tc[2], cur_font_ref, cur_fs,
),
);
emit_text_style_ops(node_style, ops);
let mut prev_x = x + pad_left;
let mut line_y_cum = first_line_pdf_y;
for (i, line) in lines.iter().enumerate() {
let is_para_start = first_line_of_para.get(i).copied().unwrap_or(false);
let indent_offset = if is_para_start { text_indent } else { 0.0 };
let pair_h = line_pair_height(i);
if i > 0 {
line_y_cum -= pair_h;
}
let line_y = line_y_cum;
let line_w = font_metrics.measure_width(line);
let text_x = match text_align {
TextAlign::Center => {
x + pad_left + indent_offset + ((content_w - indent_offset - line_w) / 2.0).max(0.0)
}
TextAlign::Right => x + pad_left + (content_w - line_w).max(0.0),
_ => x + pad_left + indent_offset,
};
if i == 0 {
write_ops(ops, format_args!("{:.2} {:.2} Td\n", text_x, line_y));
} else {
let dx = text_x - prev_x;
write_ops(ops, format_args!("{:.2} {:.2} Td\n", dx, -pair_h));
}
prev_x = text_x;
if let Some(segs) = line_segments.get(i) {
if segs.is_empty() {
let encoded = pdf_encode_text(line, idh_metrics);
write_ops(ops, format_args!("{} Tj\n", encoded));
continue;
}
for seg in segs {
let span = &spans[seg.span_idx];
let span_family = span
.font_family
.as_deref()
.map(classify_font_family)
.unwrap_or(font_family);
let span_style = span_to_node_style(span, node_style);
let span_font_ref = resolve_font_ref(&config.font_map, &span_style, span_family);
let span_fs = span.font_size.unwrap_or(font_size);
let span_tc = span
.text_color
.map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
.unwrap_or(base_tc);
if span_font_ref != cur_font_ref || (span_fs - cur_fs).abs() > 0.01 {
write_ops(ops, format_args!("{} {:.1} Tf\n", span_font_ref, span_fs));
cur_font_ref = span_font_ref;
cur_fs = span_fs;
}
if (span_tc[0] - cur_tc[0]).abs() > 0.001
|| (span_tc[1] - cur_tc[1]).abs() > 0.001
|| (span_tc[2] - cur_tc[2]).abs() > 0.001
{
write_ops(
ops,
format_args!("{:.3} {:.3} {:.3} rg\n", span_tc[0], span_tc[1], span_tc[2]),
);
cur_tc = span_tc;
}
let is_span_bold = span.font_weight.as_deref() == Some("bold");
let span_has_real_bold =
style_uses_real_bold_variant(&config.font_map, &span_style);
if is_span_bold && !span_has_real_bold {
let stroke_w = span_fs * 0.03;
write_ops(
ops,
format_args!(
"2 Tr\n{:.4} w\n{:.3} {:.3} {:.3} RG\n",
stroke_w, span_tc[0], span_tc[1], span_tc[2],
),
);
}
let encoded = pdf_encode_text(&seg.text, idh_metrics);
write_ops(ops, format_args!("{} Tj\n", encoded));
if is_span_bold && !span_has_real_bold {
write_ops(ops, format_args!("0 Tr\n"));
}
}
} else {
let encoded = pdf_encode_text(line, idh_metrics);
write_ops(ops, format_args!("{} Tj\n", encoded));
}
}
reset_text_style_ops(node_style, ops);
ops.extend_from_slice(b"ET\n");
}
fn span_to_node_style(span: &RichTextSpan, base: &FormNodeStyle) -> FormNodeStyle {
let mut style = base.clone();
if let Some(ref fam) = span.font_family {
style.font_family = Some(fam.clone());
}
if let Some(ref w) = span.font_weight {
style.font_weight = Some(w.clone());
}
if let Some(ref s) = span.font_style {
style.font_style = Some(s.clone());
}
style
}
fn classify_font_family(name: &str) -> FontFamily {
if name.contains("Courier") || name.contains("Mono") {
FontFamily::Monospace
} else if name.contains("Helvetica")
|| name.contains("Arial")
|| name.contains("Sans")
|| name.contains("Myriad")
{
FontFamily::SansSerif
} else {
FontFamily::Serif
}
}
struct LineSpanSegment {
text: String,
span_idx: usize,
}
fn leading_whitespace_len(s: &str) -> usize {
s.char_indices()
.find(|(_, ch)| !ch.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(s.len())
}
fn map_spans_to_lines(spans: &[RichTextSpan], lines: &[String]) -> Vec<Vec<LineSpanSegment>> {
let mut result = Vec::with_capacity(lines.len());
let mut span_idx = 0_usize;
let mut span_off = 0_usize;
for line in lines {
while span_idx < spans.len() {
if spans[span_idx].text == "\n" || span_off >= spans[span_idx].text.len() {
span_idx += 1;
span_off = 0;
} else {
break;
}
}
let mut segs: Vec<LineSpanSegment> = Vec::new();
let mut line_pos = 0_usize;
while line_pos < line.len() && span_idx < spans.len() {
let span = &spans[span_idx];
if span.text == "\n" {
span_idx += 1;
span_off = 0;
continue;
}
let span_rest = &span.text[span_off..];
let line_rest = &line[line_pos..];
let common = line_rest
.chars()
.zip(span_rest.chars())
.take_while(|(a, b)| a == b || (a.is_whitespace() && b.is_whitespace()))
.count();
if common > 0 {
let common_str: String = line_rest.chars().take(common).collect();
let common_line_byte_len = common_str.len();
let common_span_byte_len: usize =
span_rest.chars().take(common).map(char::len_utf8).sum();
segs.push(LineSpanSegment {
text: common_str,
span_idx,
});
line_pos += common_line_byte_len;
span_off += common_span_byte_len;
if span_off >= span.text.len() {
span_idx += 1;
span_off = 0;
}
} else {
let span_skip = leading_whitespace_len(span_rest);
if span_skip > 0 {
span_off += span_skip;
if span_off >= span.text.len() {
span_idx += 1;
span_off = 0;
}
continue;
}
let line_skip = leading_whitespace_len(line_rest);
if line_skip > 0 {
segs.push(LineSpanSegment {
text: line_rest[..line_skip].to_string(),
span_idx,
});
line_pos += line_skip;
} else {
segs.push(LineSpanSegment {
text: line_rest.to_string(),
span_idx: 0,
});
break;
}
}
}
result.push(segs);
while span_idx < spans.len() {
let span = &spans[span_idx];
if span.text == "\n" {
break;
}
let rest = &span.text[span_off..];
let skip = leading_whitespace_len(rest);
if skip > 0 {
span_off += skip;
if span_off >= span.text.len() {
span_idx += 1;
span_off = 0;
}
} else {
break;
}
}
}
result
}
fn wrap_text(text: &str, max_width: f64, metrics: &FontMetrics) -> Vec<String> {
let mut lines = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
if current.is_empty() {
current = word.to_string();
} else {
let candidate = format!("{} {}", current, word);
if metrics.measure_width(&candidate) <= max_width {
current = candidate;
} else {
lines.push(current);
current = word.to_string();
}
}
}
if !current.is_empty() {
lines.push(current);
}
if lines.is_empty() && !text.is_empty() {
lines.push(text.to_string());
}
lines
}
fn push_pdf_string_byte(out: &mut String, b: u8) {
match b {
b'(' => out.push_str("\\("),
b')' => out.push_str("\\)"),
b'\\' => out.push_str("\\\\"),
0x20..=0x7E => out.push(b as char),
_ => {
use std::fmt::Write;
let _ = write!(out, "\\{:03o}", b);
}
}
}
fn pdf_escape_with_simple_encoding(s: &str, unicode_to_code: Option<&HashMap<u16, u8>>) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if let Some(map) = unicode_to_code {
let mapped = u16::try_from(c as u32)
.ok()
.and_then(|cp| map.get(&cp).copied())
.or_else(|| unicode_to_winansi(c));
if let Some(b) = mapped {
push_pdf_string_byte(&mut out, b);
} else {
out.push('?');
}
continue;
}
match c {
'(' => out.push_str("\\("),
')' => out.push_str("\\)"),
'\\' => out.push_str("\\\\"),
'\x20'..='\x7e' => out.push(c),
_ => {
if let Some(b) = unicode_to_winansi(c) {
push_pdf_string_byte(&mut out, b);
} else {
out.push('?');
}
}
}
}
out
}
fn pdf_escape(s: &str) -> String {
pdf_escape_with_simple_encoding(s, None)
}
fn pdf_encode_text(s: &str, metrics: Option<&FontMetricsData>) -> String {
if let Some(data) = metrics {
if let Some(ref font_bytes) = data.font_data {
if let Ok(face) = ttf_parser::Face::parse(font_bytes, data.face_index) {
let mut hex = String::with_capacity(s.len() * 4 + 2);
hex.push('<');
for ch in s.chars() {
let gid = face.glyph_index(ch).map(|g| g.0).unwrap_or(0);
use std::fmt::Write;
let _ = write!(hex, "{:04X}", gid);
}
hex.push('>');
return hex;
}
}
}
let simple_map = metrics.and_then(|m| m.simple_unicode_to_code.as_ref());
format!("({})", pdf_escape_with_simple_encoding(s, simple_map))
}
fn lookup_font_metrics<'a>(
node_style: &FormNodeStyle,
config: &'a XfaRenderConfig,
) -> Option<&'a FontMetricsData> {
node_style.font_family.as_ref().and_then(|tf| {
let vkey = font_variant_key(
tf,
node_style.font_weight.as_deref(),
node_style.font_style.as_deref(),
);
config
.font_metrics_data
.get(&vkey)
.or_else(|| config.font_metrics_data.get(tf))
})
}
#[allow(clippy::too_many_arguments)]
fn render_draw(
draw_content: &DrawContent,
abs_x: f64,
pdf_y: f64,
_w: f64,
container_h: f64,
node_style: &FormNodeStyle,
config: &XfaRenderConfig,
ops: &mut Vec<u8>,
) {
match draw_content {
DrawContent::Text(text) => {
if !text.is_empty() {
let fs = node_style.font_size.unwrap_or(config.default_font_size);
let font_family = match node_style.font_family.as_deref() {
Some(f) if f.contains("Courier") || f.contains("Mono") => FontFamily::Monospace,
Some(f)
if f.contains("Helvetica")
|| f.contains("Arial")
|| f.contains("Sans")
|| f.contains("Myriad") =>
{
FontFamily::SansSerif
}
_ => FontFamily::Serif,
};
let font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
let tc = node_style
.text_color
.map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
.unwrap_or(config.text_color);
let idh_metrics = lookup_font_metrics(node_style, config);
let encoded = pdf_encode_text(text, idh_metrics);
write_ops(
ops,
format_args!(
"BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
tc[0], tc[1], tc[2], font_ref, fs,
),
);
emit_synthetic_bold_ops(&config.font_map, node_style, fs, &tc, ops);
write_ops(
ops,
format_args!("{:.2} {:.2} Td\n{} Tj\n", abs_x, pdf_y, encoded),
);
reset_synthetic_bold_ops(&config.font_map, node_style, ops);
ops.extend_from_slice(b"ET\n");
}
}
DrawContent::Line { x1, y1, x2, y2 } => {
let start_x = abs_x + x1;
let start_y = pdf_y + container_h - y1;
let end_x = abs_x + x2;
let end_y = pdf_y + container_h - y2;
write_ops(
ops,
format_args!(
"{:.2} {:.2} m\n{:.2} {:.2} l\nS\n",
start_x, start_y, end_x, end_y
),
);
}
DrawContent::Rectangle { x, y, w, h, radius } => {
let rx = abs_x + x;
let ry = pdf_y + container_h - y - h;
if let Some((r, g, b)) = node_style.border_color {
write_ops(
ops,
format_args!(
"{:.4} {:.4} {:.4} RG\n",
r as f64 / 255.0,
g as f64 / 255.0,
b as f64 / 255.0
),
);
}
if let Some(w_pt) = node_style.border_width_pt {
write_ops(ops, format_args!("{:.2} w\n", w_pt));
}
if *radius <= 0.0 {
write_ops(
ops,
format_args!("{:.2} {:.2} {:.2} {:.2} re\nS\n", rx, ry, w, h),
);
} else {
let r = radius.min(w / 2.0).min(h / 2.0);
let k = r * 0.5522847498;
write_ops(
ops,
format_args!(
"{:.2} {:.2} m\n\
{:.2} {:.2} l\n\
{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
{:.2} {:.2} l\n\
{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
{:.2} {:.2} l\n\
{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
{:.2} {:.2} l\n\
{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
h\nS\n",
rx,
ry + r,
rx,
ry + h - r,
rx,
ry + h - r + k,
rx + r - k,
ry + h,
rx + r,
ry + h,
rx + w - r,
ry + h,
rx + w - r + k,
ry + h,
rx + w,
ry + h - r + k,
rx + w,
ry + h - r,
rx + w,
ry + r,
rx + w,
ry + r - k,
rx + w - r + k,
ry,
rx + w - r,
ry,
rx + r,
ry,
rx + r - k,
ry,
rx,
ry + r - k,
rx,
ry + r,
),
);
}
}
DrawContent::Arc {
x,
y,
w,
h,
start_angle,
sweep_angle,
} => {
let cx = abs_x + x + w / 2.0;
let cy = pdf_y + container_h - y - h / 2.0;
let rx = w / 2.0;
let ry = h / 2.0;
let start_rad = start_angle.to_radians();
let sweep_rad = sweep_angle.to_radians();
let end_angle = start_rad + sweep_rad;
let k = 0.5522847498;
let cos_start = start_rad.cos();
let sin_start = start_rad.sin();
let cos_end = end_angle.cos();
let sin_end = end_angle.sin();
let p1x = cx + rx * cos_start;
let p1y = cy + ry * sin_start;
let p2x = cx + rx * cos_end;
let p2y = cy + ry * sin_end;
let cp1x = cx - rx * k * sin_start;
let cp1y = cy + ry * k * cos_start;
let cp2x = cx + rx * k * sin_end;
let cp2y = cy - ry * k * cos_end;
write_ops(
ops,
format_args!(
"{:.2} {:.2} m\n{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\nS\n",
p1x, p1y, cp1x, cp1y, cp2x, cp2y, p2x, p2y
),
);
}
}
}
pub(crate) fn unicode_to_winansi(c: char) -> Option<u8> {
let cp = c as u32;
if (0xA0..=0xFF).contains(&cp) {
return Some(cp as u8);
}
match c {
'\u{20AC}' => Some(0x80),
'\u{201A}' => Some(0x82),
'\u{0192}' => Some(0x83),
'\u{201E}' => Some(0x84),
'\u{2026}' => Some(0x85),
'\u{2020}' => Some(0x86),
'\u{2021}' => Some(0x87),
'\u{02C6}' => Some(0x88),
'\u{2030}' => Some(0x89),
'\u{0160}' => Some(0x8A),
'\u{2039}' => Some(0x8B),
'\u{0152}' => Some(0x8C),
'\u{017D}' => Some(0x8E),
'\u{2018}' => Some(0x91),
'\u{2019}' => Some(0x92),
'\u{201C}' => Some(0x93),
'\u{201D}' => Some(0x94),
'\u{2022}' => Some(0x95),
'\u{2013}' => Some(0x96),
'\u{2014}' => Some(0x97),
'\u{02DC}' => Some(0x98),
'\u{2122}' => Some(0x99),
'\u{0161}' => Some(0x9A),
'\u{203A}' => Some(0x9B),
'\u{0153}' => Some(0x9C),
'\u{017E}' => Some(0x9E),
'\u{0178}' => Some(0x9F),
_ => None,
}
}
fn write_ops(buf: &mut Vec<u8>, args: std::fmt::Arguments<'_>) {
use std::io::Write;
let _ = buf.write_fmt(args);
}
#[cfg(test)]
mod tests {
use super::*;
use xfa_layout_engine::form::FormNodeId;
use xfa_layout_engine::types::Rect;
fn make_page(nodes: Vec<LayoutNode>) -> LayoutPage {
LayoutPage {
width: 612.0,
height: 792.0,
nodes,
}
}
fn make_field_node(x: f64, y: f64, w: f64, h: f64, value: &str) -> LayoutNode {
LayoutNode {
form_node: FormNodeId(0),
rect: Rect::new(x, y, w, h),
name: "field1".to_string(),
content: LayoutContent::Field {
value: value.to_string(),
field_kind: FieldKind::Text,
font_size: 0.0,
font_family: FontFamily::Serif,
},
children: vec![],
style: Default::default(),
display_items: vec![],
save_items: vec![],
}
}
fn make_styled_field(
x: f64,
y: f64,
w: f64,
h: f64,
value: &str,
style: FormNodeStyle,
) -> LayoutNode {
LayoutNode {
form_node: FormNodeId(0),
rect: Rect::new(x, y, w, h),
name: "styled".to_string(),
content: LayoutContent::Field {
value: value.to_string(),
field_kind: FieldKind::Text,
font_size: 10.0,
font_family: FontFamily::Serif,
},
children: vec![],
style,
display_items: vec![],
save_items: vec![],
}
}
fn make_styled_field_kind(
x: f64,
y: f64,
w: f64,
h: f64,
value: &str,
field_kind: FieldKind,
style: FormNodeStyle,
) -> LayoutNode {
LayoutNode {
form_node: FormNodeId(0),
rect: Rect::new(x, y, w, h),
name: "styled-kind".to_string(),
content: LayoutContent::Field {
value: value.to_string(),
field_kind,
font_size: 10.0,
font_family: FontFamily::Serif,
},
children: vec![],
style,
display_items: vec![],
save_items: vec![],
}
}
fn make_styled_checkbox(
x: f64,
y: f64,
w: f64,
h: f64,
value: &str,
style: FormNodeStyle,
) -> LayoutNode {
LayoutNode {
form_node: FormNodeId(0),
rect: Rect::new(x, y, w, h),
name: "checkbox".to_string(),
content: LayoutContent::Field {
value: value.to_string(),
field_kind: FieldKind::Checkbox,
font_size: 10.0,
font_family: FontFamily::Serif,
},
children: vec![],
style,
display_items: vec![],
save_items: vec![],
}
}
fn make_styled_radio(
x: f64,
y: f64,
w: f64,
h: f64,
value: &str,
style: FormNodeStyle,
) -> LayoutNode {
LayoutNode {
form_node: FormNodeId(0),
rect: Rect::new(x, y, w, h),
name: "radio".to_string(),
content: LayoutContent::Field {
value: value.to_string(),
field_kind: FieldKind::Radio,
font_size: 10.0,
font_family: FontFamily::Serif,
},
children: vec![],
style,
display_items: vec![],
save_items: vec![],
}
}
fn make_styled_button(
x: f64,
y: f64,
w: f64,
h: f64,
value: &str,
style: FormNodeStyle,
) -> LayoutNode {
LayoutNode {
form_node: FormNodeId(0),
rect: Rect::new(x, y, w, h),
name: "button".to_string(),
content: LayoutContent::Field {
value: value.to_string(),
field_kind: FieldKind::Button,
font_size: 10.0,
font_family: FontFamily::Serif,
},
children: vec![],
style,
display_items: vec![],
save_items: vec![],
}
}
#[test]
fn coordinate_mapping() {
let mapper = CoordinateMapper::new(792.0, 612.0);
assert!((mapper.xfa_to_pdf_y(0.0, 20.0) - 772.0).abs() < 0.001);
}
fn overlay_str(page: &LayoutPage) -> String {
let o = generate_page_overlay(page, &XfaRenderConfig::default()).unwrap();
String::from_utf8_lossy(&o.content_stream).into_owned()
}
#[test]
fn empty_page_overlay() {
let s = overlay_str(&make_page(vec![]));
assert!(s.starts_with("q\n") && s.ends_with("Q\n"));
}
#[test]
fn field_renders_text() {
let s = overlay_str(&make_page(vec![make_field_node(
10.0, 10.0, 100.0, 20.0, "Hello",
)]));
assert!(s.contains("(Hello) Tj") && s.contains("BT") && s.contains("ET"));
}
#[test]
fn empty_field_no_text() {
let s = overlay_str(&make_page(vec![make_field_node(
10.0, 10.0, 100.0, 20.0, "",
)]));
assert!(!s.contains("BT"));
}
#[test]
fn dropdown_renders_display_item_for_matching_save_value() {
let node = LayoutNode {
form_node: FormNodeId(0),
rect: Rect::new(10.0, 10.0, 100.0, 20.0),
name: "choice".to_string(),
content: LayoutContent::Field {
value: "CA".to_string(),
field_kind: FieldKind::Dropdown,
font_size: 10.0,
font_family: FontFamily::Serif,
},
children: vec![],
style: Default::default(),
display_items: vec!["California".to_string(), "Nevada".to_string()],
save_items: vec!["CA".to_string(), "NV".to_string()],
};
let s = overlay_str(&make_page(vec![node]));
assert!(
s.contains("(California) Tj"),
"dropdown should render display item: {s}"
);
assert!(
!s.contains("(CA) Tj"),
"dropdown should not render raw save value: {s}"
);
}
#[test]
fn all_overlays() {
let layout = LayoutDom {
pages: vec![
make_page(vec![make_field_node(0.0, 0.0, 50.0, 20.0, "P1")]),
make_page(vec![make_field_node(0.0, 0.0, 50.0, 20.0, "P2")]),
],
};
assert_eq!(
generate_all_overlays(&layout, &XfaRenderConfig::default())
.unwrap()
.len(),
2
);
}
#[test]
fn pdf_escape_winansi_encoding() {
assert_eq!(pdf_escape("Hello"), "Hello");
assert_eq!(pdf_escape("a(b)c\\d"), "a\\(b\\)c\\\\d");
assert_eq!(pdf_escape("\u{2013}"), "\\226");
assert_eq!(pdf_escape("\u{2022}"), "\\225");
assert_eq!(pdf_escape("\u{00A9}"), "\\251");
assert_eq!(pdf_escape("\u{4E16}"), "?");
}
fn styled_overlay_str(node: LayoutNode) -> String {
let o = generate_page_overlay(&make_page(vec![node]), &XfaRenderConfig::default()).unwrap();
String::from_utf8_lossy(&o.content_stream).into_owned()
}
fn styled_overlay_str_with_config(node: LayoutNode, config: XfaRenderConfig) -> String {
let o = generate_page_overlay(&make_page(vec![node]), &config).unwrap();
String::from_utf8_lossy(&o.content_stream).into_owned()
}
#[test]
fn rounded_border_emits_bezier() {
let style = FormNodeStyle {
border_width_pt: Some(1.0),
border_radius_pt: Some(5.0),
..Default::default()
};
let s = styled_overlay_str(make_styled_field(10.0, 10.0, 100.0, 20.0, "Hi", style));
assert!(s.contains(" c\n"), "expected Bezier");
assert!(s.contains("h\n"), "expected close-path");
}
#[test]
fn button_default_border_radius_is_zero() {
let s = styled_overlay_str(make_styled_button(
10.0,
10.0,
100.0,
20.0,
"Click",
FormNodeStyle::default(),
));
assert!(
!s.contains(" c\n"),
"default button border radius should stay square: {s}"
);
}
#[test]
fn button_with_caption_renders_even_when_value_is_empty() {
let style = FormNodeStyle {
caption_text: Some("Click".to_string()),
..Default::default()
};
let s = styled_overlay_str(make_styled_button(10.0, 10.0, 100.0, 20.0, "", style));
assert!(
s.contains("(Click) Tj"),
"button caption should render as label: {s}"
);
}
#[test]
fn dashed_border_emits_dash_pattern() {
let style = FormNodeStyle {
border_width_pt: Some(1.0),
border_style: Some("dashed".to_string()),
..Default::default()
};
let s = styled_overlay_str(make_styled_field(10.0, 10.0, 100.0, 20.0, "Hi", style));
assert!(s.contains("[3 2] 0 d"), "expected dash");
assert!(s.contains("[] 0 d"), "expected reset");
}
#[test]
fn field_per_edge_widths_render_without_uniform_border_width() {
let style = FormNodeStyle {
border_widths: Some([1.0, 2.0, 1.0, 3.0]),
border_edges: [false, true, false, true],
..Default::default()
};
let s = styled_overlay_str(make_styled_field(10.0, 10.0, 100.0, 20.0, "", style));
assert!(s.contains("2.00 w"), "right edge width should be used: {s}");
assert!(s.contains("3.00 w"), "left edge width should be used: {s}");
assert!(
s.contains("110.00 762.00 m 110.00 782.00 l S"),
"right edge should render even without border_width_pt: {s}"
);
assert!(
s.contains("10.00 762.00 m 10.00 782.00 l S"),
"left edge should render even without border_width_pt: {s}"
);
}
#[test]
fn container_per_edge_widths_render_without_uniform_border_width() {
let node = LayoutNode {
form_node: FormNodeId(0),
rect: Rect::new(10.0, 10.0, 100.0, 20.0),
name: "box".to_string(),
content: LayoutContent::None,
children: vec![],
style: FormNodeStyle {
border_widths: Some([1.0, 2.0, 1.0, 3.0]),
border_edges: [false, true, false, true],
..Default::default()
},
display_items: vec![],
save_items: vec![],
};
let s = styled_overlay_str(node);
assert!(s.contains("2.00 w"), "right edge width should be used: {s}");
assert!(s.contains("3.00 w"), "left edge width should be used: {s}");
assert!(
s.contains("110.00 762.00 m 110.00 782.00 l S"),
"right edge should render for non-field nodes: {s}"
);
assert!(
s.contains("10.00 762.00 m 10.00 782.00 l S"),
"left edge should render for non-field nodes: {s}"
);
}
#[test]
fn para_margins_applied() {
let style = FormNodeStyle {
margin_left_pt: Some(5.0),
margin_right_pt: Some(3.0),
space_above_pt: Some(2.0),
..Default::default()
};
let s = styled_overlay_str(make_styled_field(10.0, 10.0, 200.0, 30.0, "Test", style));
assert!(s.contains("15.00"), "expected margin_left offset 10+5=15");
}
#[test]
fn top_caption_renders_after_field_fill() {
let style = FormNodeStyle {
caption_text: Some("PROJECT INFORMATION/NAME".to_string()),
caption_placement: Some("top".to_string()),
caption_reserve: Some(12.0),
bg_color: Some((12, 34, 56)),
..Default::default()
};
let s = styled_overlay_str(make_styled_field(10.0, 100.0, 200.0, 30.0, "", style));
let fill_idx = s
.find("0.047 0.133 0.220 rg")
.expect("explicit field fill should be present");
let caption_idx = s
.find("(PROJECT INFORMATION/NAME) Tj")
.expect("caption text should render");
assert!(
caption_idx > fill_idx,
"caption should render after the field fill so it stays visible: {s}"
);
}
#[test]
fn left_caption_stays_in_pre_body_render_path() {
let style = FormNodeStyle {
caption_text: Some("Field 1".to_string()),
caption_placement: Some("left".to_string()),
bg_color: Some((12, 34, 56)),
..Default::default()
};
let s = styled_overlay_str(make_styled_field(10.0, 100.0, 200.0, 30.0, "", style));
let caption_idx = s.find("(Field 1) Tj").expect("caption text should render");
let fill_idx = s
.find("0.047 0.133 0.220 rg")
.expect("explicit field fill should be present");
assert!(
caption_idx < fill_idx,
"left captions should keep the legacy pre-body ordering: {s}"
);
}
#[test]
fn left_caption_without_explicit_reserve_shifts_field_body() {
let style = FormNodeStyle {
caption_text: Some("Field 1".to_string()),
caption_placement: Some("left".to_string()),
bg_color: Some((12, 34, 56)),
..Default::default()
};
let config = XfaRenderConfig::default();
let reserve = effective_caption_reserve(&style, 10.0, FontFamily::Serif, &config);
let metrics = build_font_metrics(10.0, FontFamily::Serif, &style, &config);
assert!(
(reserve - metrics.measure_width("Field 1")).abs() < 0.01,
"auto reserve should match caption width for simple left captions"
);
let s = styled_overlay_str(make_styled_field(10.0, 100.0, 200.0, 30.0, "", style));
let mapper = CoordinateMapper::new(792.0, 612.0);
let expected_fill = format!(
"{:.2} {:.2} {:.2} 30.00 re",
10.0 + reserve,
mapper.xfa_to_pdf_y(100.0, 30.0),
200.0 - reserve
);
assert!(
s.contains(&expected_fill),
"field body should be shifted right by the caption reserve: {s}"
);
}
#[test]
fn button_caption_does_not_shrink_button_body() {
let style = FormNodeStyle {
caption_text: Some("Click".to_string()),
caption_placement: Some("left".to_string()),
bg_color: Some((12, 34, 56)),
..Default::default()
};
let config = XfaRenderConfig::default();
let reserve = effective_caption_reserve(&style, 10.0, FontFamily::Serif, &config);
assert!(
reserve > 0.0,
"button caption should still have measurable text"
);
let s = styled_overlay_str(make_styled_button(10.0, 100.0, 200.0, 30.0, "", style));
let mapper = CoordinateMapper::new(792.0, 612.0);
let expected_fill = format!(
"{:.2} {:.2} 200.00 30.00 re",
10.0,
mapper.xfa_to_pdf_y(100.0, 30.0)
);
assert!(
s.contains(&expected_fill),
"button body should keep the full field width because its caption is rendered internally: {s}"
);
}
#[test]
fn top_caption_uses_full_inner_rect_height() {
let style = FormNodeStyle {
caption_text: Some("TOP CAPTION".to_string()),
caption_placement: Some("top".to_string()),
caption_reserve: Some(12.0),
..Default::default()
};
let s = styled_overlay_str(make_styled_field(
10.0,
100.0,
200.0,
30.0,
"",
style.clone(),
));
let config = XfaRenderConfig::default();
let metrics = build_font_metrics(10.0, FontFamily::Serif, &style, &config);
let asc_pt = ascender_pt(&metrics, 10.0);
let mapper = CoordinateMapper::new(792.0, 612.0);
let inner_pdf_y = mapper.xfa_to_pdf_y(100.0, 30.0);
let expected = format!(
"{:.2} {:.2} Td\n(TOP CAPTION) Tj",
10.0,
inner_pdf_y + 30.0 - asc_pt
);
assert!(
s.contains(&expected),
"top caption should be positioned against the full inner rect, not the value area: {s}"
);
}
#[test]
fn v_align_middle() {
let style = FormNodeStyle {
v_align: Some(VerticalAlign::Middle),
..Default::default()
};
let s = styled_overlay_str(make_styled_field(0.0, 0.0, 200.0, 40.0, "Mid", style));
assert!(s.contains("(Mid) Tj"));
}
#[test]
fn text_field_without_explicit_fill_has_no_default_background() {
let s = styled_overlay_str(make_styled_field(
10.0,
10.0,
100.0,
20.0,
"Hi",
FormNodeStyle::default(),
));
assert!(
!s.contains("0.949 0.949 0.949 rg"),
"flatten output should not synthesize an interactive default field fill: {s}"
);
}
#[test]
fn numeric_field_without_explicit_fill_has_no_default_background() {
let s = styled_overlay_str(make_styled_field_kind(
10.0,
10.0,
100.0,
20.0,
"42",
FieldKind::NumericEdit,
FormNodeStyle::default(),
));
assert!(
!s.contains("0.949 0.949 0.949 rg"),
"numeric fields should also require explicit template fill to paint a background: {s}"
);
}
#[test]
fn password_field_masks_plaintext_value() {
let s = styled_overlay_str(make_styled_field_kind(
10.0,
10.0,
100.0,
20.0,
"secret",
FieldKind::PasswordEdit,
FormNodeStyle::default(),
));
assert!(
!s.contains("(secret) Tj"),
"password fields must not emit plaintext into the content stream: {s}"
);
assert!(
s.contains("(\\225\\225\\225\\225\\225\\225) Tj"),
"password fields should render bullet masking instead of plaintext: {s}"
);
}
#[test]
fn explicit_white_field_background_is_preserved() {
let s = styled_overlay_str(make_styled_field(
10.0,
10.0,
100.0,
20.0,
"Hi",
FormNodeStyle {
bg_color: Some((255, 255, 255)),
..Default::default()
},
));
assert!(
s.contains("1.000 1.000 1.000 rg"),
"explicit white field fills should stay white: {s}"
);
}
#[test]
fn checkbox_does_not_use_edit_field_default_background() {
let s = styled_overlay_str(make_styled_checkbox(
10.0,
10.0,
20.0,
20.0,
"0",
FormNodeStyle {
border_width_pt: Some(0.25),
..Default::default()
},
));
assert!(
!s.contains("0.949 0.949 0.949 rg"),
"non-edit widgets should not inherit the text field gray fill: {s}"
);
}
#[test]
fn checkbox_explicit_background_fill_is_rendered() {
let s = styled_overlay_str(make_styled_checkbox(
10.0,
10.0,
20.0,
20.0,
"0",
FormNodeStyle {
bg_color: Some((255, 255, 255)),
..Default::default()
},
));
assert!(
s.contains("1.000 1.000 1.000 rg"),
"checkbox fill color should be emitted when bg_color is present: {s}"
);
assert!(
s.contains("20.00 20.00 re\nf"),
"checkbox background should be painted as a filled rectangle before the border: {s}"
);
}
#[test]
fn radio_explicit_background_fill_is_rendered() {
let s = styled_overlay_str(make_styled_radio(
10.0,
10.0,
20.0,
20.0,
"N",
FormNodeStyle {
bg_color: Some((255, 255, 255)),
check_button_on_value: Some("Y".to_string()),
check_button_off_value: Some("N".to_string()),
..Default::default()
},
));
assert!(
s.contains("1.000 1.000 1.000 rg"),
"radio fill color should be emitted when bg_color is present: {s}"
);
assert!(
s.contains(" c\n") && s.contains("\nf\n"),
"radio background should be painted as a filled circle path before the border: {s}"
);
}
#[test]
fn checkbox_ignores_global_background_without_explicit_fill() {
let mut config = XfaRenderConfig::default();
config.background_color = Some([0.949, 0.949, 0.949]);
let s = styled_overlay_str_with_config(
make_styled_checkbox(10.0, 10.0, 20.0, 20.0, "0", FormNodeStyle::default()),
config,
);
assert!(
!s.contains("0.949 0.949 0.949 rg"),
"checkbox should only fill from explicit style.bg_color: {s}"
);
}
#[test]
fn radio_ignores_global_background_without_explicit_fill() {
let mut config = XfaRenderConfig::default();
config.background_color = Some([0.949, 0.949, 0.949]);
let s = styled_overlay_str_with_config(
make_styled_radio(
10.0,
10.0,
20.0,
20.0,
"N",
FormNodeStyle {
check_button_on_value: Some("Y".to_string()),
check_button_off_value: Some("N".to_string()),
..Default::default()
},
),
config,
);
assert!(
!s.contains("0.949 0.949 0.949 rg"),
"radio should only fill from explicit style.bg_color: {s}"
);
}
#[test]
fn pdf_escape_polish_chars_fallback() {
assert_eq!(pdf_escape("łżść"), "????");
}
#[test]
fn pdf_encode_text_winansi_fallback() {
let encoded = pdf_encode_text("Hello", None);
assert_eq!(encoded, "(Hello)");
}
#[test]
fn pdf_encode_text_identity_h() {
let metrics = FontMetricsData {
widths: vec![500; 256],
upem: 1000,
ascender: 800,
descender: -200,
font_data: None,
face_index: 0,
simple_unicode_to_code: None,
};
let encoded = pdf_encode_text("AB", Some(&metrics));
assert_eq!(encoded, "(AB)");
}
#[test]
fn pdf_encode_text_simple_encoding_fallback() {
let mut custom_map = HashMap::new();
custom_map.insert(0x0163, 0x80);
let metrics = FontMetricsData {
widths: vec![500; 256],
upem: 1000,
ascender: 800,
descender: -200,
font_data: None,
face_index: 0,
simple_unicode_to_code: Some(custom_map),
};
let encoded = pdf_encode_text("ţ", Some(&metrics));
assert_eq!(encoded, "(\\200)");
}
#[test]
fn rich_text_span_mapping_preserves_space_after_bold_label() {
let spans = vec![
RichTextSpan {
text: "Instructions:".to_string(),
font_size: None,
font_family: None,
font_weight: Some("bold".to_string()),
font_style: None,
text_color: None,
underline: false,
line_through: false,
},
RichTextSpan {
text: "This form is for your use.".to_string(),
font_size: None,
font_family: None,
font_weight: Some("normal".to_string()),
font_style: None,
text_color: None,
underline: false,
line_through: false,
},
];
let lines = vec!["Instructions: This form is for your use.".to_string()];
let mapped = map_spans_to_lines(&spans, &lines);
assert_eq!(mapped.len(), 1);
assert_eq!(mapped[0].len(), 3);
assert_eq!(mapped[0][0].text, "Instructions:");
assert_eq!(mapped[0][0].span_idx, 0);
assert_eq!(mapped[0][1].text, " ");
assert_eq!(mapped[0][1].span_idx, 1);
assert_eq!(mapped[0][2].text, "This form is for your use.");
assert_eq!(mapped[0][2].span_idx, 1);
}
#[test]
fn rich_text_span_mapping_treats_nbsp_spaceruns_as_normal_spaces() {
let spans = vec![
RichTextSpan {
text: "Instructions:".to_string(),
font_size: None,
font_family: None,
font_weight: Some("bold".to_string()),
font_style: None,
text_color: None,
underline: false,
line_through: false,
},
RichTextSpan {
text: "This form is for your use.".to_string(),
font_size: None,
font_family: None,
font_weight: Some("normal".to_string()),
font_style: None,
text_color: None,
underline: false,
line_through: false,
},
RichTextSpan {
text: "\u{00A0}\u{00A0}".to_string(),
font_size: None,
font_family: None,
font_weight: Some("normal".to_string()),
font_style: None,
text_color: None,
underline: false,
line_through: false,
},
RichTextSpan {
text: "Mail in at least 14 days before".to_string(),
font_size: None,
font_family: None,
font_weight: Some("normal".to_string()),
font_style: None,
text_color: None,
underline: false,
line_through: false,
},
];
let lines = vec![
"Instructions: This form is for your use.".to_string(),
"Mail in at least 14 days before".to_string(),
];
let mapped = map_spans_to_lines(&spans, &lines);
assert_eq!(mapped.len(), 2);
assert_eq!(mapped[0][0].span_idx, 0);
assert_eq!(mapped[0][2].span_idx, 1);
assert_eq!(mapped[1].len(), 1);
assert_eq!(mapped[1][0].text, "Mail in at least 14 days before");
assert_eq!(mapped[1][0].span_idx, 3);
}
#[test]
fn real_bold_font_variant_skips_synthetic_bold_stroke() {
let mut config = XfaRenderConfig::default();
config
.font_map
.insert("Arial_Bold_Normal".to_string(), "/XFA_Fbold".to_string());
let s = styled_overlay_str_with_config(
make_styled_field(
10.0,
10.0,
200.0,
20.0,
"Bold",
FormNodeStyle {
font_family: Some("Arial".to_string()),
font_weight: Some("bold".to_string()),
..Default::default()
},
),
config,
);
assert!(
!s.contains("2 Tr"),
"actual bold variants should not get synthetic stroke bolding: {s}"
);
assert!(
s.contains("/XFA_Fbold 10.0 Tf"),
"expected real bold resource: {s}"
);
}
#[test]
fn container_insets_offset_children() {
let child = LayoutNode {
form_node: FormNodeId(1),
rect: Rect::new(0.0, 0.0, 50.0, 20.0),
name: "child".to_string(),
content: LayoutContent::Field {
value: "Test".to_string(),
field_kind: FieldKind::Text,
font_size: 10.0,
font_family: FontFamily::Serif,
},
children: vec![],
style: Default::default(),
display_items: vec![],
save_items: vec![],
};
let parent = LayoutNode {
form_node: FormNodeId(0),
rect: Rect::new(100.0, 200.0, 200.0, 100.0),
name: "parent".to_string(),
content: LayoutContent::None,
children: vec![child],
style: FormNodeStyle {
inset_left_pt: Some(10.0),
inset_top_pt: Some(5.0),
..Default::default()
},
display_items: vec![],
save_items: vec![],
};
let s = overlay_str(&make_page(vec![parent]));
assert!(
s.contains("110.00"),
"child x should include parent left inset offset: {s}"
);
}
#[test]
fn field_insets_reduce_text_wrap_width() {
let node = LayoutNode {
form_node: FormNodeId(0),
rect: Rect::new(10.0, 10.0, 100.0, 30.0),
name: "field".to_string(),
content: LayoutContent::Field {
value: "Hello".to_string(),
field_kind: FieldKind::Text,
font_size: 10.0,
font_family: FontFamily::Serif,
},
children: vec![],
style: FormNodeStyle {
inset_left_pt: Some(8.0),
inset_right_pt: Some(8.0),
..Default::default()
},
display_items: vec![],
save_items: vec![],
};
let s = overlay_str(&make_page(vec![node]));
assert!(
s.contains("18.00"),
"text x should include field left inset: {s}"
);
}
#[test]
fn checkbox_border_width_respects_style() {
let s = styled_overlay_str(make_styled_checkbox(
10.0,
10.0,
20.0,
20.0,
"0",
FormNodeStyle {
border_width_pt: Some(0.25),
..Default::default()
},
));
assert!(
s.contains("\n0.25 w\n"),
"checkbox should use styled border width: {s}"
);
assert!(
!s.contains("\n0.50 w\n"),
"checkbox should not fall back to default 0.5pt border width: {s}"
);
assert!(
!s.contains("\n1.00 w\n"),
"checkbox should not clamp to 1pt border width: {s}"
);
}
#[test]
fn container_children_y_offset_includes_inset() {
let child = LayoutNode {
form_node: FormNodeId(1),
rect: Rect::new(0.0, 0.0, 50.0, 20.0),
name: "child-box".to_string(),
content: LayoutContent::None,
children: vec![],
style: FormNodeStyle {
border_width_pt: Some(1.0),
..Default::default()
},
display_items: vec![],
save_items: vec![],
};
let parent = LayoutNode {
form_node: FormNodeId(0),
rect: Rect::new(100.0, 200.0, 200.0, 100.0),
name: "parent".to_string(),
content: LayoutContent::None,
children: vec![child],
style: FormNodeStyle {
inset_top_pt: Some(10.0),
..Default::default()
},
display_items: vec![],
save_items: vec![],
};
let s = overlay_str(&make_page(vec![parent]));
assert!(
s.contains("100.00 562.00 50.00 20.00 re"),
"child y should include parent inset_top offset: {s}"
);
assert!(
!s.contains("100.00 572.00 50.00 20.00 re"),
"child y should no longer ignore parent inset_top offset: {s}"
);
}
#[test]
fn checkbox_mark_style_controls_rendered_symbol() {
let default_overlay = styled_overlay_str(make_styled_checkbox(
10.0,
10.0,
20.0,
20.0,
"1",
FormNodeStyle::default(),
));
let circle_overlay = styled_overlay_str(make_styled_checkbox(
10.0,
10.0,
20.0,
20.0,
"1",
FormNodeStyle {
check_button_mark: Some("circle".to_string()),
..Default::default()
},
));
assert!(
!default_overlay.contains(" c\n"),
"default checkbox mark should not emit Bezier circle commands: {default_overlay}"
);
assert!(
circle_overlay.contains(" c\n"),
"circle checkbox mark should emit Bezier circle commands: {circle_overlay}"
);
}
#[test]
fn unchecked_checkbox_with_empty_value_still_draws_outline() {
let overlay = styled_overlay_str(make_styled_checkbox(
10.0,
10.0,
20.0,
20.0,
"",
FormNodeStyle::default(),
));
assert!(
overlay.contains("10.00 762.00 20.00 20.00 re"),
"unchecked checkbox should still render its outline: {overlay}"
);
}
#[test]
fn checkbox_checked_state_uses_template_item_values() {
let checked_overlay = styled_overlay_str(make_styled_checkbox(
10.0,
10.0,
20.0,
20.0,
"Yes",
FormNodeStyle {
check_button_on_value: Some("Yes".to_string()),
check_button_off_value: Some("No".to_string()),
..Default::default()
},
));
let unchecked_overlay = styled_overlay_str(make_styled_checkbox(
10.0,
10.0,
20.0,
20.0,
"No",
FormNodeStyle {
check_button_on_value: Some("Yes".to_string()),
check_button_off_value: Some("No".to_string()),
..Default::default()
},
));
assert!(
checked_overlay.matches(" l\nS\n").count() >= 2,
"asserted template on-value should render the check/cross mark: {checked_overlay}"
);
assert!(
unchecked_overlay.matches(" l\nS\n").count() < 3,
"template off-value should not render the asserted mark: {unchecked_overlay}"
);
}
#[test]
fn radio_explicit_mark_overrides_default_circle() {
let default_overlay = styled_overlay_str(make_styled_radio(
10.0,
10.0,
20.0,
20.0,
"Y",
FormNodeStyle {
check_button_on_value: Some("Y".to_string()),
check_button_off_value: Some("N".to_string()),
..Default::default()
},
));
let cross_overlay = styled_overlay_str(make_styled_radio(
10.0,
10.0,
20.0,
20.0,
"Y",
FormNodeStyle {
check_button_mark: Some("cross".to_string()),
check_button_on_value: Some("Y".to_string()),
check_button_off_value: Some("N".to_string()),
..Default::default()
},
));
assert!(
default_overlay.matches(" c\n").count() >= 8,
"default radio should render outer circle plus filled inner circle: {default_overlay}"
);
assert!(
cross_overlay.matches(" l\nS\n").count() >= 2,
"explicit radio mark should render the requested symbol: {cross_overlay}"
);
}
#[test]
fn render_tree_page_dimensions() {
let page = make_page(vec![]);
let layout = LayoutDom { pages: vec![page] };
let tree = layout_dom_to_render_tree(&layout, &XfaRenderConfig::default());
assert_eq!(tree.pages.len(), 1);
match &tree.pages[0] {
RenderNode::Page { width, height, .. } => {
assert!((*width - 612.0).abs() < 0.01, "width should be 612pt");
assert!((*height - 792.0).abs() < 0.01, "height should be 792pt");
}
other => panic!("expected Page node, got {other:?}"),
}
}
#[test]
fn render_tree_text_node_captured() {
let node = LayoutNode {
form_node: xfa_layout_engine::form::FormNodeId(0),
rect: xfa_layout_engine::types::Rect::new(10.0, 20.0, 100.0, 15.0),
name: "lbl".to_string(),
content: LayoutContent::Text("Hello tree".to_string()),
children: vec![],
style: Default::default(),
display_items: vec![],
save_items: vec![],
};
let layout = LayoutDom {
pages: vec![LayoutPage {
width: 612.0,
height: 792.0,
nodes: vec![node],
}],
};
let tree = layout_dom_to_render_tree(&layout, &XfaRenderConfig::default());
let debug = tree.to_debug_string();
assert!(
debug.contains("Hello tree"),
"debug string should contain text content: {debug}"
);
assert!(
debug.contains("Text("),
"debug string should contain Text node: {debug}"
);
}
#[test]
fn text_node_emits_correct_font_and_size_operators() {
let node = LayoutNode {
form_node: xfa_layout_engine::form::FormNodeId(0),
rect: xfa_layout_engine::types::Rect::new(10.0, 10.0, 100.0, 20.0),
name: "f".to_string(),
content: LayoutContent::Field {
value: "test value".to_string(),
field_kind: FieldKind::Text,
font_size: 12.0,
font_family: xfa_layout_engine::text::FontFamily::Serif,
},
children: vec![],
style: Default::default(),
display_items: vec![],
save_items: vec![],
};
let s = overlay_str(&make_page(vec![node]));
assert!(
s.contains("Tf"),
"should contain Tf font-select operator: {s}"
);
assert!(
s.contains("12.0 Tf"),
"should use specified font size 12.0: {s}"
);
assert!(
s.contains("(test value) Tj"),
"should render the value: {s}"
);
}
#[test]
fn multiline_text_has_correct_td_offsets() {
let node = LayoutNode {
form_node: xfa_layout_engine::form::FormNodeId(0),
rect: xfa_layout_engine::types::Rect::new(10.0, 10.0, 100.0, 40.0),
name: "ml".to_string(),
content: LayoutContent::WrappedText {
lines: vec!["line one".to_string(), "line two".to_string()],
first_line_of_para: vec![true, false],
font_size: 10.0,
text_align: xfa_layout_engine::types::TextAlign::Left,
font_family: xfa_layout_engine::text::FontFamily::Serif,
space_above_pt: None,
space_below_pt: None,
from_field: false,
},
children: vec![],
style: Default::default(),
display_items: vec![],
save_items: vec![],
};
let s = overlay_str(&make_page(vec![node]));
assert!(s.contains("(line one) Tj"), "first line missing: {s}");
assert!(s.contains("(line two) Tj"), "second line missing: {s}");
let td_count = s.matches(" Td\n").count();
assert!(td_count >= 2, "expected ≥2 Td operators for two lines: {s}");
}
#[test]
fn checkbox_checked_renders_nonempty_stream() {
let node = make_styled_checkbox(
10.0,
10.0,
20.0,
20.0,
"1",
FormNodeStyle {
check_button_on_value: Some("1".to_string()),
check_button_off_value: Some("0".to_string()),
border_width_pt: Some(0.5),
..Default::default()
},
);
let s = overlay_str(&make_page(vec![node]));
assert!(!s.is_empty(), "checked checkbox overlay must not be empty");
assert!(s.contains("re"), "checkbox must emit a rectangle: {s}");
}
#[test]
fn radio_selected_renders_nonempty_stream() {
let node = make_styled_radio(
10.0,
10.0,
20.0,
20.0,
"yes",
FormNodeStyle {
check_button_on_value: Some("yes".to_string()),
check_button_off_value: Some("no".to_string()),
..Default::default()
},
);
let s = overlay_str(&make_page(vec![node]));
assert!(!s.is_empty(), "selected radio overlay must not be empty");
assert!(
s.contains(" c\n"),
"selected radio must render circular fill: {s}"
);
}
#[test]
fn dropdown_selected_value_renders_nonempty_stream() {
let node = LayoutNode {
form_node: xfa_layout_engine::form::FormNodeId(0),
rect: xfa_layout_engine::types::Rect::new(10.0, 10.0, 100.0, 20.0),
name: "dd".to_string(),
content: LayoutContent::Field {
value: "opt1".to_string(),
field_kind: FieldKind::Dropdown,
font_size: 10.0,
font_family: xfa_layout_engine::text::FontFamily::Serif,
},
children: vec![],
style: Default::default(),
display_items: vec!["Option 1".to_string()],
save_items: vec!["opt1".to_string()],
};
let s = overlay_str(&make_page(vec![node]));
assert!(!s.is_empty(), "dropdown overlay must not be empty");
assert!(
s.contains("(Option 1) Tj"),
"dropdown must render display label: {s}"
);
}
#[test]
fn text_field_renders_bound_value() {
let node = LayoutNode {
form_node: xfa_layout_engine::form::FormNodeId(0),
rect: xfa_layout_engine::types::Rect::new(10.0, 10.0, 100.0, 20.0),
name: "name_field".to_string(),
content: LayoutContent::Field {
value: "John Doe".to_string(),
field_kind: FieldKind::Text,
font_size: 10.0,
font_family: xfa_layout_engine::text::FontFamily::Serif,
},
children: vec![],
style: Default::default(),
display_items: vec![],
save_items: vec![],
};
let s = overlay_str(&make_page(vec![node]));
assert!(
s.contains("(John Doe) Tj"),
"text field must render its bound value: {s}"
);
}
#[test]
fn numeric_field_formats_value_with_default_pattern() {
let node = LayoutNode {
form_node: xfa_layout_engine::form::FormNodeId(0),
rect: xfa_layout_engine::types::Rect::new(10.0, 10.0, 80.0, 20.0),
name: "amount".to_string(),
content: LayoutContent::Field {
value: "42.00000000".to_string(),
field_kind: FieldKind::NumericEdit,
font_size: 10.0,
font_family: xfa_layout_engine::text::FontFamily::Serif,
},
children: vec![],
style: Default::default(),
display_items: vec![],
save_items: vec![],
};
let s = overlay_str(&make_page(vec![node]));
assert!(
s.contains("(42) Tj"),
"numeric field should render cleaned value: {s}"
);
}
#[test]
fn date_field_renders_value_with_format_pattern() {
let node = LayoutNode {
form_node: xfa_layout_engine::form::FormNodeId(0),
rect: xfa_layout_engine::types::Rect::new(10.0, 10.0, 100.0, 20.0),
name: "date_field".to_string(),
content: LayoutContent::Field {
value: "2024-01-15".to_string(),
field_kind: FieldKind::DateTimePicker,
font_size: 10.0,
font_family: xfa_layout_engine::text::FontFamily::Serif,
},
children: vec![],
style: Default::default(),
display_items: vec![],
save_items: vec![],
};
let s = overlay_str(&make_page(vec![node]));
assert!(
s.contains("(2024-01-15) Tj"),
"date field must render its value: {s}"
);
}
}