Merge pull request #5624 from Piohrun/feat/xfa-rotate

M12: an XFA draw placed by position turns about its anchor
This commit is contained in:
FredApps
2026-11-01 19:43:57 +01:00
committed by GitHub
11 changed files with 563 additions and 21 deletions
+4 -1
View File
@@ -32,7 +32,10 @@ that warning and keeps the existing layout and accepted widget values.
page masters chosen by occurrence; a table's header row repeats after a break; presence
`hidden` takes no space, `invisible` takes space; repeating subforms get their initial
instances, in flowed content and in a top-to-bottom container inside positioned content (a
table in an area) alike; fields that compute the page number or count on layout show the numbers.
table in an area) alike; fields that compute the page number or count on layout show the numbers;
a draw placed by position turns about its anchor (`rotate`: degrees counter-clockwise, a multiple
of 90), as one item a page break doesn't cut, and its container grows to the box it stands in. A
field, a subform or a draw in a flow doesn't turn.
- **PDF** (`pdf::write_form`): pages with content streams in the standard 14 fonts (Arial and
friends → Helvetica, Courier New → Courier, serif faces → Times; nothing is embedded), JPEG
XObjects, and widgets: text (multi-line, max length, alignment), date (Acrobat's `AFDate`
+13 -8
View File
@@ -32,7 +32,7 @@ use common::collect_values;
pub(crate) use common::date_pattern;
pub use common::formatted_value;
pub(crate) use items::UNDERLINE_DROP;
pub use items::{Action, BorderShape, Form, Item, Page, Rounded, Widget, WidgetBorder, WidgetKind};
pub use items::{Action, BorderShape, Form, Item, Page, Rounded, Turned, Widget, WidgetBorder, WidgetKind};
/// Most pages a form may lay out to.
pub const MAX_PAGES: usize = 500;
@@ -212,17 +212,22 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result<Form, XfaError>
let total = l.pages.len();
let count = total.to_string();
for p in &mut l.pages {
for it in &mut p.items {
if let Item::Text(s) = it
&& s.text.contains(PAGE_COUNT_MARK)
{
s.text = s.text.replace(PAGE_COUNT_MARK, &count);
}
}
fill_page_count(&mut p.items, &count);
}
Ok(Form { pages: l.pages, fields: l.fields, warnings: l.warnings })
}
/// Puts the page count, known once every page is, where `items` left room for it.
fn fill_page_count(items: &mut [Item], count: &str) {
for it in items {
match it {
Item::Text(s) if s.text.contains(PAGE_COUNT_MARK) => s.text = s.text.replace(PAGE_COUNT_MARK, count),
Item::Turned(t) => fill_page_count(&mut t.items, count),
_ => {}
}
}
}
// Bytes of embedded field values copied out on this thread, for tests that bound layout work.
#[cfg(test)]
thread_local! {
+6
View File
@@ -51,6 +51,7 @@ pub(super) 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::Turned(t) => Some(t.extent),
Item::Fill { .. } | Item::Line { .. } | Item::Rounded(_) => None,
}
}
@@ -130,6 +131,11 @@ pub(super) fn moved(mut item: Item, dx: f64, dy: f64) -> Item {
w.rect.x += dx;
w.rect.y += dy;
}
Item::Turned(t) => {
t.about = (t.about.0 + dx, t.about.1 + dy);
t.extent = (t.extent.0 + dy, t.extent.1 + dy);
t.items = std::mem::take(&mut t.items).into_iter().map(|i| moved(i, dx, dy)).collect();
}
}
item
}
+22 -2
View File
@@ -1,5 +1,5 @@
use super::common::clean_name;
use super::items::{Item, Rounded};
use super::items::{Item, Rounded, Turned};
use super::{
Arc, Border, Ctx, Draw, ExclGroup, Face, Font, Layout, Layouter, MAX_DEPTH, MAX_IMAGE_BYTES, Node, Para, Presence, Rect, SomIndices, Stroke,
Subform, Value, XfaError, data_path, text,
@@ -35,7 +35,27 @@ impl Layouter<'_> {
}
let (w, h) = self.size_of(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1);
let (x, y) = c.top_left(w, h);
self.draw_node(k, Rect::new(inner.x + x, inner.y + y, w, h), ctx, depth + 1, sib)
let rect = Rect::new(inner.x + x, inner.y + y, w, h);
let start = self.item_count();
self.draw_node(k, rect, ctx, depth + 1, sib)?;
if c.rotate != 0 && matches!(k, Node::Draw(_)) {
let stands = k.standing_box(w, h);
let extent = (inner.y + stands.y, inner.y + stands.bottom());
self.turn(start, (inner.x + c.x.unwrap_or(0.0), inner.y + c.y.unwrap_or(0.0)), c.rotate, extent);
}
Ok(())
}
/// Turn the items the last page got since `start`, a draw, about `about`.
fn turn(&mut self, start: usize, about: (f64, f64), quarters: u8, extent: (f64, f64)) {
let Some(page) = self.pages.last_mut().filter(|p| start < p.items.len()) else { return };
let items = page.items.split_off(start);
page.items.push(Item::Turned(Box::new(Turned { about, quarters, extent, items })));
// One item now, kept whole by what it is: the record of its items, the last one kept,
// would fall on the next.
if self.leaves.last().is_some_and(|leaf| leaf.0 == start) {
self.leaves.pop();
}
}
pub(super) fn draw_node(&mut self, node: &Node, rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> {
+16
View File
@@ -27,6 +27,22 @@ pub enum Item {
content_type: String,
},
Widget(Box<Widget>),
/// A draw turned about its anchor.
Turned(Box<Turned>),
}
/// A draw turned about its anchor ([`Item::Turned`]). It stays one item, so a page break doesn't
/// cut it, and what it holds is written unturned under one matrix.
#[derive(Clone, Debug, PartialEq)]
pub struct Turned {
/// The point it turns about.
pub about: (f64, f64),
/// Quarter turns counter-clockwise, 1 to 3.
pub quarters: u8,
/// Top and bottom of its box once turned.
pub extent: (f64, f64),
/// The draw as it stands before turning.
pub items: Vec<Item>,
}
/// A box with rounded corners ([`Item::Rounded`]).
+2 -2
View File
@@ -141,7 +141,7 @@ impl Layouter<'_> {
Layout::Positioned => visible
.map(|k| {
let (w, h) = self.size_of(k, avail_w, ctx, depth + 1);
k.common().top_left(w, h).0 + w
k.standing_box(w, h).right()
})
.fold(0.0, f64::max)
.min(avail_w),
@@ -297,7 +297,7 @@ impl Layouter<'_> {
.map(|k| {
let c = k.common();
let (w, h) = self.size_of(k, (inner_w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1);
c.top_left(w, h).1 + h
k.standing_box(w, h).bottom()
})
.fold(0.0, f64::max)
}
+23
View File
@@ -334,6 +334,9 @@ pub struct Common {
pub x: Option<f64>,
pub y: Option<f64>,
pub anchor: Anchor,
/// Quarter turns counter-clockwise about the anchor (`rotate`), 0 to 3. Only a draw placed by
/// position is turned.
pub rotate: u8,
pub w: Option<f64>,
pub h: Option<f64>,
pub min_w: Option<f64>,
@@ -366,6 +369,26 @@ impl Common {
}
impl Node {
/// The box a positioned object of `w` by `h` stands in, in its parent: from `x`, `y` and the
/// anchor, and for a draw, the only object that turns, turned about the anchor.
pub fn standing_box(&self, w: f64, h: f64) -> Rect {
let c = self.common();
let (x, y) = c.top_left(w, h);
let upright = Rect::new(x, y, w, h);
if !matches!(self, Node::Draw(_)) {
return upright;
}
let (ax, ay) = (c.x.unwrap_or(0.0), c.y.unwrap_or(0.0));
let (left, top, right, bottom) = (x - ax, y - ay, x + w - ax, y + h - ay);
// Counter-clockwise on a page whose y grows down, from the anchor.
match c.rotate {
1 => Rect::new(ax + top, ay - right, h, w),
2 => Rect::new(ax - right, ay - bottom, w, h),
3 => Rect::new(ax - bottom, ay + left, h, w),
_ => upright,
}
}
pub fn common(&self) -> &Common {
match self {
Node::Subform(s) => &s.common,
+11
View File
@@ -198,6 +198,16 @@ fn parse_para(n: Option<XmlNode>) -> ParaSpec {
}
}
/// `rotate` as quarter turns (XFA 3.3: degrees counter-clockwise, a multiple of 90, `deg` optional):
/// anything else leaves the object as it stands.
fn quarter_turns(rotate: Option<&str>) -> u8 {
let degrees = rotate.map(str::trim).map(|v| v.strip_suffix("deg").unwrap_or(v).trim_end());
let turns = degrees.and_then(|v| v.parse::<f64>().ok()).map(|v| v.rem_euclid(360.0) / 90.0);
// Whole, which NaN and infinities aren't, and in range, so the cast is exact; a tiny negative
// angle rounds to a full turn.
turns.filter(|t| t.fract() == 0.0).map_or(0, |t| t as u8 % 4)
}
fn parse_common(n: XmlNode) -> Common {
Common {
name: n.attribute("name").map(str::to_string).filter(|s| !s.is_empty()),
@@ -205,6 +215,7 @@ fn parse_common(n: XmlNode) -> Common {
x: attr_measure(n, "x"),
y: attr_measure(n, "y"),
anchor: Anchor::parse(n.attribute("anchorType")),
rotate: quarter_turns(n.attribute("rotate")),
w: attr_measure(n, "w"),
h: attr_measure(n, "h"),
min_w: attr_measure(n, "minW"),
+78 -6
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, Rounded, Widget, WidgetKind};
use crate::layout::{Action, BorderShape, Form, Item, Page, Rounded, Turned, Widget, WidgetKind};
use crate::model::Rect;
use crate::text::{Face, Family, Span};
@@ -567,12 +567,30 @@ impl Emitter<'_> {
let h = page.height;
// The link the text before ended in, which a next piece of it on the line widens.
let mut last_link: Option<LinkPiece> = None;
for (i, item) in page.items.iter().enumerate() {
let steps = steps(&page.items);
let mut turn = None;
for (i, step) in steps.iter().enumerate() {
let item = match step {
Step::Item(item) => *item,
Step::Turn(t) => {
let matrix = turn_matrix(t, h);
turn = Some(matrix);
last_link = None;
content.extend(format!("q {} cm\n", matrix.map(n).join(" ")).bytes());
continue;
}
Step::EndTurn => {
turn = None;
last_link = None;
content.extend_from_slice(b"Q\n");
continue;
}
};
// A link's pieces join only across text and the underline drawn just before it: a fill,
// picture, field or any other line (a rule) between them ends the link there.
let keeps_link = match item {
Item::Text(_) => true,
Item::Line { from, to, .. } => matches!(page.items.get(i + 1), Some(Item::Text(s))
Item::Line { from, to, .. } => matches!(steps.get(i + 1), Some(Step::Item(Item::Text(s)))
if (from.1 - to.1).abs() < 0.01
&& (from.0 - s.x).abs() < 0.01
&& (from.1 - (s.baseline + s.face.size * crate::layout::UNDERLINE_DROP)).abs() < 0.01),
@@ -627,7 +645,7 @@ impl Emitter<'_> {
);
content.extend(literal(&win_ansi(&s.text)));
content.extend_from_slice(format!(" Tj{close}\n").as_bytes());
if let Some(r) = self.text_link(s, h, page_ref, &mut last_link)? {
if let Some(r) = self.text_link(s, h, page_ref, &mut last_link, turn)? {
annots.push(Object::Ref(r));
}
}
@@ -637,6 +655,8 @@ impl Emitter<'_> {
xobjects.set(name.clone().into_bytes(), Object::Ref(r));
content.extend(format!("q {} 0 0 {} {} {} cm /{name} Do Q\n", n(rect.w), n(rect.h), n(rect.x), n(h - rect.bottom())).bytes());
}
// Opened out by `steps`.
Item::Turned(_) => {}
Item::Widget(w) => {
if let Some(r) = self.widget(w, page, page_ref)? {
annots.push(Object::Ref(r));
@@ -675,12 +695,25 @@ impl Emitter<'_> {
/// A span that goes on the link `last` ended in, on the same line, widens that annotation
/// instead (a link whose text changes style partway is one link). An address PdfCraft
/// wouldn't open from a document leaves the text as plain text, with one warning for the form.
fn text_link(&mut self, s: &Span, page_height: f64, page_ref: ObjRef, last: &mut Option<LinkPiece>) -> Result<Option<ObjRef>, XfaError> {
fn text_link(
&mut self,
s: &Span,
page_height: f64,
page_ref: ObjRef,
last: &mut Option<LinkPiece>,
turn: Option<[f64; 6]>,
) -> Result<Option<ObjRef>, XfaError> {
let piece = self.link_piece(s, page_height);
let Some((target, rect)) = piece else {
let Some((target, mut rect)) = piece else {
*last = None;
return Ok(None);
};
// Turned text is drawn by a matrix, and clicked where that puts it. Its pieces don't join:
// that goes by the line they stand on before turning.
if let Some(m) = turn {
rect = turned_rect(rect, m);
*last = None;
}
if let Some(prev) = last.as_mut()
&& prev.uri == target
&& (prev.baseline - s.baseline).abs() < 0.01
@@ -765,6 +798,45 @@ fn write_button_action(action: Option<&Action>, d: &mut Dict) {
}
}
/// What a page's content is written from: its items, with a turned draw opened out.
enum Step<'a> {
Item(&'a Item),
Turn(&'a Turned),
EndTurn,
}
fn steps(items: &[Item]) -> Vec<Step<'_>> {
let mut out = Vec::with_capacity(items.len());
for item in items {
if let Item::Turned(t) = item {
out.push(Step::Turn(t));
out.extend(t.items.iter().map(Step::Item));
out.push(Step::EndTurn);
} else {
out.push(Step::Item(item));
}
}
out
}
/// The matrix that turns `t` about its point, on a page `page_height` tall.
fn turn_matrix(t: &Turned, page_height: f64) -> [f64; 6] {
let (cos, sin) = match t.quarters {
1 => (0.0, 1.0),
2 => (-1.0, 0.0),
_ => (0.0, -1.0),
};
let (x, y) = (t.about.0, page_height - t.about.1);
[cos, sin, -sin, cos, x - cos * x + sin * y, y - sin * x - cos * y]
}
/// Rectangle `r`, `[left, bottom, right, top]`, under quarter-turn matrix `m`.
fn turned_rect(r: [f64; 4], m: [f64; 6]) -> [f64; 4] {
let at = |x: f64, y: f64| (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]);
let (a, b) = (at(r[0], r[1]), at(r[2], r[3]));
[a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)]
}
/// The link annotation a page's text last ended in: the next piece of the same link on the same
/// line widens it.
struct LinkPiece {
+386
View File
@@ -4145,3 +4145,389 @@ fn clear_password_values_clears_static_xfa_password_fields() {
let packets = read_packets(&doc).unwrap().unwrap();
assert!(!packets.xdp.contains("hunter2"), "{}", packets.xdp);
}
/// A form with `draws` in a subform placed by position, on a letter page whose content area is a
/// quarter inch in, with `master` on the page itself.
fn form_of_draws(master: &str, draws: &str) -> String {
format!(
r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb"><pageSet><pageArea name="p">{master}<contentArea x="0.25in" y="0.25in" w="8in" h="10.5in"/><medium stock="letter" short="8.5in" long="11in"/></pageArea></pageSet><subform name="s" w="8in" h="2.5in">{draws}</subform></subform></template></xdp:xdp>"#
)
}
/// A line from the top-left to the bottom-right of a box 0.7in × 0.4in, as #5561's form draws.
fn line_draw(x: &str, y: &str, rotate: &str) -> String {
format!(r#"<draw x="{x}" y="{y}" w="0.7in" h="0.4in" {rotate}><value><line><edge thickness="2.5pt"/></line></value></draw>"#)
}
/// `xdp` laid out into its document, and that document's first page.
fn written(xdp: &str) -> (Document, Dict) {
let mut doc = Document::open(Arc::new(shell(xdp))).unwrap();
render_into(&mut doc).unwrap();
let root = doc.get(doc.root().unwrap());
let pages = doc.get(root.as_dict().unwrap().reference(b"Pages").unwrap());
let kids = pages.as_dict().unwrap().get(b"Kids").map(|k| doc.resolve(k)).unwrap();
let page = doc.get(kids.as_array().unwrap()[0].as_ref().unwrap());
let page = page.as_dict().unwrap().clone();
(doc, page)
}
fn page_content(doc: &Document, page: &Dict) -> String {
let contents = doc.get(page.reference(b"Contents").unwrap());
let Object::Stream(s) = &*contents else { panic!("not a stream") };
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
}
/// The ends of each line `content` strokes, on the page: through the matrices around it.
fn stroked_lines(content: &str) -> Vec<[(f64, f64); 2]> {
let (mut stack, mut matrix) = (Vec::new(), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
let (mut numbers, mut from, mut lines): (Vec<f64>, _, _) = (Vec::new(), (0.0, 0.0), Vec::new());
for token in content.split_whitespace() {
let at = |m: [f64; 6], x: f64, y: f64| (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]);
let last = |numbers: &[f64]| (numbers[numbers.len() - 2], numbers[numbers.len() - 1]);
match token {
"q" => stack.push(matrix),
"Q" => matrix = stack.pop().unwrap(),
"cm" => {
let n: [f64; 6] = numbers[numbers.len() - 6..].try_into().unwrap();
let m = matrix;
matrix = [
n[0] * m[0] + n[1] * m[2],
n[0] * m[1] + n[1] * m[3],
n[2] * m[0] + n[3] * m[2],
n[2] * m[1] + n[3] * m[3],
n[4] * m[0] + n[5] * m[2] + m[4],
n[4] * m[1] + n[5] * m[3] + m[5],
];
}
"m" => {
let (x, y) = last(&numbers);
from = at(matrix, x, y);
}
"l" => {
let (x, y) = last(&numbers);
lines.push([from, at(matrix, x, y)]);
}
_ => {}
}
match token.parse() {
Ok(v) => numbers.push(v),
Err(_) => numbers.clear(),
}
}
lines
}
fn turned(page: &Page) -> Vec<&crate::layout::Turned> {
page.items.iter().filter_map(|i| if let Item::Turned(t) = i { Some(t.as_ref()) } else { None }).collect()
}
/// A draw turns counter-clockwise about its anchor. The figures are where Acrobat put these lines
/// on the page it flattened for #5561's form: from the anchor by (+50.4, -28.8) unturned, and by
/// (+28.8, +50.4), (-50.4, +28.8) and (-28.8, -50.4) at 90, 180 and 270 degrees.
#[test]
fn a_draw_turns_counter_clockwise_about_its_anchor() {
let draws = [
line_draw("1in", "0.95in", ""),
line_draw("3in", "0.95in", r#"rotate="90""#),
line_draw("5in", "0.95in", r#"rotate="180""#),
line_draw("7in", "0.95in", r#"rotate="270""#),
]
.concat();
let (doc, page) = written(&form_of_draws("", &draws));
let lines = stroked_lines(&page_content(&doc, &page));
assert_eq!(lines.len(), 4, "{lines:?}");
let top = 792.0 - 18.0 - 0.95 * 72.0;
let acrobat = [((90.0, top), (50.4, -28.8)), ((234.0, top), (28.8, 50.4)), ((378.0, top), (-50.4, 28.8)), ((522.0, top), (-28.8, -50.4))];
for ([from, to], (anchor, by)) in lines.iter().zip(acrobat) {
assert!((from.0 - anchor.0).abs() < 0.01 && (from.1 - anchor.1).abs() < 0.01, "{from:?} for {anchor:?}");
let moved = (to.0 - from.0, to.1 - from.1);
assert!((moved.0 - by.0).abs() < 0.01 && (moved.1 - by.1).abs() < 0.01, "{moved:?} for {by:?}");
}
}
/// The anchor is the point it turns about, whichever corner that is: a box anchored at its bottom
/// right stands up and to the left of it, not where a top-left anchor would turn it.
#[test]
fn a_draw_turns_about_the_anchor_it_names() {
let draw = r#"<draw x="3in" y="1in" w="0.7in" h="0.4in" anchorType="bottomRight" rotate="90"><value><line><edge thickness="1pt"/></line></value></draw>"#;
let (doc, page) = written(&form_of_draws("", draw));
let lines = stroked_lines(&page_content(&doc, &page));
let anchor = (18.0 + 216.0, 792.0 - 18.0 - 72.0);
// Its bottom right is 0.7in right and 0.4in down of its top left, here turned to 0.7in up and
// 0.4in right: so the top left is that far back from the anchor, and the line ends on it.
let [from, to] = lines[0];
assert!((to.0 - anchor.0).abs() < 0.01 && (to.1 - anchor.1).abs() < 0.01, "{to:?} for {anchor:?}");
assert!((from.0 - (anchor.0 - 28.8)).abs() < 0.01 && (from.1 - (anchor.1 - 50.4)).abs() < 0.01, "{from:?}");
}
/// Each of the nine anchors stays where it is, and the box goes round it at each turn. What is
/// asked for comes from the box's corners here, not from layout: from the anchor, y up, the line
/// runs from the top left to the bottom right, and a quarter turn takes (x, y) to (-y, x).
#[test]
fn a_draw_turns_about_each_of_the_nine_anchors() {
let (w, h) = (50.4, 28.8);
let anchor = (18.0 + 216.0, 792.0 - 18.0 - 72.0);
for (name, down) in [("top", 0.0), ("middle", 0.5), ("bottom", 1.0)] {
for (side, across) in [("Left", 0.0), ("Center", 0.5), ("Right", 1.0)] {
for quarters in 0..4 {
let rotate = quarters * 90;
let draw = format!(
r#"<draw x="3in" y="1in" w="0.7in" h="0.4in" anchorType="{name}{side}" rotate="{rotate}"><value><line><edge thickness="1pt"/></line></value></draw>"#
);
let (doc, page) = written(&form_of_draws("", &draw));
let lines = stroked_lines(&page_content(&doc, &page));
assert_eq!(lines.len(), 1, "{name}{side} at {rotate}: {lines:?}");
let (left, top) = (-across * w, down * h);
for (got, corner) in lines[0].iter().zip([(left, top), (left + w, top - h)]) {
let turned = (0..quarters).fold(corner, |(x, y), _| (-y, x));
let want = (anchor.0 + turned.0, anchor.1 + turned.1);
assert!((got.0 - want.0).abs() < 0.01 && (got.1 - want.1).abs() < 0.01, "{name}{side} at {rotate}: {got:?} for {want:?}");
}
}
}
}
}
/// A draw that stands past the edge of the page once turned stays where it turned to: nothing moves
/// it back or cuts it, as for an upright one. From the page's top left corner of the content area
/// it goes off to the left and above the page, and from its right side, off to the right.
#[test]
fn a_turned_draw_stands_past_the_edge_of_the_page() {
for (x, rotate, anchor, by) in [("0in", "180", 18.0, (-50.4, 28.8)), ("8in", "90", 594.0, (28.8, 50.4))] {
let draw = line_draw(x, "0in", &format!(r#"rotate="{rotate}""#));
let (doc, page) = written(&form_of_draws("", &draw));
let content = page_content(&doc, &page);
assert!(!content.split_whitespace().any(|t| t == "W" || t == "W*"), "{rotate}: nothing clips it: {content}");
let lines = stroked_lines(&content);
assert_eq!(lines.len(), 1, "{rotate}: {lines:?}");
let [from, to] = lines[0];
assert!((from.0 - anchor).abs() < 0.01 && (from.1 - 774.0).abs() < 0.01, "{rotate}: {from:?}");
assert!((to.0 - (anchor + by.0)).abs() < 0.01 && (to.1 - (774.0 + by.1)).abs() < 0.01, "{rotate}: {to:?}");
assert!(to.0 < 0.0 || to.0 > 612.0, "{rotate}: it does stand past the edge, at {to:?}");
}
}
/// Everything a draw shows turns with it, under one matrix: its text, its picture and its border,
/// rounded or not, each written as if it stood upright.
#[test]
fn text_pictures_and_borders_turn_with_their_draw() {
let picture = format!(
r#"<draw x="1in" y="1in" w="1in" h="1in" rotate="90"><value><image contentType="image/png">{}</image></value></draw>"#,
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, tiny_png())
);
for (draw, shows) in [
(r#"<draw x="1in" y="1in" w="2in" h="0.5in" rotate="90"><value><text>upright</text></value></draw>"#.to_string(), "(upright) Tj"),
(picture, " Do "),
(r#"<draw x="1in" y="1in" w="2in" h="0.5in" rotate="90"><value><rectangle><edge thickness="1pt"/></rectangle></value></draw>"#.to_string(), " l S"),
(
r#"<draw x="1in" y="1in" w="2in" h="0.5in" rotate="90"><value><rectangle><edge thickness="1pt"/><corner radius="5pt"/></rectangle></value></draw>"#.to_string(),
" c",
),
(r#"<draw x="1in" y="1in" w="2in" h="0.5in" rotate="90"><border><fill><color value="255,0,0"/></fill></border><value><text>x</text></value></draw>"#.to_string(), " re f"),
] {
let (doc, page) = written(&form_of_draws("", &draw));
let content = page_content(&doc, &page);
let open = content.find("0 1 -1 0 ").unwrap_or_else(|| panic!("no matrix for {draw}: {content}"));
let close = open + content[open..].find("\nQ\n").unwrap_or_else(|| panic!("no end for {draw}: {content}"));
assert!(content[open..close].contains(shows), "{shows} outside the matrix: {content}");
}
}
/// `rotate` as a multiple of 90 degrees, with a unit or not, past a full turn or below none;
/// anything else leaves the draw as it stands.
#[test]
fn rotate_reads_as_quarter_turns() {
let quarters = |rotate: &str| {
let form = layout_xml(&form_of_draws("", &line_draw("1in", "1in", &format!(r#"rotate="{rotate}""#)))).unwrap();
turned(&form.pages[0]).first().map_or(0, |t| t.quarters)
};
for (rotate, want) in [("90", 1), ("180", 2), ("270", 3), ("0", 0), ("360", 0), ("450", 1), ("-90", 3), ("90deg", 1), (" 90 ", 1), ("90.0", 1)] {
assert_eq!(quarters(rotate), want, "{rotate}");
}
for rotate in ["45", "89.5", "135", "225", "abc", "", "deg", "NaN", "inf", "-inf"] {
assert_eq!(quarters(rotate), 0, "{rotate}");
}
// Far past what a turn can be told apart at, or just under none: in range whatever it reads as.
for rotate in ["1e300", "-1e-300"] {
assert!(quarters(rotate) < 4, "{rotate}");
}
}
/// A turned draw is one item: a page break moves it whole, what it holds moving with the point it
/// turns about, and doesn't cut it, and the draws around it stay where they belong.
#[test]
fn a_page_break_moves_a_turned_draw_whole() {
let draws = format!(
r#"<draw x="6in" y="10.4in" w="1in" h="0.5in"><border><edge thickness="1pt"/></border><value><text>across</text></value></draw><draw x="1in" y="1in" w="2in" h="0.5in"><value><text>first</text></value></draw>{}{}<draw x="1in" y="13in" w="2in" h="0.5in"><value><text>last</text></value></draw>"#,
[
line_draw("3in", "12in", r#"rotate="90""#),
r#"<draw x="5in" y="12in" w="2in" h="0.5in" rotate="90"><border><edge thickness="1pt"/></border><value><text>sideways</text></value></draw>"#.to_string(),
]
.concat(),
(2..8).map(|y| line_draw("1in", &format!("{y}in"), "")).collect::<String>()
);
let xdp = form_of_draws("", &draws).replace(r#"h="2.5in""#, r#"h="14in""#);
let form = layout_xml(&xdp).unwrap();
assert!(form.pages.len() >= 2, "{}", form.pages.len());
assert_eq!(texts(&form.pages[0]), ["first"]);
// Written after the turned draws, these lines still belong at the top of the block: what the
// break knew of the draw's many items mustn't carry over to those that now stand in their places.
let lines = form.pages[0].items.iter().filter(|i| matches!(i, Item::Line { .. })).count();
assert_eq!(lines, 6, "{:?}", form.pages[0].items);
assert_eq!(turned(&form.pages[0]).len(), 0);
let moved = turned(&form.pages[1]);
assert_eq!(moved.len(), 2, "{:?}", form.pages[1].items);
let Item::Line { from, .. } = &moved[0].items[0] else { panic!("not a line") };
assert_eq!(*from, moved[0].about, "the line moved with the point it turns about");
// Turned 90 degrees, a box 0.7in wide stands 0.7in up from its anchor, on a second page.
assert!(moved[0].about.1 < 792.0, "{:?}", moved[0].about);
assert!((moved[0].extent.1 - moved[0].about.1).abs() < 0.01, "{:?}", moved[0]);
assert!((moved[0].about.1 - moved[0].extent.0 - 50.4).abs() < 0.01, "{:?}", moved[0]);
let moved_text = texts(&form.pages[1]);
assert!(moved_text.contains(&"last".to_string()) && moved_text.contains(&"across".to_string()), "{moved_text:?}");
}
/// What a page break goes by is the box as it stands once turned, whichever way, about whichever
/// corner: from a top-left anchor, it stands up, up or down of it; from a bottom-right one, the
/// box starts behind the anchor and the same turns take it the other way.
#[test]
fn a_turned_draw_knows_where_it_stands() {
let box_at = |anchor: &str, rotate: u32| {
let draw = format!(
r#"<draw x="3in" y="1in" w="0.7in" h="0.4in" {anchor} rotate="{rotate}"><value><line><edge thickness="1pt"/></line></value></draw>"#
);
let form = layout_xml(&form_of_draws("", &draw)).unwrap();
let held = turned(&form.pages[0]);
let t = held.first().expect("turned");
(t.extent.0 - t.about.1, t.extent.1 - t.about.1)
};
let close = |got: (f64, f64), want: (f64, f64)| (got.0 - want.0).abs() < 0.01 && (got.1 - want.1).abs() < 0.01;
for (rotate, want) in [(90, (-50.4, 0.0)), (180, (-28.8, 0.0)), (270, (0.0, 50.4))] {
assert!(close(box_at("", rotate), want), "{rotate}: {:?}", box_at("", rotate));
}
for (rotate, want) in [(90, (0.0, 50.4)), (180, (0.0, 28.8)), (270, (-50.4, 0.0))] {
let got = box_at(r#"anchorType="bottomRight""#, rotate);
assert!(close(got, want), "{rotate}: {got:?}");
}
}
/// The page count is filled in inside a turned draw too, as it comes last, when every page is known.
#[test]
fn the_page_count_reaches_a_turned_draw() {
let master = r##"<draw x="7in" y="10in" w="1in" h="0.2in" rotate="90"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>of <span xfa:embed="#pageCount" xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"/></p></body></exData></value></draw><field name="pageCount" id="pageCount" presence="hidden" w="1in" h="0.2in"><ui><textEdit/></ui><event activity="ready" ref="$layout"><script contentType="application/x-javascript">this.rawValue = xfa.layout.pageCount();</script></event></field>"##;
let form = layout_xml(&form_of_draws(master, &line_draw("1in", "1in", ""))).unwrap();
let held = turned(&form.pages[0]);
assert_eq!(held.len(), 1, "{:?}", form.pages[0].items);
let text: Vec<&str> = held[0].items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s.text.as_str()) } else { None }).collect();
assert_eq!(text.concat().trim(), "of 1", "{text:?}");
}
/// A link in turned text is clicked where the text is drawn, whichever way it turned: the box it
/// has unturned, turned about the draw's anchor.
#[test]
fn a_link_turns_with_its_text() {
let rect_at = |rotate: &str| {
let draw = format!(
r#"<draw x="1in" y="3in" w="2in" h="0.5in" {rotate}><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p><a href="https://example.com/a">a link in turned text</a></p></body></exData></value></draw>"#
);
let (doc, page) = written(&form_of_draws("", &draw));
let annots = page.get(b"Annots").map(|a| doc.resolve(a)).expect("the link");
let annot = doc.get(annots.as_array().unwrap()[0].as_ref().unwrap());
let rect: Vec<f64> = annot.as_dict().unwrap().get(b"Rect").unwrap().as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
rect
};
let upright = rect_at("");
let (ax, ay) = (18.0 + 72.0, 792.0 - 18.0 - 216.0);
let (left, bottom, right, top) = (upright[0] - ax, upright[1] - ay, upright[2] - ax, upright[3] - ay);
assert!(left >= -0.01 && top <= 0.01 && right - left > 3.0 * (top - bottom), "it stands right and down of its anchor: {upright:?}");
for (rotate, want) in [
("90", [ax - top, ay + left, ax - bottom, ay + right]),
("180", [ax - right, ay - top, ax - left, ay - bottom]),
("270", [ax + bottom, ay - right, ax + top, ay - left]),
] {
let got = rect_at(&format!(r#"rotate="{rotate}""#));
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 0.01), "{rotate}: {got:?} for {want:?}");
}
}
/// Only a draw placed by position turns: a field, and a draw in a flow, stand as they did.
#[test]
fn only_positioned_draws_turn() {
let field = r#"<field name="f" x="1in" y="1in" w="2in" h="0.5in" rotate="90"><ui><textEdit/></ui></field>"#;
let form = layout_xml(&form_of_draws("", field)).unwrap();
assert!(turned(&form.pages[0]).is_empty());
assert_eq!(widgets(&form.pages[0]).len(), 1);
let flowed = form_of_draws("", &line_draw("1in", "1in", r#"rotate="90""#))
.replace(r#"<subform name="s" w="8in" h="2.5in">"#, r#"<subform name="s" w="8in" layout="tb">"#);
let form = layout_xml(&flowed).unwrap();
assert!(turned(&form.pages[0]).is_empty(), "{:?}", form.pages[0].items);
assert!(form.pages[0].items.iter().any(|i| matches!(i, Item::Line { .. })), "it is still drawn");
// A draw that shows nothing has nothing to turn.
let form = layout_xml(&form_of_draws("", &line_draw("1in", "1in", r#"rotate="90" presence="invisible""#))).unwrap();
assert!(form.pages[0].items.is_empty(), "{:?}", form.pages[0].items);
}
/// A block carried over to a content area further across takes a turned draw with it, point and
/// items both.
#[test]
fn a_turned_draw_moves_across_with_its_content_area() {
let xdp = form_of_draws("", &line_draw("1in", "6in", r#"rotate="90""#))
.replace(
r#"<contentArea x="0.25in" y="0.25in" w="8in" h="10.5in"/>"#,
r#"<contentArea x="0.25in" y="0.25in" w="3in" h="5in"/><contentArea x="4in" y="0.25in" w="3in" h="5in"/>"#,
)
.replace(r#"h="2.5in""#, r#"h="7in""#);
let form = layout_xml(&xdp).unwrap();
let moved = form.pages.iter().flat_map(turned).next().expect("turned");
let Item::Line { from, .. } = &moved.items[0] else { panic!("not a line") };
assert_eq!(*from, moved.about);
assert!((moved.about.0 - (4.0 + 1.0) * 72.0).abs() < 0.01, "{:?}", moved.about);
}
/// A positioned subform with no height grows to hold what it holds as it stands, so what comes next
/// neither runs through a turned draw nor leaves the room its upright box would have had.
#[test]
fn a_container_grows_to_a_turned_draw_as_it_stands() {
let next = r#"</subform><subform name="next" w="8in" h="1in"><draw x="0in" y="0in" w="2in" h="0.5in"><value><text>next</text></value></draw></subform></subform></template>"#;
let baseline = |rotate: &str| {
let xdp = form_of_draws("", &line_draw("1in", "1in", rotate)).replace(r#" h="2.5in""#, "").replace("</subform></subform></template>", next);
let form = layout_xml(&xdp).unwrap();
assert_eq!(form.pages.len(), 1);
form.pages[0].items.iter().find_map(|i| if let Item::Text(s) = i { Some(s.baseline) } else { None }).expect("the next block")
};
// Upright the box ends 0.4in below the anchor, and the next block starts there.
let upright = baseline("");
for (rotate, bottom) in [("90", 0.0), ("180", 0.0), ("270", 50.4)] {
let got = baseline(&format!(r#"rotate="{rotate}""#)) - (upright - 28.8);
assert!((got - bottom).abs() < 0.01, "{rotate}: the next block starts {got} below the anchor, not {bottom}");
}
}
/// A subform with no width is as wide as what it holds as it stands, whichever way that turned,
/// so a neighbour in a row doesn't stand off from it or over it.
#[test]
fn a_container_is_as_wide_as_a_turned_draw_stands() {
let row = |rotate: &str| {
let xdp = form_of_draws(
"",
&format!(
r#"<subform name="turned">{}</subform><subform name="beside" w="1in" h="1in"><draw x="0in" y="0in" w="1in" h="0.5in"><value><text>beside</text></value></draw></subform>"#,
line_draw("1in", "1in", rotate)
),
)
.replace(r#"<subform name="s" w="8in" h="2.5in">"#, r#"<subform name="s" w="8in" h="2.5in" layout="lr-tb">"#);
let form = layout_xml(&xdp).unwrap();
form.pages[0].items.iter().find_map(|i| if let Item::Text(s) = i { Some(s.x) } else { None }).expect("the neighbour")
};
let upright = row("");
// Upright it ends 0.7in right of the anchor; turned, 0.4in right, at the anchor, and at it again.
for (rotate, right) in [("90", 28.8), ("180", 0.0), ("270", 0.0)] {
let got = row(&format!(r#"rotate="{rotate}""#)) - (upright - 50.4);
assert!((got - right).abs() < 0.01, "{rotate}: the neighbour stands {got} right of the anchor, not {right}");
}
// From a bottom-right anchor the box starts behind it, and the same turns take it the other way.
for (rotate, right) in [("90", 0.0), ("180", 50.4), ("270", 28.8)] {
let got = row(&format!(r#"anchorType="bottomRight" rotate="{rotate}""#)) - (upright - 50.4);
assert!((got - right).abs() < 0.01, "{rotate}: the neighbour stands {got} right of the anchor, not {right}");
}
}
File diff suppressed because one or more lines are too long