Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions crates/xfa/src/layout/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,17 @@ pub(super) fn divide(item: Item, cut: f64) -> (Option<Item>, Option<Item>) {
(Some(Item::Fill { rect: above, color }), Some(Item::Fill { rect: below, color }))
}
}
Item::Line { from, to, width, color, dashed } => {
Item::Line { from, to, width, color, stroke } => {
let (lo, hi) = if from.1 <= to.1 { (from, to) } else { (to, from) };
if lo.1 >= cut {
(None, Some(Item::Line { from, to, width, color, dashed }))
(None, Some(Item::Line { from, to, width, color, stroke }))
} else if hi.1 <= cut {
(Some(Item::Line { from, to, width, color, dashed }), None)
(Some(Item::Line { from, to, width, color, stroke }), None)
} else {
// lo.1 < cut < hi.1, so the divisor is positive.
let t = (cut - lo.1) / (hi.1 - lo.1);
let mid = (lo.0 + (hi.0 - lo.0) * t, cut);
(Some(Item::Line { from: lo, to: mid, width, color, dashed }), Some(Item::Line { from: mid, to: hi, width, color, dashed }))
(Some(Item::Line { from: lo, to: mid, width, color, stroke }), Some(Item::Line { from: mid, to: hi, width, color, stroke }))
}
}
Item::Rounded(r) => {
Expand Down
12 changes: 3 additions & 9 deletions crates/xfa/src/layout/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ impl Layouter<'_> {
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))
(e.thickness.clamp(0.1, 20.0), color, e.stroke)
});
let shape = Rounded {
rect,
Expand Down Expand Up @@ -191,13 +191,7 @@ impl Layouter<'_> {
_ => (t / 2.0, 0.0),
};
let color = if e.stroke == Stroke::Relief { [0.55, 0.55, 0.55] } else { e.color.0 };
self.push(Item::Line {
from: (seg.0.0 + dx, seg.0.1 + dy),
to: (seg.1.0 + dx, seg.1.1 + dy),
width: t,
color,
dashed: matches!(e.stroke, Stroke::Dashed | Stroke::Dotted),
})?;
self.push(Item::Line { from: (seg.0.0 + dx, seg.0.1 + dy), to: (seg.1.0 + dx, seg.1.1 + dy), width: t, color, stroke: e.stroke })?;
}
Ok(())
}
Expand Down Expand Up @@ -243,7 +237,7 @@ impl Layouter<'_> {
} else {
((inner.x, inner.y), (inner.right(), inner.bottom()))
};
self.push(Item::Line { from, to, width: edge.thickness.clamp(0.1, 20.0), color: edge.color.0, dashed: edge.stroke == Stroke::Dashed })
self.push(Item::Line { from, to, width: edge.thickness.clamp(0.1, 20.0), color: edge.color.0, stroke: edge.stroke })
}
Value::Rectangle(b) => self.draw_border(b, inner),
Value::Empty => Ok(()),
Expand Down
9 changes: 5 additions & 4 deletions crates/xfa/src/layout/items.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use super::{Arc, Face, HAlign, Rect, Span};
use super::{Arc, Face, HAlign, Rect, Span, Stroke};

