Merge PR #4319: M12: XFA borders and rectangles with rounded corners are drawn rounded

parity/acrobat-features.toml: form.xfa-dynamic evidence: both sides' entries; notes: both sides' insertions.
This commit is contained in:
aiongg
2026-10-24 06:24:30 +00:00
6 changed files with 295 additions and 12 deletions
+82 -5
View File
@@ -44,13 +44,45 @@ pub(crate) const UNDERLINE_DROP: f64 = 0.1;
#[derive(Clone, Debug, PartialEq)]
pub enum Item {
Fill { rect: Rect, color: [f64; 3] },
Line { from: (f64, f64), to: (f64, f64), width: f64, color: [f64; 3], dashed: bool },
Fill {
rect: Rect,
color: [f64; 3],
},
Line {
from: (f64, f64),
to: (f64, f64),
width: f64,
color: [f64; 3],
dashed: bool,
},
/// A box with rounded (or inverted) corners: its fill, and its edge drawn inside it.
Rounded(Box<Rounded>),
Text(Span),
Image { rect: Rect, data: Arc<Vec<u8>>, content_type: String },
Image {
rect: Rect,
data: Arc<Vec<u8>>,
content_type: String,
},
Widget(Box<Widget>),
}
/// A box with rounded corners ([`Item::Rounded`]).
#[derive(Clone, Debug, PartialEq)]
pub struct Rounded {
pub rect: Rect,
/// Top-left, top-right, bottom-right and bottom-left radii, each at most half the box's
/// shorter side.
pub radii: [f64; 4],
/// Corners whose curve goes into the box rather than around it, in the same order.
pub inverted: [bool; 4],
pub fill: Option<[f64; 3]>,
/// The edge: its width, colour and whether it is dashed.
pub edge: Option<(f64, [f64; 3], bool)>,
/// A page break cut the box here: that side has no edge, and its corners are square.
pub open_top: bool,
pub open_bottom: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub enum WidgetKind {
Text,
@@ -339,7 +371,7 @@ fn solid_extent(item: &Item) -> Option<(f64, f64)> {
Item::Text(s) => Some((s.baseline - s.face.size * 0.8, s.baseline + s.face.size * 0.2)),
Item::Image { rect, .. } => Some((rect.y, rect.bottom())),
Item::Widget(w) => Some((w.rect.y, w.rect.bottom())),
Item::Fill { .. } | Item::Line { .. } => None,
Item::Fill { .. } | Item::Line { .. } | Item::Rounded(_) => None,
}
}
@@ -374,6 +406,23 @@ fn divide(item: Item, cut: f64) -> (Option<Item>, Option<Item>) {
(Some(Item::Line { from: lo, to: mid, width, color, dashed }), Some(Item::Line { from: mid, to: hi, width, color, dashed }))
}
}
Item::Rounded(r) => {
if r.rect.bottom() <= cut + 0.01 {
(Some(Item::Rounded(r)), None)
} else if r.rect.y >= cut - 0.01 {
(None, Some(Item::Rounded(r)))
} else {
// Each part keeps the corners on its side, rounded no more than its own height
// allows, and is open where it was cut.
let [tl, tr, br, bl] = r.radii;
let above = Rect::new(r.rect.x, r.rect.y, r.rect.w, cut - r.rect.y);
let below = Rect::new(r.rect.x, cut, r.rect.w, r.rect.bottom() - cut);
let fit = |v: f64, h: f64| v.min(h.max(0.0));
let upper = Rounded { rect: above, radii: [fit(tl, above.h), fit(tr, above.h), 0.0, 0.0], open_bottom: true, ..(*r).clone() };
let lower = Rounded { rect: below, radii: [0.0, 0.0, fit(br, below.h), fit(bl, below.h)], open_top: true, ..*r };
(Some(Item::Rounded(Box::new(upper))), Some(Item::Rounded(Box::new(lower))))
}
}
other => (Some(other), None),
}
}
@@ -385,6 +434,10 @@ fn moved(mut item: Item, dx: f64, dy: f64) -> Item {
rect.x += dx;
rect.y += dy;
}
Item::Rounded(r) => {
r.rect.x += dx;
r.rect.y += dy;
}
Item::Line { from, to, .. } => {
*from = (from.0 + dx, from.1 + dy);
*to = (to.0 + dx, to.1 + dy);
@@ -1408,11 +1461,35 @@ impl Layouter<'_> {
}
}
/// Draw border `b` around `rect`: its fill, then each shown edge as a line inside the box, or
/// the whole box as one rounded shape when its corners are rounded.
fn draw_border(&mut self, b: &Border, rect: Rect) -> Result<(), XfaError> {
let vis = b.visible_edges();
// Rounded corners: the box is one shape, filled and edged along its curves, when its
// edges are all alike (all shown and the same, or all hidden). Edges that differ keep
// square corners.
let radii = b.radii(rect.w, rect.h);
let alike = b.edges.iter().all(|e| *e == b.edges[0]);
if radii.iter().any(|r| *r > 0.0) && (vis == [true; 4] && alike || vis == [false; 4]) {
let e = b.edges[0];
let edge = vis[0].then(|| {
let color = if e.stroke == Stroke::Relief { [0.55, 0.55, 0.55] } else { e.color.0 };
(e.thickness.clamp(0.1, 20.0), color, matches!(e.stroke, Stroke::Dashed | Stroke::Dotted))
});
let shape = Rounded {
rect,
radii,
inverted: b.corners.map(|c| c.inverted),
fill: b.fill().map(|f| f.0),
edge,
open_top: false,
open_bottom: false,
};
return self.push(Item::Rounded(Box::new(shape)));
}
if let Some(fill) = b.fill() {
self.push(Item::Fill { rect, color: fill.0 })?;
}
let vis = b.visible_edges();
let (x0, y0, x1, y1) = (rect.x, rect.y, rect.right(), rect.bottom());
// Top, right, bottom, left; lines sit inside the box.
let segs = [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x0, y1), (x1, y1)), ((x0, y0), (x0, y1))];
+22 -2
View File
@@ -125,11 +125,22 @@ impl Edge {
}
}
/// A box border: four edges (top, right, bottom, left) and a fill.
/// One corner of a border: how far it rounds the box (`radius`), and whether the curve goes
/// into the box (`inverted`) rather than around it.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Corner {
pub presence: Presence,
pub radius: f64,
pub inverted: bool,
}
/// A box border: four edges (top, right, bottom, left), four corners (top-left, top-right,
/// bottom-right, bottom-left) and a fill.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Border {
pub presence: Presence,
pub edges: [Edge; 4],
pub corners: [Corner; 4],
pub fill: Option<Color>,
}
@@ -144,6 +155,15 @@ impl Border {
pub fn fill(&self) -> Option<Color> {
if self.presence == Presence::Visible { self.fill } else { None }
}
/// How far each corner (top-left, top-right, bottom-right, bottom-left) rounds a box `w` by
/// `h`: its radius when the corner is shown, no more than half the box's shorter side, and 0
/// for a hidden corner or a hidden border.
pub fn radii(&self, w: f64, h: f64) -> [f64; 4] {
let most = (w.min(h) / 2.0).max(0.0);
let shown = self.presence == Presence::Visible;
self.corners.map(|c| if shown && c.presence == Presence::Visible && c.radius.is_finite() { c.radius.clamp(0.0, most) } else { 0.0 })
}
}
/// Font properties as written on an element; `None` inherits from the ancestors.
@@ -581,7 +601,7 @@ pub enum Value {
slope_up: bool,
edge: Edge,
},
Rectangle(Border),
Rectangle(Box<Border>),
}
impl Value {
+18 -2
View File
@@ -146,7 +146,23 @@ fn parse_border(n: Option<XmlNode>) -> Option<Border> {
3 => [e(0), e(1), e(2), e(1)],
_ => [e(0), e(1), e(2), e(3)],
};
Some(Border { presence: Presence::parse(n.attribute("presence")), edges: four, fill: parse_fill(child(n, "fill")) })
Some(Border { presence: Presence::parse(n.attribute("presence")), edges: four, corners: parse_corners(n), fill: parse_fill(child(n, "fill")) })
}
/// 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.
fn parse_corners(n: XmlNode) -> [Corner; 4] {
let given: Vec<Corner> = children(n, "corner")
.take(4)
.map(|c| Corner {
presence: Presence::parse(c.attribute("presence")),
radius: attr_measure(c, "radius").filter(|r| r.is_finite()).unwrap_or(0.0).max(0.0),
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))
}
/// `<font>`: typeface, size, weight, posture, underline, fill color and horizontal scale.
@@ -389,7 +405,7 @@ fn parse_value(n: Option<XmlNode>, warnings: &mut Vec<String>) -> Value {
"rectangle" => {
let mut b = parse_border(Some(c)).unwrap_or_default();
b.fill = parse_fill(child(c, "fill"));
return Value::Rectangle(b);
return Value::Rectangle(Box::new(b));
}
"date" | "time" | "dateTime" | "integer" | "decimal" | "float" | "boolean" => return Value::Text(text_of(c)),
_ => {}
+73 -1
View File
@@ -8,7 +8,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use pdfcraft_fonts::{literal, win_ansi};
use crate::XfaError;
use crate::layout::{Action, BorderShape, Form, Item, Page, Widget, WidgetKind};
use crate::layout::{Action, BorderShape, Form, Item, Page, Rounded, Widget, WidgetKind};
use crate::model::Rect;
use crate::text::{Face, Family, Span};
@@ -129,6 +129,77 @@ fn all_faces() -> Vec<(&'static str, &'static str)> {
out
}
/// The content that draws rounded box `r` on a page `page_h` tall: its fill along its curves,
/// then its edge inside it. A side a page break cut has no edge.
pub(crate) fn rounded(r: &Rounded, page_h: f64) -> String {
let mut c = String::new();
if let Some(color) = r.fill {
c.push_str(&format!("{} rg {}f\n", rgb(color), outline(r.rect, r.radii, r.inverted, (false, false), page_h)));
}
if let Some((w, color, dashed)) = r.edge {
// The edge's centre line, half its width inside the box, so the edge stays in: a corner
// curving around the box tightens by as much, and one curving into it widens.
let inset = Rect::new(r.rect.x + w / 2.0, r.rect.y + w / 2.0, (r.rect.w - w).max(0.0), (r.rect.h - w).max(0.0));
let most = inset.w.min(inset.h) / 2.0;
let radii: [f64; 4] = std::array::from_fn(|i| match (r.radii[i], r.inverted[i]) {
(v, _) if v <= 0.0 => 0.0,
(v, true) => (v + w / 2.0).min(most),
(v, false) => (v - w / 2.0).clamp(0.0, most),
});
let path = outline(inset, radii, r.inverted, (r.open_top, r.open_bottom), page_h);
c.push_str(&format!("q {} RG {} w {}{path}S Q\n", rgb(color), n(w), if dashed { "[2 2] 0 d " } else { "" }));
}
c
}
/// The path around `rect` (top-left based, on a page `page_h` tall) with the corners `radii`
/// (top-left, top-right, bottom-right, bottom-left), each curving around the box or, when
/// `inverted`, into it. `open` leaves out the top or bottom side (and doesn't close the path):
/// those are where a page break cut the box. Ends with a space, for the paint operator.
fn outline(rect: Rect, radii: [f64; 4], inverted: [bool; 4], open: (bool, bool), page_h: f64) -> String {
let (x0, y0, x1, y1) = (rect.x, rect.y, rect.right(), rect.bottom());
let pt = |q: (f64, f64)| format!("{} {}", n(q.0), n(page_h - q.1));
// Corner `i`: its point, then where its curve starts and ends, going clockwise.
let corner = |i: usize| {
let r = radii.get(i).copied().unwrap_or(0.0);
match i {
0 => ((x0, y0), (x0, y0 + r), (x0 + r, y0)),
1 => ((x1, y0), (x1 - r, y0), (x1, y0 + r)),
2 => ((x1, y1), (x1, y1 - r), (x1 - r, y1)),
_ => ((x0, y1), (x0 + r, y1), (x0, y1 - r)),
}
};
// Corner `i`'s curve, from its start: a quarter circle as one Bézier curve.
let curve = |i: usize| {
if radii.get(i).copied().unwrap_or(0.0) <= 0.0 {
return String::new();
}
let (p, a, b) = corner(i);
const K: f64 = 0.5523;
let (c1, c2) = if inverted.get(i).copied().unwrap_or(false) {
// Centred on the corner itself.
((a.0 + (b.0 - p.0) * K, a.1 + (b.1 - p.1) * K), (b.0 + (a.0 - p.0) * K, b.1 + (a.1 - p.1) * K))
} else {
((a.0 + (p.0 - a.0) * K, a.1 + (p.1 - a.1) * K), (b.0 + (p.0 - b.0) * K, b.1 + (p.1 - b.1) * K))
};
format!("{} {} {} c ", pt(c1), pt(c2), pt(b))
};
// Along corners `from`, joining them with straight sides.
let along = |from: &[usize]| {
let mut s = String::new();
for &i in from {
s.push_str(&format!("{} l {}", pt(corner(i).1), curve(i)));
}
s
};
match open {
(false, false) => format!("{} m {}{}{}{}h ", pt(corner(0).1), curve(0), along(&[1]), along(&[2]), along(&[3])),
(true, false) => format!("{} m {}{} l ", pt((x1, y0)), along(&[2, 3]), pt((x0, y0))),
(false, true) => format!("{} m {}{} l ", pt((x0, y1)), along(&[0, 1]), pt((x1, y1))),
(true, true) => format!("{} m {} l {} m {} l ", pt((x0, y0)), pt((x0, y1)), pt((x1, y0)), pt((x1, y1))),
}
}
/// A PDF appearance-state name for a check button's on value.
fn state_name(on: &str) -> String {
let s: String = on.trim().chars().map(|c| if c.is_whitespace() || "()<>[]{}/%#".contains(c) || c.is_control() { '_' } else { c }).collect();
@@ -619,6 +690,7 @@ impl Emitter<'_> {
Item::Fill { rect, color } => {
content.extend(format!("{} rg {} {} {} {} re f\n", rgb(*color), n(rect.x), n(h - rect.bottom()), n(rect.w), n(rect.h)).bytes());
}
Item::Rounded(r) => content.extend(rounded(r, h).bytes()),
Item::Line { from, to, width, color, dashed } => {
content.extend(
format!(
+98
View File
@@ -2265,6 +2265,104 @@ fn a_multi_line_field_without_a_height_grows_to_hold_its_text() {
assert!((notes.rect.h - 108.0).abs() < 0.01, "{:?}", notes.rect);
}
/// The rounded boxes laid out on `page`, in order.
fn rounded(page: &Page) -> Vec<&crate::layout::Rounded> {
page.items.iter().filter_map(|i| if let Item::Rounded(r) = i { Some(r.as_ref()) } else { None }).collect()
}
/// A border or rectangle with rounded corners is one shape, filled and edged along its curves:
/// one corner applies to all four, a corner not given repeats the last one given, a radius is at
/// most half the box's shorter side, and inverted corners are kept. Hidden corners, and edges
/// that differ from each other, keep the square box.
#[test]
fn rounded_corners_make_a_border_one_rounded_shape() {
let draw = |name: &str, y: f64, border: &str| {
format!(r#"<draw name="{name}" x="0in" y="{y}in" w="2in" h="1in"><value><text>{name}</text></value>{border}</draw>"#)
};
let body = [
draw("round", 0.0, r#"<border><edge thickness="1pt"><color value="255,0,0"/></edge><corner radius="0.25in"/><fill><color value="230,230,230"/></fill></border>"#),
draw("mixed", 1.2, r#"<border><edge/><corner radius="0.1in"/><corner radius="0.2in" inverted="1"/></border>"#),
draw("huge", 2.4, r#"<border><edge/><corner radius="3in"/></border>"#),
draw("hiddenCorner", 3.6, r#"<border><edge/><corner radius="0.25in" presence="hidden"/></border>"#),
draw("unlikeEdges", 4.8, r#"<border><edge/><edge presence="hidden"/><corner radius="0.25in"/></border>"#),
draw("fillOnly", 6.0, r#"<border><edge presence="hidden"/><corner radius="0.25in"/><fill><color value="200,200,200"/></fill></border>"#),
r#"<draw name="shape" x="0in" y="7.2in" w="2in" h="1in"><value><rectangle><edge thickness="0.5pt"/><corner radius="5.08mm"/></rectangle></value></draw>"#.to_string(),
]
.concat();
let form = layout_xml(&one_area_form(r#"layout="tb""#, &format!(r#"<subform name="body" layout="position" w="7.5in" h="9in">{body}</subform>"#)))
.unwrap();
let shapes = rounded(&form.pages[0]);
let at = |y: f64| shapes.iter().copied().find(|r| (r.rect.y - 36.0 - y * 72.0).abs() < 0.01);
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))));
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!(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");
assert_eq!((fill_only.fill.is_some(), fill_only.edge), (true, None));
let shape = at(7.2).expect("a rectangle drawn as a value");
assert!((shape.radii[0] - 5.08 * 72.0 / 25.4).abs() < 1e-9 && shape.edge.is_some());
// The square boxes are drawn as before: edges as lines.
let lines_at = |y: f64| {
form.pages[0]
.items
.iter()
.filter(|i| matches!(i, Item::Line { from, .. } if from.1 >= 36.0 + y * 72.0 - 1.0 && from.1 <= 36.0 + (y + 1.0) * 72.0 + 1.0))
.count()
};
assert_eq!((lines_at(3.6), lines_at(4.8)), (4, 2));
}
/// 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]
fn a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page() {
// A 12-inch box in a 10-inch content area: it is cut at the area's bottom.
let body = r#"<subform name="body" layout="tb" w="7.5in"><subform name="tall" layout="position" w="7.5in" h="12in"><border><edge/><corner radius="0.25in"/></border><draw name="note" x="0in" y="0in" w="2in" h="0.3in"><value><text>Note</text></value></draw></subform></subform>"#;
let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap();
assert_eq!(form.pages.len(), 2);
let (first, last) = (rounded(&form.pages[0]), rounded(&form.pages[1]));
assert_eq!((first.len(), last.len()), (1, 1), "{first:?} {last:?}");
assert_eq!((first[0].radii, first[0].open_top, first[0].open_bottom), ([18.0, 18.0, 0.0, 0.0], false, true));
assert_eq!((last[0].radii, last[0].open_top, last[0].open_bottom), ([0.0, 0.0, 18.0, 18.0], true, false));
assert!((first[0].rect.h + last[0].rect.h - 12.0 * 72.0).abs() < 0.01, "the two parts make the whole box");
}
/// A rounded box is written as one path: filled along its curves, then edged half its width
/// inside, the curves as quarter circles. An inverted corner curves into the box, and the edge
/// of a box a page break cut doesn't close across the cut.
#[test]
fn rounded_boxes_are_written_as_curved_paths() {
use crate::layout::Rounded;
// A 2-by-1-inch box at the page's top left, on a page 792pt tall.
let shape = |inverted: [bool; 4], edge: bool, open: (bool, bool)| Rounded {
rect: Rect::new(36.0, 36.0, 144.0, 72.0),
radii: [18.0; 4],
inverted,
fill: Some([0.9, 0.9, 0.9]),
edge: edge.then_some((2.0, [1.0, 0.0, 0.0], false)),
open_top: open.0,
open_bottom: open.1,
};
let content = |r: Rounded| crate::pdf::rounded(&r, 792.0);
let ops = content(shape([false; 4], true, (false, false)));
// The fill: from the top-left corner's start, a quarter circle to (54, 36), closed.
assert!(ops.contains("0.9 0.9 0.9 rg 36 738 m 36 747.941 44.059 756 54 756 c "), "{ops}");
assert!(ops.contains(" h f\n"), "{ops}");
// The edge: 1pt inside, the corners 1pt tighter.
assert!(ops.contains("q 1 0 0 RG 2 w 37 738 m 37 747.389 44.611 755 54 755 c "), "{ops}");
assert!(ops.contains(" h S Q\n"), "{ops}");
// Inverted: the curve bends towards the box's inside.
let inv = content(shape([true; 4], false, (false, false)));
assert!(inv.contains("36 738 m 45.941 738 54 746.059 54 756 c "), "{inv}");
// Cut at the bottom: the edge runs from the bottom left round the top and back down, open.
let cut = content(Rounded { radii: [18.0, 18.0, 0.0, 0.0], ..shape([false; 4], true, (false, true)) });
let edge = cut.lines().find(|l| l.contains(" RG ")).unwrap_or_default();
assert!(edge.starts_with("q 1 0 0 RG 2 w 37 685 m 37 738 l ") && edge.ends_with(" 179 685 l S Q") && !edge.contains(" h "), "{edge}");
}
#[test]
fn a_page_break_keeps_a_fields_border_with_the_field() {
// 6in of heading, then a bordered 5in field: it doesn't fit the 10in area and moves to
File diff suppressed because one or more lines are too long