mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-03 06:51:50 -06:00
M12: an XFA draw placed by position turns about its anchor
Nothing read rotate, so a turned line, picture or label was drawn as if it stood upright: part of #5561. XFA gives the angle in degrees counter-clockwise about the anchor, and Acrobat's flattened page for that issue's form puts the three turned lines exactly there, which the test holds it to. A turned draw is one item: a page break moves it whole, and what it holds is written unturned under one matrix, so text, pictures and borders need nothing of their own. The page count and link annotations look inside it, and its container grows to the box it stands in. Fields, subforms and draws in a flow don't turn yet. That issue's own form is static, so it already shows Acrobat's page: this is for dynamic forms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
13655d0992
commit
2afbf55cd2
@@ -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`
|
||||
|
||||
@@ -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! {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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`]).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -4145,3 +4145,342 @@ 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:?}");
|
||||
}
|
||||
|
||||
/// 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
Reference in New Issue
Block a user