/// How far below its baseline an underline is drawn, in the text's size: an underline is the
/// level line pushed just before its span, this far below it (the PDF writer tells it apart from
Expand All @@ -16,7 +16,8 @@ pub enum Item {
to: (f64, f64),
width: f64,
color: [f64; 3],
dashed: bool,
/// Its stroke, which sets its dash pattern ([`Stroke::Relief`] draws solid).
stroke: Stroke,
},
/// A box with rounded (or inverted) corners: its fill, and its edge drawn inside it.
Rounded(Box<Rounded>),
Expand All @@ -39,8 +40,8 @@ pub struct Rounded {
/// 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)>,
/// The edge: its width, colour and stroke (which sets its dash pattern).
pub edge: Option<(f64, [f64; 3], Stroke)>,
/// 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,
Expand Down
10 changes: 8 additions & 2 deletions crates/xfa/src/layout/pages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use super::EMBED_COPIED;
use super::items::{Item, Page, UNDERLINE_DROP};
use super::{
Block, Border, Ctx, Cursor, FlowMark, Font, Frame, Layouter, MAX_ITEMS, MAX_MEASURES, MAX_PAGES, PAGE_COUNT_MARK, PageRole, Para, Rc, Rect,
SomIndices, XfaError, text,
SomIndices, Stroke, XfaError, text,
};

impl Layouter<'_> {
Expand Down Expand Up @@ -56,7 +56,13 @@ impl Layouter<'_> {
if s.face.underline {
let w = s.face.width(&s.text);
let y = s.baseline + s.face.size * UNDERLINE_DROP;
self.push(Item::Line { from: (s.x, y), to: (s.x + w, y), width: (s.face.size * 0.06).max(0.3), color: s.face.color, dashed: false })?;
self.push(Item::Line {
from: (s.x, y),
to: (s.x + w, y),
width: (s.face.size * 0.06).max(0.3),
color: s.face.color,
stroke: Stroke::Solid,
})?;
}
if !s.text.trim().is_empty() {
self.push(Item::Text(s))?;
Expand Down
4 changes: 4 additions & 0 deletions crates/xfa/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ pub enum Stroke {
Solid,
Dashed,
Dotted,
/// A dash, then a dot.
DashDot,
/// A dash, then two dots.
DashDotDot,
/// Raised, lowered, etched and embossed: drawn as a solid grey line.
Relief,
}
Expand Down
4 changes: 3 additions & 1 deletion crates/xfa/src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,10 @@ fn parse_edge(n: XmlNode) -> Edge {
thickness: attr_measure(n, "thickness").unwrap_or(0.5).max(0.0),
color: parse_color(child(n, "color")).unwrap_or(Color::BLACK),
stroke: match n.attribute("stroke") {
Some("dashed" | "dashDot" | "dashDotDot") => Stroke::Dashed,
Some("dashed") => Stroke::Dashed,
Some("dotted") => Stroke::Dotted,
Some("dashDot") => Stroke::DashDot,
Some("dashDotDot") => Stroke::DashDotDot,
Some("raised" | "lowered" | "etched" | "embossed") => Stroke::Relief,
_ => Stroke::Solid,
},
Expand Down
18 changes: 2 additions & 16 deletions crates/xfa/src/pdf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ mod common;
mod strip;

pub use common::jpeg_size;
pub(crate) use common::rounded;
use common::{all_faces, content_hash, empty_form, hex32, jpeg_info, js_action, n, parse_hex32, rgb, rgb_array, standard_font, state_name};
pub(crate) use common::{line, rounded};
pub(crate) use strip::USAGE_RIGHTS_REMOVED;
use strip::drop_usage_rights;
pub use strip::{Stripped, XfaShape, existing_layout, strip, xfa_shape};
Expand Down Expand Up @@ -586,21 +586,7 @@ impl Emitter<'_> {
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!(
"q {} RG {} w {}{} {} m {} {} l S Q\n",
rgb(*color),
n(*width),
if *dashed { "[2 2] 0 d " } else { "" },
n(from.0),
n(h - from.1),
n(to.0),
n(h - to.1)
)
.bytes(),
);
}
Item::Line { from, to, width, color, stroke } => content.extend(line(*from, *to, *width, *color, *stroke, h).bytes()),
Item::Text(s) => {
// The form's own text, set in the standard fonts: what they can't draw is
// shown as `?` and reported once (the layout goes on), never silently.
Expand Down
52 changes: 50 additions & 2 deletions crates/xfa/src/pdf/common.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::{Dict, Document, Family, Object, PdfString, Rect, Rounded, Stream};
use crate::model::Stroke;

/// Width and height of a JPEG from its first frame header (SOF0–SOF15, not the DNL/JPG/DAC/DHT
/// markers). `None` for anything else.
Expand Down Expand Up @@ -114,7 +115,7 @@ pub(crate) fn rounded(r: &Rounded, page_h: f64) -> String {
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 {
if let Some((w, color, stroke)) = 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));
Expand All @@ -125,11 +126,58 @@ pub(crate) fn rounded(r: &Rounded, page_h: f64) -> String {
(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.push_str(&format!("q {} RG {} w {}{path}S Q\n", rgb(color), n(w), dash(stroke, w)));
}
c
}

/// The content that draws a straight line from `from` to `to` (top-left based, on a page
/// `page_h` tall), `width` thick, in `color`, with `stroke`'s dash pattern.
///
/// The ink stays between `from` and `to` whatever the caps: a dashed line's projecting caps
/// reach half its width past each end of the path, so the path is shortened by as much (a
/// border's edge would otherwise paint past the box's corners). A dashed line no longer than
/// its width can't be shortened, and is drawn with butt caps instead.
pub(crate) fn line(from: (f64, f64), to: (f64, f64), width: f64, color: [f64; 3], stroke: Stroke, page_h: f64) -> String {
let (dx, dy) = (to.0 - from.0, to.1 - from.1);
let len = dx.hypot(dy);
let (from, to, dash) = match dash_array(stroke, width) {
None => (from, to, String::new()),
Some(array) if len.is_finite() && width.is_finite() && len > width => {
let k = width / 2.0 / len;
((from.0 + dx * k, from.1 + dy * k), (to.0 - dx * k, to.1 - dy * k), format!("{array}2 J "))
}
Some(array) => (from, to, array),
};
format!("q {} RG {} w {dash}{} {} m {} {} l S Q\n", rgb(color), n(width), n(from.0), n(page_h - from.1), n(to.0), n(page_h - to.1))
}

/// The operators that set `stroke`'s dash pattern for an edge `width` thick, with projecting
/// caps and a trailing space, or nothing for a solid (or 3-D) stroke.
///
/// Dashes and gaps are multiples of the width, drawn with projecting caps: for a 3pt edge that is
/// `[12 6]` dashed, `[3 6]` dotted, `[9 6 3 6]` dash-dot and `[9 6 3 6 3 6]` dash-dot-dot, the
/// patterns Acrobat flattens those strokes to (#5561). A width under 0.01pt dashes as if it were
/// 0.01pt (so no dash rounds to zero) and a width that isn't finite as if it were 1pt.
pub(crate) fn dash(stroke: Stroke, width: f64) -> String {
dash_array(stroke, width).map(|a| format!("{a}2 J ")).unwrap_or_default()
}

/// The `d` operator of `stroke`'s dash pattern ([`dash`]), with a trailing space, without the
/// caps; `None` for a solid (or 3-D) stroke.
fn dash_array(stroke: Stroke, width: f64) -> Option<String> {
let units: &[f64] = match stroke {
Stroke::Solid | Stroke::Relief => return None,
Stroke::Dashed => &[4.0, 2.0],
Stroke::Dotted => &[1.0, 2.0],
Stroke::DashDot => &[3.0, 2.0, 1.0, 2.0],
Stroke::DashDotDot => &[3.0, 2.0, 1.0, 2.0, 1.0, 2.0],
};
let w = if width.is_finite() { width.max(0.01) } else { 1.0 };
let array: Vec<String> = units.iter().map(|u| n(u * w)).collect();
Some(format!("[{}] 0 d ", array.join(" ")))
}

/// 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):
Expand Down
111 changes: 109 additions & 2 deletions crates/xfa/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2411,7 +2411,7 @@ fn rounded_corners_make_a_border_one_rounded_shape() {
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))));
assert_eq!((round.fill, round.edge), (Some([230.0 / 255.0; 3]), Some((1.0, [1.0, 0.0, 0.0], Stroke::Solid))));
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");
Expand Down Expand Up @@ -2458,7 +2458,7 @@ fn rounded_boxes_are_written_as_curved_paths() {
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)),
edge: edge.then_some((2.0, [1.0, 0.0, 0.0], Stroke::Solid)),
open_top: open.0,
open_bottom: open.1,
};
Expand All @@ -2479,6 +2479,113 @@ fn rounded_boxes_are_written_as_curved_paths() {
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}");
}

