M12: XFA border corners not listed are drawn the way edges are

XFA 3.3 p. 38 gives a border's corners in the order top-left, top-right,
bottom-right, bottom-left, as its edges go top, right, bottom, left, and
says that when fewer than four are listed the last one listed is reused
for the rest. Readers don't do that: every reader tested draws a side not
listed from the edge or corner on the same side, one listed serving all
four sides, two alternating (0, 1, 0, 1), three reusing the second for
the fourth (0, 1, 2, 1). The layout already drew edges the readers' way,
but drew corners as p. 38's text says, so with corners [round, square]
only the top-left was round where readers round the top-left and the
bottom-right.

Corners now follow the readers too, through one helper, drawn_side, that
edges and corners share: with [round, square] the top-left and
bottom-right are round and the top-right and bottom-left square. Edges
are drawn as before. The scripting host's reads of a corner past those
listed (from the previous commit) mirror the same pattern, so a script
reads the corner the page draws.

Real forms (358 public forms with XFA, templates read through the
engine): 337 borders in 51 forms list two or three corners that differ,
all only in a square corner's presence, which the layout doesn't draw
differently; none has a rounded or inverted corner that would move, so
no rendered page changes.

Tests: one, two and three corners, two in both orders, and the
rounded-corners test's two-corner box (now [r1, r2, r1, r2]) fail with
the change reverted; a script test reads corners past those listed by
the same pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
This commit is contained in:
mtlcl410
2026-10-29 17:56:53 +08:00
co-authored by Claude Opus 5.5
parent 34f3716f5e
commit 8d40450d22
5 changed files with 79 additions and 21 deletions
+4 -2
View File
@@ -837,8 +837,10 @@ pub(crate) fn node_color(h: &XHost, node: usize, path: &str) -> String {
/// than four. XFA 3.3 p. 38 says the last one listed is reused for the rest; readers draw a
/// side not listed from the edge or corner on the same side instead, and reads follow them:
/// one serves every side, two alternate (0, 1, 0, 1), three reuse the second for the fourth
/// (0, 1, 2, 1). `None` past the four sides, and when none is listed (the side is a default
/// one).
/// (0, 1, 2, 1). `None` past the four sides, and when none is
/// listed (the side is a default one). The layout picks edges and corners with the same
/// pattern (`drawn_side` in `pdfcraft_xfa`, which this crate doesn't depend on); tests on both
/// sides pin it.
fn drawn_index(listed: usize, index: usize) -> Option<usize> {
match listed {
// A border has four sides: edges and corners occur at most four times (p. 629).
+18
View File
@@ -688,6 +688,24 @@ fn border_color_colours_all_four_edges() {
assert_eq!(eval("this.borderColor = '7,7,7'; this.border.getElement('edge', 3).thickness", "styled"), "2pt");
}
/// A corner past those a border lists reads as the corner drawn there: one serves all four,
/// two alternate, three reuse the second for the fourth, as for edges.
#[test]
fn corners_not_listed_read_as_the_corner_drawn_there() {
let corners = |listed: &[&str]| {
let kids = listed.iter().map(|r| p("corner", &[("radius", r)], vec![])).collect();
let f = node("c", XfaKind::Field, &[], vec![p("border", &[], kids)]);
let mut root = form();
root.children.push(f);
let o = go_on(&root, "[0, 1, 2, 3].map(function (i) { return this.border.getElement('corner', i).radius; }, this).join('|')", "c");
assert_eq!(o.error, None, "{o:?}");
o.result.unwrap_or_default()
};
assert_eq!(corners(&["1pt"]), "1pt|1pt|1pt|1pt");
assert_eq!(corners(&["1pt", "2pt"]), "1pt|2pt|1pt|2pt");
assert_eq!(corners(&["1pt", "2pt", "3pt"]), "1pt|2pt|3pt|2pt");
}
/// A border's presence reads `hidden` when the object has no `<border>` element, as readers
/// read it, and `visible` when it has one that doesn't say (XFA 3.3 p. 629).
#[test]
+25 -16
View File
@@ -136,26 +136,36 @@ fn parse_fill(n: Option<XmlNode>) -> Option<Color> {
Some(parse_color(child(n, "color")).unwrap_or(Color::WHITE))
}
/// `<border>`: edges in XFA order. One edge applies to all four; two: top/bottom then
/// left/right; three: top, left/right, bottom; four: top, right, bottom, left.
/// `<border>`: edges top, right, bottom, left (XFA 3.3 p. 38), the sides not listed drawn as
/// readers draw them ([`drawn_side`]), not as p. 38's text says.
fn parse_border(n: Option<XmlNode>) -> Option<Border> {
let n = n?;
let edges: Vec<Edge> = children(n, "edge").map(parse_edge).collect();
let d = Edge::default();
let e = |i: usize| edges.get(i).copied().unwrap_or(d);
let four = match edges.len() {
0 => [d; 4],
1 => [e(0); 4],
2 => [e(0), e(1), e(0), e(1)],
3 => [e(0), e(1), e(2), e(1)],
_ => [e(0), e(1), e(2), e(3)],
};
let edges: Vec<Edge> = children(n, "edge").take(4).map(parse_edge).collect();
let four = std::array::from_fn(|i| drawn_side(edges.len(), i).and_then(|k| edges.get(k).copied()).unwrap_or_default());
Some(Border { presence: Presence::parse(n.attribute("presence")), edges: four, corners: parse_corners(n), fill: parse_fill(child(n, "fill")) })
}
/// Which of the `listed` edges (or corners) of a border is drawn on side `index`: edges go
/// top, right, bottom, left and corners top-left, top-right, bottom-right, bottom-left (XFA 3.3
/// p. 38). For a border that lists fewer than four, p. 38 says the last one listed is reused
/// for the rest; readers draw a side not listed from the edge or corner on the same side
/// instead, and so does this: one serves every side, two alternate (0, 1, 0, 1), three reuse
/// the second for the fourth (0, 1, 2, 1). Edges already followed the readers; corners now do
/// too. The scripting host's reads mirror the same pattern so a script reads what is drawn.
/// `None` when none is listed.
pub(crate) fn drawn_side(listed: usize, index: usize) -> Option<usize> {
match listed {
0 => None,
_ if index < listed => Some(index),
1 => Some(0),
2 => Some(index % 2),
_ => Some(1),
}
}
/// The `<corner>` children of a border or rectangle, top-left, top-right, bottom-right then
/// bottom-left: one applies to all four, and a corner not given is the last one given. Without
/// any, the corners are square.
/// bottom-left, the ones not listed drawn as [`drawn_side`] picks, as edges are. Without any,
/// the corners are square.
fn parse_corners(n: XmlNode) -> [Corner; 4] {
let given: Vec<Corner> = children(n, "corner")
.take(4)
@@ -165,8 +175,7 @@ fn parse_corners(n: XmlNode) -> [Corner; 4] {
inverted: c.attribute("inverted") == Some("1"),
})
.collect();
let Some(last) = given.last().copied() else { return [Corner::default(); 4] };
std::array::from_fn(|i| given.get(i).copied().unwrap_or(last))
std::array::from_fn(|i| drawn_side(given.len(), i).and_then(|k| given.get(k).copied()).unwrap_or_default())
}
/// `<font>`: typeface, size, weight, posture, underline, fill color and horizontal scale.
+30 -1
View File
@@ -2412,8 +2412,9 @@ fn rounded_corners_make_a_border_one_rounded_shape() {
let round = at(0.0).expect("the rounded border is one shape");
assert_eq!((round.rect.w, round.rect.h, round.radii), (144.0, 72.0, [18.0; 4]));
assert_eq!((round.fill, round.edge), (Some([230.0 / 255.0; 3]), Some((1.0, [1.0, 0.0, 0.0], false))));
// Two corners alternate: the first is drawn top-left and bottom-right.
let mixed = at(1.2).expect("the box with two corners given");
assert_eq!((mixed.radii, mixed.inverted), ([7.2, 14.4, 14.4, 14.4], [false, true, true, true]));
assert_eq!((mixed.radii, mixed.inverted), ([7.2, 14.4, 7.2, 14.4], [false, true, false, true]));
assert_eq!(at(2.4).expect("the huge radius").radii, [36.0; 4], "at most half the shorter side");
assert!(at(3.6).is_none() && at(4.8).is_none(), "hidden corners and unlike edges keep a square box");
let fill_only = at(6.0).expect("a fill with hidden edges is rounded too");
@@ -2431,6 +2432,34 @@ fn rounded_corners_make_a_border_one_rounded_shape() {
assert_eq!((lines_at(3.6), lines_at(4.8)), (4, 2));
}
/// Corners not listed are drawn the way edges are, from the corner on the same side, as
/// readers draw them (XFA 3.3 p. 38 gives the order top-left, top-right, bottom-right,
/// bottom-left, and says the last corner listed is reused): one serves all four, two
/// alternate, three reuse the second for the bottom-left.
#[test]
fn corners_not_listed_follow_the_edge_pattern() {
let radii = |corners: &str| {
let body = format!(
r#"<subform name="body" layout="position" w="7.5in" h="9in"><draw name="d" x="0in" y="0in" w="2in" h="1in"><value><text>d</text></value><border><edge/>{corners}</border></draw></subform>"#
);
let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
rounded(&form.pages[0]).first().map(|r| r.radii)
};
let (round, square) = (r#"<corner radius="0.25in"/>"#, r#"<corner/>"#);
assert_eq!(radii(&format!("{round}{square}")), Some([18.0, 0.0, 18.0, 0.0]), "round top-left and bottom-right");
assert_eq!(radii(&format!("{square}{round}")), Some([0.0, 18.0, 0.0, 18.0]));
assert_eq!(
radii(r#"<corner radius="5pt"/><corner radius="10pt"/><corner radius="15pt"/>"#),
Some([5.0, 10.0, 15.0, 10.0]),
"the second serves the bottom-left"
);
assert_eq!(radii(round), Some([18.0; 4]));
for (listed, sides) in [(1, [0, 0, 0, 0]), (2, [0, 1, 0, 1]), (3, [0, 1, 2, 1]), (4, [0, 1, 2, 3])] {
assert_eq!(std::array::from_fn::<_, 4, _>(|i| crate::parse::drawn_side(listed, i)), sides.map(Some), "{listed}");
}
assert_eq!(crate::parse::drawn_side(0, 2), None);
}
/// A rounded box a page break cuts keeps its rounded corners on each page, square where it was
/// cut, and its edge stops there instead of closing across the cut.
#[test]
File diff suppressed because one or more lines are too long