use pdf_interpret::color::Color;
use xfa_layout_engine::form::{FieldKind, FormNodeStyle};
use xfa_layout_engine::layout::{LayoutContent, LayoutNode, LayoutPage};
use xfa_layout_engine::text::{FontFamily, FontMetrics};
use xfa_layout_engine::types::TextAlign;
use crate::render_bridge::XfaRenderConfig;
#[derive(Debug, Clone)]
pub enum XfaPaintCommand {
FillRect {
x: f64,
y: f64,
w: f64,
h: f64,
color: Color,
},
StrokeRect {
x: f64,
y: f64,
w: f64,
h: f64,
color: Color,
width: f64,
},
DrawText {
x: f64,
y: f64,
text: String,
font_family: FontFamily,
font_size: f64,
color: Color,
},
DrawMultilineText {
x: f64,
y: f64,
lines: Vec<String>,
font_family: FontFamily,
font_size: f64,
line_height: f64,
color: Color,
text_align: TextAlign,
container_width: f64,
text_padding: f64,
},
DrawImage {
x: f64,
y: f64,
w: f64,
h: f64,
image_data: Vec<u8>,
mime_type: String,
},
DrawCheckbox {
x: f64,
y: f64,
w: f64,
h: f64,
checked: bool,
border_color: [f64; 3],
check_color: [f64; 3],
border_width: f64,
},
}
fn apply_node_style(config: &XfaRenderConfig, style: &FormNodeStyle) -> XfaRenderConfig {
let mut cfg = config.clone();
if let Some((r, g, b)) = style.bg_color {
if !(r >= 250 && g >= 250 && b >= 250) {
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) = style.border_width_pt {
if bw > 0.0 {
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
}
pub fn layout_to_commands(page: &LayoutPage, config: &XfaRenderConfig) -> Vec<XfaPaintCommand> {
let mut commands = Vec::new();
let page_height = page.height;
for node in &page.nodes {
emit_node_commands(node, 0.0, 0.0, page_height, config, &mut commands);
}
commands
}
fn emit_node_commands(
node: &LayoutNode,
parent_x: f64,
parent_y: f64,
page_height: f64,
config: &XfaRenderConfig,
commands: &mut Vec<XfaPaintCommand>,
) {
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 = page_height - abs_y - h;
let node_config = apply_node_style(config, &node.style);
if !matches!(node.content, LayoutContent::Field { .. }) {
if let Some(bg) = &node_config.background_color {
commands.push(XfaPaintCommand::FillRect {
x: abs_x,
y: pdf_y,
w,
h,
color: Color::from_device_rgb(bg[0] as f32, bg[1] as f32, bg[2] as f32),
});
}
if let Some(bw) = node.style.border_width_pt {
if bw > 0.0 && w > 0.0 && h > 0.0 {
let bc = node
.style
.border_color
.map_or([0.0, 0.0, 0.0], |(r, g, b)| {
[r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0]
});
commands.push(XfaPaintCommand::StrokeRect {
x: abs_x,
y: pdf_y,
w,
h,
color: Color::from_device_rgb(bc[0] as f32, bc[1] as f32, bc[2] as f32),
width: bw,
});
}
}
}
match &node.content {
LayoutContent::Field {
value,
field_kind,
font_size,
font_family,
} => match field_kind {
FieldKind::Checkbox | FieldKind::Radio => {
let checked = !value.is_empty()
&& !value.eq_ignore_ascii_case("0")
&& !value.eq_ignore_ascii_case("off")
&& !value.eq_ignore_ascii_case("false");
commands.push(XfaPaintCommand::DrawCheckbox {
x: abs_x,
y: pdf_y,
w,
h,
checked,
border_color: node_config.border_color,
check_color: node_config.text_color,
border_width: node_config.border_width.max(0.5),
});
}
_ => {
emit_field_commands(
abs_x,
pdf_y,
w,
h,
value,
*font_size,
*font_family,
&node_config,
commands,
);
}
},
LayoutContent::Text(text) => {
if !text.is_empty() {
let text_color = make_color(&node_config.text_color);
commands.push(XfaPaintCommand::DrawText {
x: abs_x + node_config.text_padding,
y: pdf_y + node_config.text_padding,
text: text.clone(),
font_family: FontFamily::SansSerif,
font_size: node_config.default_font_size,
color: text_color,
});
}
}
LayoutContent::WrappedText {
lines,
font_size,
text_align,
font_family,
..
} => {
let fs = *font_size;
let line_height = fs * 1.2;
if !lines.is_empty() {
let text_color = make_color(&node_config.text_color);
let first_line_y = page_height - abs_y - fs;
commands.push(XfaPaintCommand::DrawMultilineText {
x: abs_x,
y: first_line_y,
lines: lines.clone(),
font_family: *font_family,
font_size: fs,
line_height,
color: text_color,
text_align: *text_align,
container_width: w,
text_padding: node_config.text_padding,
});
}
}
LayoutContent::Image { .. } => {}
LayoutContent::Draw(_) => {}
LayoutContent::None => {}
}
for child in &node.children {
emit_node_commands(child, abs_x, abs_y, page_height, config, commands);
}
}
#[allow(clippy::too_many_arguments)]
fn emit_field_commands(
x: f64,
pdf_y: f64,
w: f64,
h: f64,
value: &str,
font_size: f64,
font_family: FontFamily,
config: &XfaRenderConfig,
commands: &mut Vec<XfaPaintCommand>,
) {
if let Some(bg) = &config.background_color {
commands.push(XfaPaintCommand::FillRect {
x,
y: pdf_y,
w,
h,
color: Color::from_device_rgb(bg[0] as f32, bg[1] as f32, bg[2] as f32),
});
}
if config.draw_borders && config.border_width > 0.0 {
commands.push(XfaPaintCommand::StrokeRect {
x,
y: pdf_y,
w,
h,
color: make_color(&config.border_color),
width: config.border_width,
});
}
if !value.is_empty() {
let fs = if font_size > 0.0 {
font_size
} else {
config.default_font_size
};
let p = config.text_padding;
let content_w = (w - p * 2.0).max(0.0);
let metrics = FontMetrics {
size: fs,
typeface: font_family,
..Default::default()
};
let text_w = metrics.measure_width(value);
let text_color = make_color(&config.text_color);
if text_w <= content_w || content_w <= 0.0 {
commands.push(XfaPaintCommand::DrawText {
x: x + p,
y: pdf_y + p,
text: value.to_string(),
font_family,
font_size: fs,
color: text_color,
});
} else {
let lines = wrap_text(value, content_w, &metrics);
let line_height = fs * 1.2;
commands.push(XfaPaintCommand::DrawMultilineText {
x,
y: pdf_y + h - p - fs,
lines,
font_family,
font_size: fs,
line_height,
color: text_color,
text_align: TextAlign::Left,
container_width: w,
text_padding: p,
});
}
}
}
fn make_color(rgb: &[f64; 3]) -> Color {
Color::from_device_rgb(rgb[0] as f32, rgb[1] as f32, rgb[2] as f32)
}
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 font_family_to_ref(ff: FontFamily) -> &'static str {
match ff {
FontFamily::Serif => "/F1",
FontFamily::SansSerif => "/F2",
FontFamily::Monospace => "/F3",
}
}
pub fn execute_commands(commands: &[XfaPaintCommand]) -> Vec<u8> {
let mut ops = Vec::new();
ops.extend_from_slice(b"q\n");
let mut image_index = 0usize;
for cmd in commands {
match cmd {
XfaPaintCommand::FillRect { x, y, w, h, color } => {
let rgba = color.to_rgba();
let [r, g, b, _] = rgba.to_rgba8();
ops.extend(
format!(
"{:.3} {:.3} {:.3} rg\n{:.2} {:.2} {:.2} {:.2} re\nf\n",
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
x,
y,
w,
h
)
.bytes(),
);
}
XfaPaintCommand::StrokeRect {
x,
y,
w,
h,
color,
width,
} => {
let rgba = color.to_rgba();
let [r, g, b, _] = rgba.to_rgba8();
ops.extend(format!("{:.2} w\n", width).bytes());
ops.extend(
format!(
"{:.3} {:.3} {:.3} RG\n{:.2} {:.2} {:.2} {:.2} re\nS\n",
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
x,
y,
w,
h
)
.bytes(),
);
}
XfaPaintCommand::DrawText {
x,
y,
text,
font_family,
font_size,
color,
} => {
let rgba = color.to_rgba();
let [r, g, b, _] = rgba.to_rgba8();
let font_ref = font_family_to_ref(*font_family);
ops.extend(
format!(
"BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n{:.2} {:.2} Td\n({}) Tj\nET\n",
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
font_ref,
font_size,
x,
y,
pdf_escape(text)
)
.bytes(),
);
}
XfaPaintCommand::DrawMultilineText {
x,
y,
lines,
font_family,
font_size,
line_height,
color,
text_align,
container_width,
text_padding,
} => {
let rgba = color.to_rgba();
let [r, g, b, _] = rgba.to_rgba8();
let font_ref = font_family_to_ref(*font_family);
let p = *text_padding;
let content_w = (container_width - p * 2.0).max(0.0);
let metrics = FontMetrics {
size: *font_size,
typeface: *font_family,
..Default::default()
};
ops.extend(
format!(
"BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
font_ref,
font_size
)
.bytes(),
);
let mut prev_tx = x + p;
for (i, line) in lines.iter().enumerate() {
let line_w = metrics.measure_width(line);
let text_x = match text_align {
TextAlign::Center => x + p + ((content_w - line_w) / 2.0).max(0.0),
TextAlign::Right => x + p + (content_w - line_w).max(0.0),
_ => x + p,
};
if i == 0 {
ops.extend(format!("{:.2} {:.2} Td\n", text_x, y).bytes());
} else {
let dx = text_x - prev_tx;
ops.extend(format!("{:.2} {:.2} Td\n", dx, -line_height).bytes());
}
prev_tx = text_x;
ops.extend(format!("({}) Tj\n", pdf_escape(line)).bytes());
}
ops.extend_from_slice(b"ET\n");
}
XfaPaintCommand::DrawImage {
x,
y,
w,
h,
image_data: _,
mime_type: _,
} => {
ops.extend(
format!(
"q\n{:.2} 0 0 {:.2} {:.2} {:.2} cm\n/Im{} Do\nQ\n",
w, h, x, y, image_index
)
.bytes(),
);
image_index += 1;
}
XfaPaintCommand::DrawCheckbox {
x,
y,
w,
h,
checked,
border_color,
check_color,
border_width,
} => {
ops.extend(
format!(
"q\n{:.2} w\n{:.3} {:.3} {:.3} RG\n{:.2} {:.2} {:.2} {:.2} re\nS\n",
border_width, border_color[0], border_color[1], border_color[2], x, y, w, h
)
.bytes(),
);
if *checked {
let m = w.min(*h) * 0.15;
ops.extend(
format!(
"{:.2} w\n{:.3} {:.3} {:.3} RG\n\
{:.2} {:.2} m {:.2} {:.2} l S\n\
{:.2} {:.2} m {:.2} {:.2} l S\n",
border_width.max(1.0),
check_color[0],
check_color[1],
check_color[2],
x + m,
y + m,
x + w - m,
y + h - m,
x + m,
y + h - m,
x + w - m,
y + m,
)
.bytes(),
);
}
ops.extend_from_slice(b"Q\n");
}
}
}
ops.extend_from_slice(b"Q\n");
ops
}
fn pdf_escape(s: &str) -> String {
let mut r = String::with_capacity(s.len());
for c in s.chars() {
match c {
'(' => r.push_str("\\("),
')' => r.push_str("\\)"),
'\\' => r.push_str("\\\\"),
'\x20'..='\x7e' => r.push(c),
_ => {
if let Some(b) = unicode_to_winansi(c) {
use std::fmt::Write;
let _ = write!(r, "\\{:03o}", b);
} else {
r.push('?');
}
}
}
}
r
}
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,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render_bridge::XfaRenderConfig;
use xfa_layout_engine::layout::{LayoutNode, LayoutPage};
use xfa_layout_engine::types::Rect;
fn test_config() -> XfaRenderConfig {
XfaRenderConfig {
default_font: "Helvetica".into(),
default_font_size: 10.0,
draw_borders: true,
border_width: 0.5,
border_color: [0.0, 0.0, 0.0],
text_color: [0.0, 0.0, 0.0],
background_color: Some([1.0, 1.0, 1.0]),
text_padding: 2.0,
font_map: std::collections::HashMap::new(),
font_metrics_data: std::collections::HashMap::new(),
check_button_mark: None,
field_values_only: false,
}
}
fn field_node(name: &str, x: f64, y: f64, w: f64, h: f64, value: &str) -> LayoutNode {
LayoutNode {
form_node: xfa_layout_engine::form::FormNodeId(0),
rect: Rect {
x,
y,
width: w,
height: h,
},
name: name.into(),
content: LayoutContent::Field {
value: value.into(),
field_kind: xfa_layout_engine::form::FieldKind::Text,
font_size: 0.0,
font_family: xfa_layout_engine::text::FontFamily::Serif,
},
children: vec![],
style: Default::default(),
display_items: vec![],
save_items: vec![],
}
}
fn field_node_with_border(
name: &str,
x: f64,
y: f64,
w: f64,
h: f64,
value: &str,
) -> LayoutNode {
LayoutNode {
form_node: xfa_layout_engine::form::FormNodeId(0),
rect: Rect {
x,
y,
width: w,
height: h,
},
name: name.into(),
content: LayoutContent::Field {
value: value.into(),
field_kind: xfa_layout_engine::form::FieldKind::Text,
font_size: 0.0,
font_family: xfa_layout_engine::text::FontFamily::Serif,
},
children: vec![],
style: FormNodeStyle {
border_width_pt: Some(0.5),
border_color: Some((0, 0, 0)),
..Default::default()
},
display_items: vec![],
save_items: vec![],
}
}
#[test]
fn field_with_value_and_border_emits_fill_stroke_text() {
let page = LayoutPage {
width: 612.0,
height: 792.0,
nodes: vec![field_node_with_border(
"name", 10.0, 10.0, 200.0, 20.0, "Hello",
)],
};
let cmds = layout_to_commands(&page, &test_config());
assert_eq!(cmds.len(), 3); assert!(matches!(cmds[0], XfaPaintCommand::FillRect { .. }));
assert!(matches!(cmds[1], XfaPaintCommand::StrokeRect { .. }));
assert!(matches!(cmds[2], XfaPaintCommand::DrawText { .. }));
}
#[test]
fn field_without_border_style_no_stroke() {
let page = LayoutPage {
width: 612.0,
height: 792.0,
nodes: vec![field_node("name", 10.0, 10.0, 200.0, 20.0, "Hello")],
};
let cmds = layout_to_commands(&page, &test_config());
assert_eq!(cmds.len(), 2); assert!(matches!(cmds[0], XfaPaintCommand::FillRect { .. }));
assert!(matches!(cmds[1], XfaPaintCommand::DrawText { .. }));
}
#[test]
fn empty_field_no_text_command() {
let page = LayoutPage {
width: 612.0,
height: 792.0,
nodes: vec![field_node("name", 10.0, 10.0, 200.0, 20.0, "")],
};
let cmds = layout_to_commands(&page, &test_config());
assert_eq!(cmds.len(), 1); }
#[test]
fn transparent_background_no_fill() {
let mut config = test_config();
config.background_color = None;
let page = LayoutPage {
width: 612.0,
height: 792.0,
nodes: vec![field_node("name", 10.0, 10.0, 200.0, 20.0, "Hi")],
};
let cmds = layout_to_commands(&page, &config);
assert_eq!(cmds.len(), 1); assert!(matches!(cmds[0], XfaPaintCommand::DrawText { .. }));
}
#[test]
fn multiline_text_emits_multiline_command() {
let page = LayoutPage {
width: 612.0,
height: 792.0,
nodes: vec![LayoutNode {
form_node: xfa_layout_engine::form::FormNodeId(0),
rect: Rect {
x: 10.0,
y: 10.0,
width: 200.0,
height: 60.0,
},
name: "memo".into(),
content: LayoutContent::WrappedText {
lines: vec!["Line 1".into(), "Line 2".into()],
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::SansSerif,
space_above_pt: None,
space_below_pt: None,
from_field: false,
},
children: vec![],
style: Default::default(),
display_items: vec![],
save_items: vec![],
}],
};
let cmds = layout_to_commands(&page, &test_config());
assert_eq!(cmds.len(), 2);
assert!(matches!(cmds[0], XfaPaintCommand::FillRect { .. }));
assert!(matches!(cmds[1], XfaPaintCommand::DrawMultilineText { .. }));
}
#[test]
fn multiple_nodes_coordinate_mapping() {
let page = LayoutPage {
width: 612.0,
height: 792.0,
nodes: vec![
field_node("a", 10.0, 10.0, 100.0, 20.0, "A"),
field_node("b", 10.0, 40.0, 100.0, 20.0, "B"),
],
};
let cmds = layout_to_commands(&page, &test_config());
assert_eq!(cmds.len(), 4);
if let XfaPaintCommand::FillRect { y: y1, .. } = &cmds[0] {
if let XfaPaintCommand::FillRect { y: y2, .. } = &cmds[2] {
assert!(
y1 > y2,
"first field (y=10) should have higher PDF y than second (y=40)"
);
}
}
}
#[test]
fn checkbox_emits_checkbox_command() {
let page = LayoutPage {
width: 612.0,
height: 792.0,
nodes: vec![LayoutNode {
form_node: xfa_layout_engine::form::FormNodeId(0),
rect: Rect {
x: 10.0,
y: 10.0,
width: 15.0,
height: 15.0,
},
name: "check1".into(),
content: LayoutContent::Field {
value: "1".into(),
field_kind: FieldKind::Checkbox,
font_size: 10.0,
font_family: FontFamily::SansSerif,
},
children: vec![],
style: Default::default(),
display_items: vec![],
save_items: vec![],
}],
};
let cmds = layout_to_commands(&page, &test_config());
assert_eq!(cmds.len(), 1);
assert!(matches!(
cmds[0],
XfaPaintCommand::DrawCheckbox { checked: true, .. }
));
}
#[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}"), "?"); }
#[test]
fn child_coordinates_accumulate() {
let page = LayoutPage {
width: 612.0,
height: 792.0,
nodes: vec![LayoutNode {
form_node: xfa_layout_engine::form::FormNodeId(0),
rect: Rect {
x: 50.0,
y: 100.0,
width: 200.0,
height: 200.0,
},
name: "parent".into(),
content: LayoutContent::None,
children: vec![LayoutNode {
form_node: xfa_layout_engine::form::FormNodeId(1),
rect: Rect {
x: 10.0,
y: 10.0,
width: 100.0,
height: 20.0,
},
name: "child".into(),
content: LayoutContent::Field {
value: "Test".into(),
field_kind: FieldKind::Text,
font_size: 10.0,
font_family: FontFamily::SansSerif,
},
children: vec![],
style: Default::default(),
display_items: vec![],
save_items: vec![],
}],
style: Default::default(),
display_items: vec![],
save_items: vec![],
}],
};
let config = XfaRenderConfig::default();
let cmds = layout_to_commands(&page, &config);
let text_cmd = cmds
.iter()
.find(|c| matches!(c, XfaPaintCommand::DrawText { .. }));
assert!(text_cmd.is_some());
if let Some(XfaPaintCommand::DrawText { x, .. }) = text_cmd {
assert!(
(*x - 60.0).abs() < 0.1,
"child x should be parent(50) + child(10) = 60, got {}",
x
);
}
}
}