/// Each edge stroke keeps its own dash pattern, in multiples of the edge's width and with
/// projecting caps, on a `<line>` and on a border alike: a 3pt edge draws dashed `[12 6]`,
/// dotted `[3 6]` (not solid), dash-dot `[9 6 3 6]` and dash-dot-dot `[9 6 3 6 3 6]` (not
/// dashed), and a solid or 3-D edge draws solid (#5561).
#[test]
fn edge_strokes_keep_their_own_dash_patterns() {
let strokes = [
("solid", Stroke::Solid, ""),
("dashed", Stroke::Dashed, "[12 6] 0 d 2 J "),
("dotted", Stroke::Dotted, "[3 6] 0 d 2 J "),
("dashDot", Stroke::DashDot, "[9 6 3 6] 0 d 2 J "),
("dashDotDot", Stroke::DashDotDot, "[9 6 3 6 3 6] 0 d 2 J "),
("lowered", Stroke::Relief, ""),
("raised", Stroke::Relief, ""),
("embossed", Stroke::Relief, ""),
("etched", Stroke::Relief, ""),
];
let body: String = strokes
.iter()
.enumerate()
.map(|(i, (name, ..))| {
format!(
r#"<draw name="line{i}" x="0in" y="{i}in" w="3in" h="0.1in"><value><line><edge thickness="3pt" stroke="{name}"/></line></value></draw><draw name="box{i}" x="4in" y="{i}in" w="2in" h="0.5in"><value><text>box</text></value><border><edge thickness="3pt" stroke="{name}"/></border></draw>"#
)
})
.collect();
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();
for (i, (name, stroke, dash)) in strokes.iter().enumerate() {
// The draws in row `i` start an inch apart, 0.5in below the page's top.
let top = 36.0 + i as f64 * 72.0;
let lines: Vec<String> = form.pages[0]
.items
.iter()
.filter_map(|it| match it {
Item::Line { from, to, width, color, stroke: s } if from.1 >= top - 2.0 && from.1 <= top + 40.0 => {
assert_eq!(s, stroke, "{name}: the item keeps the edge's stroke");
Some(crate::pdf::line(*from, *to, *width, *color, *s, 792.0))
}
_ => None,
})
.collect();
assert_eq!(lines.len(), 5, "{name}: the line and the box's four edges: {lines:?}");
for ops in &lines {
assert!(ops.starts_with("q ") && ops.contains(&format!(" 3 w {dash}")), "{name}: {ops}");
assert_eq!(ops.contains(" d "), !dash.is_empty(), "{name}: {ops}");
}
}
}

