From 7f8ee6e9fff7a9d31b3f7f08567d000a84c40ec6 Mon Sep 17 00:00:00 2001 From: hsmith-dev Date: Sat, 31 Oct 2026 19:04:57 -0600 Subject: [PATCH 1/3] M12: XFA edges keep their own dash patterns (#5561) dashDot and dashDotDot were folded into dashed, every non-solid stroke was written as one [2 2] pattern, and a dotted drew solid. Edges and lines now carry their Stroke to the PDF writer, which writes each stroke's pattern in multiples of the edge width with projecting caps: [12 6], [3 6], [9 6 3 6] and [9 6 3 6 3 6] for a 3pt edge, the patterns Acrobat flattens them to. Co-Authored-By: Claude Opus 5.5 --- crates/xfa/src/layout/common.rs | 8 ++-- crates/xfa/src/layout/draw.rs | 12 ++---- crates/xfa/src/layout/items.rs | 9 ++-- crates/xfa/src/layout/pages.rs | 10 ++++- crates/xfa/src/model.rs | 4 ++ crates/xfa/src/parse.rs | 4 +- crates/xfa/src/pdf.rs | 18 +------- crates/xfa/src/pdf/common.rs | 40 +++++++++++++++++- crates/xfa/src/tests.rs | 73 ++++++++++++++++++++++++++++++++- parity/acrobat-features.toml | 2 +- 10 files changed, 139 insertions(+), 41 deletions(-) diff --git a/crates/xfa/src/layout/common.rs b/crates/xfa/src/layout/common.rs index 625f305a6bb..a7a5ee8645e 100644 --- a/crates/xfa/src/layout/common.rs +++ b/crates/xfa/src/layout/common.rs @@ -73,17 +73,17 @@ pub(super) fn divide(item: Item, cut: f64) -> (Option, Option) { (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) => { diff --git a/crates/xfa/src/layout/draw.rs b/crates/xfa/src/layout/draw.rs index f6520c16284..c76f9a50263 100644 --- a/crates/xfa/src/layout/draw.rs +++ b/crates/xfa/src/layout/draw.rs @@ -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, @@ -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(()) } @@ -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(()), diff --git a/crates/xfa/src/layout/items.rs b/crates/xfa/src/layout/items.rs index 8a683886847..9ff4a31dc3c 100644 --- a/crates/xfa/src/layout/items.rs +++ b/crates/xfa/src/layout/items.rs @@ -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 @@ -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), @@ -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, diff --git a/crates/xfa/src/layout/pages.rs b/crates/xfa/src/layout/pages.rs index dac17c11699..c77775b7e58 100644 --- a/crates/xfa/src/layout/pages.rs +++ b/crates/xfa/src/layout/pages.rs @@ -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<'_> { @@ -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))?; diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index 0fac8db18ec..4d9eafbc825 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -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, } diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index fc114aebacc..4b5b91599bc 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -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, }, diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 0c21507abf6..7928ae67596 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -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}; @@ -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. diff --git a/crates/xfa/src/pdf/common.rs b/crates/xfa/src/pdf/common.rs index 7cbb1ce7a1a..e19fde8d2d1 100644 --- a/crates/xfa/src/pdf/common.rs +++ b/crates/xfa/src/pdf/common.rs @@ -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. @@ -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)); @@ -125,11 +126,46 @@ 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. +pub(crate) fn line(from: (f64, f64), to: (f64, f64), width: f64, color: [f64; 3], stroke: Stroke, page_h: f64) -> String { + format!( + "q {} RG {} w {}{} {} m {} {} l S Q\n", + rgb(color), + n(width), + dash(stroke, 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 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 { + let units: &[f64] = match stroke { + Stroke::Solid | Stroke::Relief => return String::new(), + 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 = units.iter().map(|u| n(u * w)).collect(); + format!("[{}] 0 d 2 J ", 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): diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index e424be5441f..419107e1e2f 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -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"); @@ -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, }; @@ -2479,6 +2479,75 @@ 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 `` 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, ""), + ]; + let body: String = strokes + .iter() + .enumerate() + .map(|(i, (name, ..))| { + format!( + r#"box"# + ) + }) + .collect(); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &format!(r#"{body}"#))) + .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 = 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 dash pattern scales with the edge's width, 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; + 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 100 m 10 100 l S Q\n"); + 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 ")); + assert!(line((0.0, 0.0), (10.0, 0.0), f64::NAN, [0.0; 3], Stroke::DashDot, 100.0).contains("[3 2 1 2] 0 d ")); + 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 diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 18498399c94..82c3c4edfd1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5321,7 +5321,7 @@ status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/js/src/tests.rs::message_box_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_in_an_xfa_script_reads_app_alerts_arguments", "crates/engine/src/tests.rs::xfa_message_boxes_keep_their_title_icon_and_buttons", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout"] -notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60). xfa.host.messageBox keeps its title, icon and buttons, and app.alert in an XFA script reads its own argument order; every message box still answers OK (1) (#4291)." +notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, dashed, dotted, dash-dot and dash-dot-dot edges and lines in the patterns Acrobat flattens them to (measured on 3pt edges; that the patterns scale with an edge's width is assumed, not observed, #5561), rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a draw's rotate (#5561); a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60). xfa.host.messageBox keeps its title, icon and buttons, and app.alert in an XFA script reads its own argument order; every message box still answers OK (1) (#4291)." [[feature]] id = "form.xfa-formcalc" From 2dcb1f40e8b80a27f1c485dc4161d5b88bf9bfd3 Mon Sep 17 00:00:00 2001 From: hsmith-dev Date: Sat, 31 Oct 2026 20:31:12 -0600 Subject: [PATCH 2/3] M12: a dashed XFA edge's projecting caps stay inside its box (#5561) Projecting caps reach half the width past each end of a dashed line's path, so a square border's dashed edge painted past the box's corners. The path is now shortened by as much; a dashed line no longer than its width keeps its ends and is drawn with butt caps. Co-Authored-By: Claude Opus 5.5 --- crates/xfa/src/pdf/common.rs | 44 +++++++++++++++++++++++------------- crates/xfa/src/tests.rs | 19 ++++++++++++---- 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/crates/xfa/src/pdf/common.rs b/crates/xfa/src/pdf/common.rs index e19fde8d2d1..4023e1f805c 100644 --- a/crates/xfa/src/pdf/common.rs +++ b/crates/xfa/src/pdf/common.rs @@ -133,29 +133,41 @@ pub(crate) fn rounded(r: &Rounded, page_h: f64) -> String { /// 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 { - format!( - "q {} RG {} w {}{} {} m {} {} l S Q\n", - rgb(color), - n(width), - dash(stroke, 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 a trailing -/// space, or nothing for a solid (or 3-D) stroke. + 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 { let units: &[f64] = match stroke { - Stroke::Solid | Stroke::Relief => return String::new(), + 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], @@ -163,7 +175,7 @@ pub(crate) fn dash(stroke: Stroke, width: f64) -> String { }; let w = if width.is_finite() { width.max(0.01) } else { 1.0 }; let array: Vec = units.iter().map(|u| n(u * w)).collect(); - format!("[{}] 0 d 2 J ", array.join(" ")) + Some(format!("[{}] 0 d ", array.join(" "))) } /// The path around `rect` (top-left based, on a page `page_h` tall) with the corners `radii` diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 419107e1e2f..7b26812bc68 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2526,16 +2526,25 @@ fn edge_strokes_keep_their_own_dash_patterns() { } } -/// A dash pattern scales with the edge's width, 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. +/// 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; - 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 100 m 10 100 l S Q\n"); + // 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 ")); - assert!(line((0.0, 0.0), (10.0, 0.0), f64::NAN, [0.0; 3], Stroke::DashDot, 100.0).contains("[3 2 1 2] 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], From d4edc6609f0b896783db11c1a005801b2c82ab13 Mon Sep 17 00:00:00 2001 From: hsmith-dev Date: Sat, 31 Oct 2026 23:32:57 -0600 Subject: [PATCH 3/3] M12: test every relief stroke and a dashed border cut by a page break (#5561) Review follow-up. edge_strokes_keep_their_own_dash_patterns now covers raised, embossed and etched as well as lowered (all solid). A new test cuts a 12-inch box with a dashed border at a page break and checks that both halves of each side edge stay dashed, that the halves add up to the whole side, and that the box's edges are dashed on both pages. With the stroke dropped from the lower half in `divide`, the new test fails. Co-Authored-By: Claude Opus 5.5 --- crates/xfa/src/tests.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 7b26812bc68..d291e12ea4a 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2492,6 +2492,9 @@ fn edge_strokes_keep_their_own_dash_patterns() { ("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() @@ -2526,6 +2529,32 @@ fn edge_strokes_keep_their_own_dash_patterns() { } } +/// 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#"Note"#; + 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::(); + } + 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.