xfa-layout-engine 1.0.0-beta.5

Box-model and pagination layout engine for XFA forms. Experimental — part of the PDFluent XFA stack, under active development.
Documentation
use xfa_layout_engine::form::{FormNode, FormNodeId, FormNodeType, FormTree, Occur};
use xfa_layout_engine::layout::{LayoutContent, LayoutEngine, LayoutNode};
use xfa_layout_engine::text::FontMetrics;
use xfa_layout_engine::types::{BoxModel, LayoutStrategy};

fn make_text_field(tree: &mut FormTree, name: &str, value: &str, w: f64, h: f64) -> FormNodeId {
    tree.add_node(FormNode {
        name: name.to_string(),
        node_type: FormNodeType::Field {
            value: value.to_string(),
        },
        box_model: BoxModel {
            width: Some(w),
            height: Some(h),
            max_width: f64::MAX,
            max_height: f64::MAX,
            ..Default::default()
        },
        layout: LayoutStrategy::Positioned,
        children: vec![],
        occur: Occur::once(),
        font: FontMetrics::default(),
        calculate: None,
        validate: None,
        column_widths: vec![],
        col_span: 1,
    })
}

fn make_root(
    tree: &mut FormTree,
    width: f64,
    height: f64,
    children: Vec<FormNodeId>,
) -> FormNodeId {
    tree.add_node(FormNode {
        name: "Root".to_string(),
        node_type: FormNodeType::Root,
        box_model: BoxModel {
            width: Some(width),
            height: Some(height),
            max_width: f64::MAX,
            max_height: f64::MAX,
            ..Default::default()
        },
        layout: LayoutStrategy::TopToBottom,
        children,
        occur: Occur::once(),
        font: FontMetrics::default(),
        calculate: None,
        validate: None,
        column_widths: vec![],
        col_span: 1,
    })
}

fn find_named_node<'a>(nodes: &'a [LayoutNode], name: &str) -> Option<&'a LayoutNode> {
    for node in nodes {
        if node.name == name {
            return Some(node);
        }
        if let Some(found) = find_named_node(&node.children, name) {
            return Some(found);
        }
    }
    None
}

fn lines_for_field(value: &str, border_width_pt: Option<f64>) -> Vec<String> {
    let mut tree = FormTree::new();
    let field = make_text_field(&mut tree, "wrapped_field", value, 100.0, 60.0);

    {
        let style = &mut tree.meta_mut(field).style;
        style.margin_left_pt = Some(0.0);
        style.margin_right_pt = Some(0.0);
        style.border_width_pt = border_width_pt;
    }
    tree.get_mut(field).box_model.border_width = border_width_pt.unwrap_or(0.0);

    let root = make_root(&mut tree, 100.0, 100.0, vec![field]);
    let layout = LayoutEngine::new(&tree).layout(root).unwrap();
    let field_node = find_named_node(&layout.pages[0].nodes, "wrapped_field")
        .expect("wrapped_field should appear in the first page layout");

    match &field_node.content {
        LayoutContent::WrappedText { lines, .. } => lines.clone(),
        other => panic!("expected WrappedText content, got {other:?}"),
    }
}

#[test]
fn border_width_reduces_available_width_for_wrapping() {
    let text = "AAAAAA AAAAAA AA";
    let measured_width = FontMetrics::default().measure_width(text);
    assert!(
        measured_width > 90.0 && measured_width <= 100.0,
        "test text must fit 100pt but overflow 90pt, got width {measured_width:.2}: {text:?}"
    );

    let lines_without_border = lines_for_field(text, None);
    assert_eq!(
        lines_without_border.len(),
        1,
        "text should stay on one line without border width: {text:?}"
    );

    let lines_with_border = lines_for_field(text, Some(5.0));
    assert_eq!(
        lines_with_border.len(),
        2,
        "5pt border on each side should reduce available width from 100pt to 90pt"
    );
    assert_eq!(lines_with_border[0], "AAAAAA AAAAAA");
    assert_eq!(lines_with_border[1], "AA");
}

#[test]
fn two_point_border_shrinks_content_width_by_four_points() {
    let no_border = BoxModel {
        width: Some(100.0),
        max_width: f64::MAX,
        max_height: f64::MAX,
        ..Default::default()
    };
    let two_point_border = BoxModel {
        width: Some(100.0),
        border_width: 2.0,
        max_width: f64::MAX,
        max_height: f64::MAX,
        ..Default::default()
    };

    assert_eq!(no_border.content_width(), 100.0);
    assert_eq!(two_point_border.content_width(), 96.0);
    assert_eq!(
        no_border.content_width() - two_point_border.content_width(),
        4.0,
        "2pt border on both sides should shrink the content area by exactly 4pt"
    );
}