/// A square border a page break cuts keeps its edges' stroke on both pages: each side edge is
/// divided at the cut and both halves stay dashed, as do the top and bottom edges (#5561).
#[test]
fn a_dashed_border_cut_by_a_page_break_stays_dashed_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 thickness="2pt" stroke="dashed"/></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 mut side_length = 0.0;
for (n, page) in form.pages.iter().enumerate() {
let lines: Vec<_> = page
.items
.iter()
.filter_map(|it| if let Item::Line { from, to, width, color, stroke } = it { Some((*from, *to, *width, *color, *stroke)) } else { None })
.collect();
assert!(!lines.is_empty(), "page {n} has the box's edges");
for (from, to, width, color, stroke) in &lines {
assert_eq!(*stroke, Stroke::Dashed, "page {n}: {from:?}-{to:?}");
assert!(crate::pdf::line(*from, *to, *width, *color, *stroke, 792.0).contains("[8 4] 0 d 2 J "), "page {n}");
}
// The left edge's part on this page.
side_length += lines.iter().filter(|(f, t, ..)| (f.0 - t.0).abs() < 0.01 && f.0 < 100.0).map(|(f, t, ..)| (t.1 - f.1).abs()).sum::<f64>();
}
assert!((side_length - 12.0 * 72.0).abs() < 4.0, "the two parts of the left edge make the whole side: {side_length}");
}

/// A dash pattern scales with the edge's width, a dashed line's ink stays between its ends (its
/// path shortened for the projecting caps), a rounded box's edge is dashed like a square one, and
/// a width that would round every dash to zero, or isn't a number, still gives a valid pattern.
#[test]
fn dash_patterns_scale_with_width_and_stay_valid() {
use crate::layout::Rounded;
use crate::pdf::line;
// The path is shortened by half the width at each end, so the projecting caps end at the
// line's own ends; a solid line keeps its ends and butt caps.
assert_eq!(line((0.0, 0.0), (10.0, 0.0), 0.5, [0.0; 3], Stroke::Dashed, 100.0), "q 0 0 0 RG 0.5 w [2 1] 0 d 2 J 0.25 100 m 9.75 100 l S Q\n");
assert_eq!(line((0.0, 0.0), (10.0, 0.0), 0.5, [0.0; 3], Stroke::Solid, 100.0), "q 0 0 0 RG 0.5 w 0 100 m 10 100 l S Q\n");
// A vertical (border) edge is shortened along its own direction.
assert!(line((5.0, 0.0), (5.0, 20.0), 2.0, [0.0; 3], Stroke::Dotted, 100.0).contains(" 5 99 m 5 81 l "));
// A dashed line no longer than its width keeps its ends, with butt caps.
let short = line((0.0, 0.0), (2.0, 0.0), 3.0, [0.0; 3], Stroke::Dashed, 100.0);
assert!(short.contains("[12 6] 0 d 0 100 m 2 100 l ") && !short.contains(" J "), "{short}");
assert!(line((0.0, 0.0), (10.0, 0.0), 0.0001, [0.0; 3], Stroke::Dotted, 100.0).contains("[0.01 0.02] 0 d "));
let nan = line((0.0, 0.0), (10.0, 0.0), f64::NAN, [0.0; 3], Stroke::DashDot, 100.0);
assert!(nan.contains("[3 2 1 2] 0 d 0 100 m 10 100 l "), "{nan}");
let rounded = Rounded {
rect: Rect::new(36.0, 36.0, 144.0, 72.0),
radii: [18.0; 4],
inverted: [false; 4],
fill: None,
edge: Some((2.0, [0.0; 3], Stroke::DashDotDot)),
open_top: false,
open_bottom: false,
};
assert!(crate::pdf::rounded(&rounded, 792.0).starts_with("q 0 0 0 RG 2 w [6 4 2 4 2 4] 0 d 2 J "));
}

/// A flowed subform without a fixed height gets its border and fill round all it takes, margins
/// included, drawn under its content; a flowed table too. One a page break cuts between its
/// children gets a closed border round its part on each page, its bottom margin ending the first
Expand Down
Loading