Forms: rotate field widgets with /MK /R (#516)

* M6: rotate form field widgets with /MK /R

Filling or restyling a rotated field redrew its text upright. Appearances now follow /MK /R, and Field Properties and form_set_props can set the rotation.

* Forms: a rotation refused as too small is checked before any other property is written; no indexing by widget number

* hu, uk: Rotation:

---------

Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
Márk BartalosandBrandon Thomas authored and GitHub committed 2026-10-09 13:13:12 -04:00
1 parent 2d24de1c16
commit c9d0619226
28 files changed
+402 -34

No files matched your search

+13 -13
View File
@@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "6bed3ae5e307b0a225c49bb6a4c688911869f4bbf5df527a3bf84d0997e6baaf"
sha256 = "997f3479a8930bacfc330ee2c7bf882720ac4a382630b87ae808c86702586926"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "42c3498eac138f95ac700f71332742658ed59bec171460aa675b1cb67e97ca36"
sha256 = "8e2051a5fdf7c52cc4fd5f79bf4be3405c213e247cf80bba714e008ee405ec13"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "fb315c0c3dfbafd35d73e7dedd1260daa1da919b0b30db53d16725cbd0f0ae12"
sha256 = "8bab483a3f5ba3a724365c2a5a892291a10f41fdc443eaceb66f49c8a20cae20"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "20043dd4e64d6a8405dbf3167e9f315f68d4894c3d76c0b2b68e9c7e2879c06f"
sha256 = "2cd5dcc6cf7e94914b955ee0f2f5c3e8951b16e26ab49fcb7111246a6e31ee2a"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "4c576bf5573a127ba2565980623e92b104699e7e03ac68dc3eddf4e788c7e8a4"
sha256 = "05ebb95a81e3516fed37329237a6b4f26fd2168fa36cb3e16d4ae218d00e373a"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "66ea8fd85159745626643bfce304dbe7ae0f9ae45a006a5d3834c3cad181080b"
sha256 = "78f794a4fdcf870b15b5692f1ff5567d4afd439bb2647c637a7ec4bdb9bc2ddb"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "ef0c8f5830ecb2950b6956eff50f963282bc607f4aedd98d89b9020a73db53b7"
sha256 = "04f4cbb7d72d42059135bb2e49fcfcaa5812eae773047769c6024e289a0d149d"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "2c399d16589f2d0c3659c87406cb45d27792e7286774ededaa6578a224e2fae9"
sha256 = "d3630f989e9725c22444dc27765142674b66f72436502ca969b2851fc38d703d"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "1782bfb95566a44e9cc3e22a595ff158965c55b67936d994a8dae9b3e51975a2"
sha256 = "36d25dacd015fabf5e351407edbed3920bfdfb429d6a1482744c80e504c65c0d"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "96fa31e4e77f54680ee2d66ad2a8d6662fd6dea592f8b07126e06f0bcbfc5248"
sha256 = "cec5873ded1b535b2f9413408db8cc61e5a99a4ac9a62d13caaaca5c22b086c9"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "eb0b3fa525198152d7cf514284460ff904bcb9af091142ddd1c09deb8461ed69"
sha256 = "1b164ae65c65e2902aebc1a12643cd51b2a67dd0ae2502726b8588fb3c6b54ff"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "2bfcb94d72df43a6146e45e971950433a13bb008fc18c68463c096089f715466"
sha256 = "d0cbe639f664b47ec97dd573ab2642b72a1b80c170f3a756f045647f5afa34cb"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "6939906a84a2b5b7f5d2ca36c454ed1787751232d435daa30cc62ededbe7c165"
sha256 = "0d4247fb4538391b8f65238c0a422e0bd751765c3e9e3c1e85428fb967f1296c"
[[asset]]
path = "assets/icons/accessibility.svg"
Generated
+1
View File
@@ -3641,6 +3641,7 @@ dependencies = [
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
"pdfcraft-render",
"thiserror 2.0.21",
]
+1
View File
@@ -127,6 +127,7 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
## Log
- **2026-10-09 (M6, field rotation):** Regenerated form appearances honour `/MK /R` (0, 90, 180, 270). Filling or restyling a rotated field keeps the text, check mark and button caption turned, and Field Properties ▸ Position and `form_set_props` `rotation` set it, swapping width and height around the field's center when the axis changes. `form.appearance-regeneration` stays partial (Helvetica metrics are still approximate). Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M5.7, #434 partial):** Natural-size image stamps placed at a click or through `stamp_custom` remain upright on rotated pages, with a displayed-space-sized rectangle and a counterrotated standard appearance matrix. Move, resize, restyle and undo/redo preserve that orientation. Native and external Poppler save/reopen rendering cover all four quarter-turns; UI tests verify the displayed bounds. Explicit rectangle placement, image signatures, PDF-page stamps and image-file orientation metadata remain outside this repair. #434 stays open; milestone table and effort estimates remain unchanged.
- **2026-10-09 (M1, raw-stream decoding limits):** COS checks unfiltered decoded data against the caller and per-stream limits before copying it, including empty filter arrays and streams whose Crypt filter was applied on load. Strict decoding uses the same raw-data guard. Tiny synthetic regressions cover exceeded, exact and zero-byte limits while retaining the filtered decoder behavior. Milestone and overall effort estimates unchanged (about 30–35%).
+6
View File
@@ -194,6 +194,7 @@ impl Automation {
},
"page": w.and_then(|w| w.page).map(|p| p + 1),
"rect": rect,
"rotation": w.map(|w| w.rotation).unwrap_or(0),
"read_only": f.read_only(),
"required": f.has(field_flags::REQUIRED),
});
@@ -394,6 +395,11 @@ impl Automation {
out
}
},
rotation: match a.opt_int("rotation")? {
None => None,
Some(r @ (0 | 90 | 180 | 270)) => Some((0, r)),
Some(r) => return Err(ToolError::InvalidArgs(format!("rotation must be 0, 90, 180 or 270, not {r}"))),
},
actions: None,
check_style: match a.opt_str("check_style")? {
None => None,
+3 -2
View File
@@ -245,7 +245,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "from", "to"],
)),
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page and rect (top-left-origin points), read-only and required flags.")
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags.")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t(
@@ -298,7 +298,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"form_set_props",
"Field properties",
"Change a field's properties (General and Options tabs): name (renames it), tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), rect (position), format, validate, calculate (Acrobat's Format/Validate/Calculate tabs, run natively: values are checked, formatted and recalculated as in Acrobat). Options tab: align (left, center, right), default (the value Reset form restores; for check boxes and radio buttons their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes and radio buttons: check, circle, cross, diamond, square, star). Only the given ones change. Undoable.",
"Change a field's properties (General and Options tabs): name (renames it), tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), rect (position), rotation (0, 90, 180 or 270 degrees counterclockwise; changing between horizontal and vertical swaps width and height around the center), format, validate, calculate (Acrobat's Format/Validate/Calculate tabs, run natively: values are checked, formatted and recalculated as in Acrobat). Options tab: align (left, center, right), default (the value Reset form restores; for check boxes and radio buttons their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes and radio buttons: check, circle, cross, diamond, square, star). Only the given ones change. Undoable.",
)
.with(schema(
json!({
@@ -318,6 +318,7 @@ pub fn tools() -> Vec<ToolDef> {
"flags": { "type": "object", "additionalProperties": { "type": "boolean" } },
"font_size": { "type": "number", "minimum": 0 },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Move/resize: points from the top-left of the displayed page." },
"rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. Changing between horizontal and vertical swaps the rectangle around its center." },
"appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." },
"format": { "description": "Format tab: {\"type\": \"number\", \"decimals\": 2, \"currency\": \"$\", \"separator\": 0-4, \"negative\": 0-3}, {\"type\": \"percent\"}, {\"type\": \"date\"|\"time\", \"pattern\": \"mm/dd/yyyy\"}, {\"type\": \"zip\"|\"zip4\"|\"phone\"|\"ssn\"}, {\"type\": \"mask\", \"mask\": \"AA-9999\"}, or \"none\"." },
"validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out) or \"none\"." },
+28
View File
@@ -1698,6 +1698,34 @@ fn preparing_a_form_through_tools() {
assert!(matches!(a.call("form_set_image", &json!({ "doc": doc, "field": "size", "path": "pics/a_page_1.png" })), Err(ToolError::Failed(_))));
}
#[test]
fn rotating_a_field_through_tools() {
let dir = workdir("rotate-field");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, 20, 180, 42], "name": "City" }));
let rect_of = |a: &mut Automation| {
let f = &ok(a, "form_fields", json!({ "doc": doc }))["fields"][0];
(f["rotation"].as_i64().unwrap(), f["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::<Vec<_>>())
};
let (rot, before) = rect_of(&mut a);
assert_eq!(rot, 0);
assert!(matches!(a.call("form_set_props", &json!({ "doc": doc, "field": "City", "rotation": 45 })), Err(ToolError::InvalidArgs(_))));
assert_eq!(rect_of(&mut a).1, before, "a rejected rotation changes nothing");
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "City", "rotation": 90 }));
let (rot, turned) = rect_of(&mut a);
assert_eq!(rot, 90);
let (bw, bh) = (before[2] - before[0], before[3] - before[1]);
let (tw, th) = (turned[2] - turned[0], turned[3] - turned[1]);
assert!((tw - bh).abs() < 0.2 && (th - bw).abs() < 0.2, "swapped {before:?} -> {turned:?}");
let center = |r: &[f64]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
let (bc, tc) = (center(&before), center(&turned));
assert!((bc.0 - tc.0).abs() < 0.2 && (bc.1 - tc.1).abs() < 0.2, "center moved {bc:?} -> {tc:?}");
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Change field properties");
let (rot, back) = rect_of(&mut a);
assert_eq!((rot, back), (0, before));
}
#[test]
fn redacting_through_tools() {
let dir = workdir("redact");
+1
View File
@@ -17,3 +17,4 @@ thiserror = { workspace = true }
[dev-dependencies]
hayro-syntax = { workspace = true }
pdfcraft-render = { workspace = true }
+3 -2
View File
@@ -39,5 +39,6 @@ delete_field(&mut doc, "email")?;
## Not yet
JavaScript actions (format, keystroke, validate, calculate; M6.4/M6.5), rich-text values,
`/MK /R` rotation, the Appearance (colours, fonts), Actions, Format, Validate and Calculate
property tabs, FDF/XFDF import and export (M6.7).
the Appearance (colours, fonts), Actions, Format, Validate and Calculate property tabs,
FDF/XFDF import and export (M6.7). `/MK /R` rotation (0, 90, 180, 270) is drawn into the
appearance.
+63 -4
View File
@@ -10,6 +10,10 @@
//! Japanese forms use (text encoded in that CMap). Otherwise (other composite fonts, missing
//! resources) Helvetica is used, so text is always visible. Widths use the approximate metrics
//! of `pdfcraft-fonts` (Helvetica, or one em per full-width character in composite fonts).
//!
//! `/MK /R` (0, 90, 180, 270, counterclockwise) lays the content out in the upright box and sets
//! the appearance `/Matrix` so that box lands on the widget rectangle. Any other value is 0, and
//! rotation 0 leaves `/Matrix` off.
use pdfcraft_cos::{Dict, Document, Object, Stream};
use pdfcraft_fonts::{UnicodeCMap, cjk_width, helvetica_width, literal, win_ansi, wrap_with};
@@ -28,6 +32,57 @@ pub fn fmt(v: f64) -> String {
n(v)
}
/// `/MK /R` as a quadrant, or 0 when it is missing or not a multiple of 90.
pub(crate) fn mk_rotation(doc: &Document, wd: &Dict) -> i64 {
let Some(raw) = wd.get(b"MK") else { return 0 };
let mk_obj = doc.resolve(raw);
let Some(mk) = mk_obj.as_dict() else { return 0 };
let Some(r_obj) = mk.get(b"R") else { return 0 };
let resolved = doc.resolve(r_obj);
let Some(r) = degrees(&resolved) else { return 0 };
match r.rem_euclid(360) {
q @ (0 | 90 | 180 | 270) => q,
_ => 0,
}
}
fn degrees(o: &Object) -> Option<i64> {
if let Some(i) = o.as_int() {
return Some(i);
}
let v = o.as_f64()?;
(v.is_finite() && v.abs() <= 1.0e6).then_some(v.round() as i64)
}
/// Where an appearance is drawn: the upright layout box, and the `/Matrix` that turns it onto
/// the widget rectangle. Rotation 0 has no matrix.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Placement {
pub layout_w: f64,
pub layout_h: f64,
pub matrix: Option<[f64; 6]>,
}
pub(crate) fn placement(rotation: i64, page_w: f64, page_h: f64) -> Placement {
let (layout_w, layout_h, matrix) = match rotation {
// (x, y) -> (page_w - y, x): text that ran to the right runs upward.
90 => (page_h, page_w, Some([0.0, 1.0, -1.0, 0.0, page_w, 0.0])),
180 => (page_w, page_h, Some([-1.0, 0.0, 0.0, -1.0, page_w, page_h])),
// (x, y) -> (y, page_h - x): text that ran to the right runs downward.
270 => (page_h, page_w, Some([0.0, -1.0, 1.0, 0.0, 0.0, page_h])),
_ => (page_w, page_h, None),
};
Placement { layout_w, layout_h, matrix }
}
pub(crate) fn set_form_box(d: &mut Dict, place: Placement) {
let box_ = [0.0, 0.0, place.layout_w, place.layout_h];
d.set(b"BBox".to_vec(), Object::Array(box_.iter().map(|v| Object::Real(*v)).collect()));
if let Some(m) = place.matrix {
d.set(b"Matrix".to_vec(), Object::Array(m.iter().map(|v| Object::Real(*v)).collect()));
}
}
/// A parsed default appearance string: font resource name, size (0 = auto) and colour operator.
#[derive(Clone, Debug, PartialEq)]
pub struct Da {
@@ -222,7 +277,9 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri
};
let wobj = doc.get(w.obj);
let wd = wobj.as_dict().cloned().unwrap_or_default();
let (width, height) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
let place = placement(mk_rotation(doc, &wd), page_w, page_h);
let (width, height) = (place.layout_w, place.layout_h);
let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da));
let (font_name, font_obj, enc) = match dr_font(doc, &da.font) {
Some((o, enc)) => (da.font.clone(), o, enc),
@@ -338,7 +395,7 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Form"));
d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, width, height].iter().map(|v| Object::Real(*v)).collect()));
set_form_box(&mut d, place);
d.set(b"Resources".to_vec(), Object::Dict(res));
Stream::flate(d, &content)
}
@@ -349,14 +406,16 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri
pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name: &str) -> Dict {
let wobj = doc.get(w.obj);
let wd = wobj.as_dict().cloned().unwrap_or_default();
let (width, height) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
let place = placement(mk_rotation(doc, &wd), page_w, page_h);
let (width, height) = (place.layout_w, place.layout_h);
let (frame_c, _) = frame(doc, &wd, width, height);
let style = crate::author::CheckStyle::of_widget(doc, &wd, kind);
let mut form = |content: String| -> Object {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Form"));
d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, width, height].iter().map(|v| Object::Real(*v)).collect()));
set_form_box(&mut d, place);
Object::Ref(doc.add(Object::Stream(Stream::flate(d, content.as_bytes()))))
};
let s = width.min(height);
+68 -10
View File
@@ -91,6 +91,10 @@ pub struct FieldProps {
pub actions: Option<Vec<(crate::Trigger, crate::FieldAction)>>,
/// Options tab: the mark of a check box or radio button (every widget).
pub check_style: Option<CheckStyle>,
/// Position tab: rotate one widget (`/MK /R`) to 0, 90, 180 or 270 degrees counterclockwise.
/// Changing between horizontal and vertical swaps that widget's width and height around its
/// center, unless [`Self::rect`] is also set.
pub rotation: Option<(usize, i64)>,
}
fn invalid<T>(m: impl Into<String>) -> Result<T, FormError> {
@@ -524,6 +528,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
tab: usize::MAX,
locked: false,
hidden: false,
rotation: 0,
};
let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export);
doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?;
@@ -574,10 +579,12 @@ pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
Ok(())
}
fn frame_only(doc: &Document, w: &Widget) -> (String, f64, f64) {
let (width, height) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
fn frame_only(doc: &Document, w: &Widget) -> (String, crate::appearance::Placement) {
let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
let wobj = doc.get(w.obj);
let wd = wobj.as_dict().cloned().unwrap_or_default();
let place = crate::appearance::placement(crate::appearance::mk_rotation(doc, &wd), page_w, page_h);
let (width, height) = (place.layout_w, place.layout_h);
let mk = wd.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default();
let col = |k: &[u8]| -> Option<String> {
let v: Vec<f64> = mk.get(k)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect();
@@ -590,14 +597,14 @@ fn frame_only(doc: &Document, w: &Widget) -> (String, f64, f64) {
if let Some(bc) = col(b"BC") {
c.push_str(&format!("{bc} RG 1 w 0.5 0.5 {:.3} {:.3} re S\n", width - 1.0, height - 1.0));
}
(c, width, height)
(c, place)
}
fn form_stream(width: f64, height: f64, content: Vec<u8>, resources: Dict) -> Stream {
fn form_stream(place: crate::appearance::Placement, content: Vec<u8>, resources: Dict) -> Stream {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Form"));
d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, width, height].iter().map(|v| Object::Real(*v)).collect()));
crate::appearance::set_form_box(&mut d, place);
d.set(b"Resources".to_vec(), Object::Dict(resources));
Stream::flate(d, &content)
}
@@ -605,7 +612,8 @@ fn form_stream(width: f64, height: f64, content: Vec<u8>, resources: Dict) -> St
/// A push button: background, border, its icon (`/MK /I`, scaled to fit and centred) and its
/// centred caption (unless the layout is icon only, `/TP 1`).
fn button_appearance(doc: &Document, w: &Widget) -> Stream {
let (mut c, width, height) = frame_only(doc, w);
let (mut c, place) = frame_only(doc, w);
let (width, height) = (place.layout_w, place.layout_h);
let wobj = doc.get(w.obj);
let mk = wobj.as_dict().and_then(|d| d.get(b"MK")).map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default();
let caption = mk.get(b"CA").and_then(|c| c.as_string()).map(|s| s.to_text()).unwrap_or_default();
@@ -646,7 +654,7 @@ fn button_appearance(doc: &Document, w: &Widget) -> Stream {
if !xobjects.is_empty() {
res.set(b"XObject".to_vec(), Object::Dict(xobjects));
}
form_stream(width, height, content, res)
form_stream(place, content, res)
}
/// Give a push button (an image field) the picture `image` (an image XObject of `px` pixels):
@@ -662,7 +670,8 @@ pub fn set_button_icon(doc: &mut Document, name: &str, image: ObjRef, px: (u32,
xo.set(b"Im0".to_vec(), Object::Ref(image));
let mut res = Dict::new();
res.set(b"XObject".to_vec(), Object::Dict(xo));
let icon = doc.add(Object::Stream(form_stream(w, h, format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q").into_bytes(), res)));
let icon =
doc.add(Object::Stream(form_stream(crate::appearance::placement(0, w, h), format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q").into_bytes(), res)));
for wd in &f.widgets {
let mut mk =
doc.get(wd.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m))).and_then(|m| m.as_dict().cloned()).unwrap_or_default();
@@ -676,14 +685,43 @@ pub fn set_button_icon(doc: &mut Document, name: &str, image: ObjRef, px: (u32,
}
fn empty_box(doc: &Document, w: &Widget) -> Stream {
let (c, width, height) = frame_only(doc, w);
form_stream(width, height, c.into_bytes(), Dict::new())
let (c, place) = frame_only(doc, w);
form_stream(place, c.into_bytes(), Dict::new())
}
/// Swap width and height, keeping the center. `None` when that is not a usable widget.
fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> {
if !r.iter().all(|v| v.is_finite()) {
return None;
}
let (x0, y0, x1, y1) = (r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3]));
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
let (nw, nh) = (y1 - y0, x1 - x0);
let out = [cx - nw / 2.0, cy - nh / 2.0, cx + nw / 2.0, cy + nh / 2.0];
(out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= 4.0 && out[3] - out[1] >= 4.0).then_some(out)
}
/// Change a field's properties (General and Options tabs) and redraw it.
pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<String, FormError> {
let all = fields(doc);
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone();
// Validated before anything is written, so a refused rotation leaves the document unchanged.
// `Some(Some(rect))`: the widget's axis changes and it gets the swapped rectangle.
let mut rotated_rect: Option<Option<[f64; 4]>> = None;
if let Some((wi, rot)) = props.rotation {
if !matches!(rot, 0 | 90 | 180 | 270) {
return invalid("rotation must be 0, 90, 180 or 270 degrees");
}
let Some(w) = f.widgets.get(wi) else {
return invalid(format!("{name} has no widget {}", wi.saturating_add(1)));
};
let axis_change = (w.rotation % 180 == 0) != (rot % 180 == 0);
rotated_rect = Some(None);
if axis_change && props.rect.is_none() {
let Some(rect) = swapped_around_center(w.rect) else { return invalid("the field is too small") };
rotated_rect = Some(Some(rect));
}
}
// A locked field only takes unlocking (Acrobat greys its properties out).
if f.locked() && props.locked != Some(false) && *props != (FieldProps { locked: props.locked, ..FieldProps::default() }) {
return invalid(format!("{name} is locked: unlock it first"));
@@ -943,6 +981,26 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
}
}
}
if let (Some((wi, rot)), Some(swapped)) = (props.rotation, rotated_rect)
&& let Some(w) = f.widgets.get(wi)
{
if let Some(rect) = swapped {
doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())))?;
}
let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default();
if rot == 0 {
mk.remove(b"R");
} else {
mk.set(b"R".to_vec(), Object::Int(rot));
}
doc.update_dict(w.obj, |d| {
if mk.is_empty() {
d.remove(b"MK");
} else {
d.set(b"MK".to_vec(), Object::Dict(mk));
}
})?;
}
redraw_field(doc, &new_name)?;
if props.calculate.is_some() {
crate::recalculate(doc)?;
+3
View File
@@ -101,6 +101,8 @@ pub struct Widget {
pub locked: bool,
/// The widget's Hidden or NoView flag (`/F` bit 2 or 6): it isn't shown and takes no input.
pub hidden: bool,
/// `/MK /R` as 0, 90, 180 or 270 degrees counterclockwise. Anything else is stored as 0.
pub rotation: i64,
}
/// A terminal form field.
@@ -702,6 +704,7 @@ fn walk(
tab: usize::MAX,
locked: annot_flags & 128 != 0,
hidden: annot_flags & (2 | 32) != 0,
rotation: appearance::mk_rotation(doc, wd),
})
})
.collect();
+140
View File
@@ -955,3 +955,143 @@ fn page_shapes_reads_boxes_and_rules() {
assert_eq!(sh.boxes, [[20.0, 20.0, 32.0, 32.0]]);
assert_eq!(sh.rules, [[40.0, 100.0, 240.0, 100.0], [60.0, 600.5, 260.0, 600.5]]);
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 0.05
}
fn nums(o: &Object) -> Vec<f64> {
o.as_array().unwrap().iter().filter_map(Object::as_f64).collect()
}
/// Normal appearance `/BBox` and `/Matrix` (absent when the widget is not rotated).
fn appearance_box(doc: &Document, w: &Widget) -> (Vec<f64>, Option<Vec<f64>>) {
let n = doc.get(w.obj).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap();
let Object::Stream(s) = &*doc.get(n) else { panic!("appearance") };
(nums(s.dict.get(b"BBox").unwrap()), s.dict.get(b"Matrix").map(nums))
}
#[test]
fn rotated_fields_draw_in_their_quadrant() {
let mut doc = fixture();
// No /R: the appearance is the widget rectangle and carries no /Matrix.
set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap();
let (bbox, matrix) = appearance_box(&doc, &field(&fields(&doc), "name").widgets[0]);
assert!(close(bbox[2], 200.0) && close(bbox[3], 20.0), "{bbox:?}");
assert!(matrix.is_none(), "{matrix:?}");
let set_r = |doc: &mut Document, degrees: i64| {
let obj = field(&fields(doc), "name").widgets[0].obj;
doc.update_dict(obj, |d| {
let mut mk = d.get(b"MK").and_then(|m| m.as_dict().cloned()).unwrap_or_default();
mk.set(b"R".to_vec(), Object::Int(degrees));
d.set(b"MK".to_vec(), Object::Dict(mk));
})
.unwrap();
set_value(doc, "name", &FieldValue::Text("Ada".into())).unwrap();
};
// 90 and 270 lay the text out in the swapped box. 180 keeps the box and turns it over.
set_r(&mut doc, 90);
let (bbox, matrix) = appearance_box(&doc, &field(&fields(&doc), "name").widgets[0]);
assert!(close(bbox[2], 20.0) && close(bbox[3], 200.0), "{bbox:?}");
let m = matrix.unwrap();
assert!(close(m[0], 0.0) && close(m[1], 1.0) && close(m[2], -1.0) && close(m[4], 200.0), "{m:?}");
set_r(&mut doc, 180);
let (bbox, matrix) = appearance_box(&doc, &field(&fields(&doc), "name").widgets[0]);
assert!(close(bbox[2], 200.0) && close(bbox[3], 20.0), "{bbox:?}");
let m = matrix.unwrap();
assert!(close(m[0], -1.0) && close(m[3], -1.0) && close(m[4], 200.0) && close(m[5], 20.0), "{m:?}");
set_r(&mut doc, 270);
let (bbox, matrix) = appearance_box(&doc, &field(&fields(&doc), "name").widgets[0]);
assert!(close(bbox[2], 20.0) && close(bbox[3], 200.0), "{bbox:?}");
let m = matrix.unwrap();
assert!(close(m[1], -1.0) && close(m[2], 1.0) && close(m[5], 20.0), "{m:?}");
// A non-quadrant /R is drawn upright.
set_r(&mut doc, 45);
let (bbox, matrix) = appearance_box(&doc, &field(&fields(&doc), "name").widgets[0]);
assert!(matrix.is_none() && close(bbox[2], 200.0) && close(bbox[3], 20.0), "{bbox:?} {matrix:?}");
assert_eq!(field(&fields(&doc), "name").widgets[0].rotation, 0);
let before = field(&fields(&doc), "name").widgets[0].rect;
assert!(set_props(&mut doc, "name", &FieldProps { rotation: Some((0, 45)), ..FieldProps::default() }).is_err());
assert!(field(&fields(&doc), "name").widgets[0].rect.iter().zip(before).all(|(a, b)| close(*a, b)));
set_props(&mut doc, "name", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap();
let turned = field(&fields(&doc), "name").widgets[0].clone();
assert_eq!(turned.rotation, 90);
let r = turned.rect;
// [50 700 250 720] around its center becomes 20 wide and 200 tall.
assert!(close(r[0], 140.0) && close(r[1], 610.0) && close(r[2], 160.0) && close(r[3], 810.0), "{r:?}");
let widget = doc.get(turned.obj);
let mk = widget.as_dict().unwrap().get(b"MK").unwrap().as_dict().cloned().unwrap();
assert!(mk.contains(b"BG") && mk.contains(b"BC") && mk.int(b"R") == Some(90));
let (_, matrix) = appearance_box(&doc, &turned);
assert!(matrix.is_some());
// 270 stays on the vertical axis, so the rectangle is not swapped again.
set_props(&mut doc, "name", &FieldProps { rotation: Some((0, 270)), ..FieldProps::default() }).unwrap();
let r270 = field(&fields(&doc), "name").widgets[0].rect;
assert!(r270.iter().zip(r).all(|(a, b)| close(*a, b)), "{r270:?}");
// A check box and a push button use the same matrix.
let mut doc = one_page();
add_field(&mut doc, 0, [20.0, 40.0, 40.0, 60.0], &NewField::CheckBox, Some("agree")).unwrap();
add_field(&mut doc, 0, [20.0, 80.0, 120.0, 110.0], &NewField::Button { caption: "Go".into() }, Some("go")).unwrap();
set_props(&mut doc, "agree", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap();
set_props(&mut doc, "go", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap();
let agree = field(&fields(&doc), "agree").widgets[0].clone();
let widget = doc.get(agree.obj);
let n = widget.as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().get(b"N").cloned().unwrap();
let states = doc.resolve(&n);
let yes = states.as_dict().unwrap().reference(b"Yes").unwrap();
let Object::Stream(s) = &*doc.get(yes) else { panic!("check") };
assert!(s.dict.contains(b"Matrix"), "the check mark is rotated");
let go = field(&fields(&doc), "go").widgets[0].clone();
let (text, matrix) = (ap(&doc, &go), appearance_box(&doc, &go).1);
assert!(text.contains("(Go) Tj") && matrix.is_some(), "{text}");
}
#[test]
fn a_rotation_refused_as_too_small_changes_nothing_else() {
let mut doc = one_page();
add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("thin")).unwrap();
// 200 x 3: turned a quarter it would be 3 wide, too small to use.
let obj = field(&fields(&doc), "thin").widgets[0].obj;
doc.update_dict(obj, |d| d.set(b"Rect".to_vec(), Object::Array([50.0, 700.0, 250.0, 703.0].iter().map(|v| Object::Real(*v)).collect()))).unwrap();
let props = FieldProps { tooltip: Some("changed".into()), rotation: Some((0, 90)), ..FieldProps::default() };
assert!(set_props(&mut doc, "thin", &props).is_err());
let f = field(&fields(&doc), "thin").clone();
assert_eq!(f.tooltip, None, "the tooltip was written before the rotation was refused");
assert_eq!(f.widgets[0].rotation, 0);
}
#[test]
fn a_rotated_field_draws_along_the_vertical_edge() {
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
let mut doc = one_page();
add_field(&mut doc, 0, [10.0, 40.0, 90.0, 60.0], &NewField::Text { multiline: false }, Some("wide")).unwrap();
set_value(&mut doc, "wide", &FieldValue::Text("MMMMMM".into())).unwrap();
set_props(&mut doc, "wide", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap();
let span = |doc: &Document| -> (u32, u32) {
let bytes = write_incremental(doc, &SaveOptions::default()).unwrap();
let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0 });
assert!(p.error.is_none(), "{:?}", p.error);
let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0);
let mut ink = false;
for y in 0..p.height {
for x in 0..p.width {
let i = ((y * p.width + x) * 4) as usize;
if p.rgba[i] < 200 {
ink = true;
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
}
}
}
assert!(ink, "the field drew no text");
(x1 - x0, y1 - y0)
};
let (wide, tall) = span(&doc);
assert!(wide > tall, "upright text is a horizontal run: {wide}x{tall}");
set_props(&mut doc, "wide", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap();
let (wide, tall) = span(&doc);
assert!(tall > wide, "rotated text is a vertical run: {wide}x{tall}");
}
+1
View File
@@ -290,6 +290,7 @@
Bottom: Долу:
Width: Ширина:
Height: Височина:
Rotation: Завъртане:
Points from the page's bottom-left corner. Точки от долния ляв ъгъл на страницата.
Alignment: Подравняване:
Default Value: Стойност по подразбиране:
Can't render this file because it contains an unexpected character in line 8 and column 16.
+1
View File
@@ -10,6 +10,7 @@
Menu Nabídka
File Soubor
Rotation: Otočení:
Edit Úpravy
Pages Stránky
View Zobrazení
Can't render this file because it contains an unexpected character in line 7 and column 81.
+1
View File
@@ -576,6 +576,7 @@
Bottom: Unten:
Width: Breite:
Height: Höhe:
Rotation: Drehung:
Points from the page's bottom-left corner. Punkte ab der unteren linken Ecke der Seite.
Alignment: Ausrichtung:
Default Value: Standardwert:
Can't render this file because it contains an unexpected character in line 6 and column 51.
+1
View File
@@ -599,6 +599,7 @@
Bottom: Inferior:
Width: Ancho:
Height: Alto:
Rotation: Rotación:
Points from the page's bottom-left corner. Puntos desde la esquina inferior izquierda de la página.
Alignment: Alineación:
Default Value: Valor predeterminado:
Can't render this file because it contains an unexpected character in line 6 and column 60.
+1
View File
@@ -576,6 +576,7 @@
Bottom: Bas :
Width: Largeur :
Height: Hauteur :
Rotation: Rotation :
Points from the page's bottom-left corner. Points depuis le coin inférieur gauche de la page.
Alignment: Alignement :
Default Value: Valeur par défaut :
Can't render this file because it contains an unexpected character in line 6 and column 39.
+1
View File
@@ -2082,3 +2082,4 @@ action wizard Start Indítás
Squiggly Hullámos
Home / End Home / End
H / Space (hold) H / szóköz (nyomva tartva)
Rotation: Elforgatás:
Can't render this file because it contains an unexpected character in line 7 and column 44.
+1
View File
@@ -605,6 +605,7 @@
Bottom: 下:
Width: 幅:
Height: 高さ:
Rotation: 回転:
Points from the page's bottom-left corner. ページの左下隅からの距離(ポイント)。
Alignment: 配置:
Default Value: 既定値:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -10,6 +10,7 @@
Menu Menu
File Arquivo
Rotation: Rotação:
Edit Editar
Pages Páginas
View Exibir
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -597,6 +597,7 @@
Bottom: Снизу:
Width: Ширина:
Height: Высота:
Rotation: Поворот:
Points from the page's bottom-left corner. Точки от левого нижнего угла страницы.
Alignment: Выравнивание:
Default Value: Значение по умолчанию:
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -582,6 +582,7 @@ comment menu Edit సవరించు
Bottom: కింద:
Width: వెడల్పు:
Height: ఎత్తు:
Rotation: భ్రమణం:
Points from the page's bottom-left corner. పేజీ కింది ఎడమ మూల నుండి పాయింట్‌లలో కొలుస్తారు.
Alignment: అమరిక:
Default Value: డిఫాల్ట్ విలువ:
Can't render this file because it contains an unexpected character in line 13 and column 16.
+1
View File
@@ -2104,3 +2104,4 @@ calendar date December грудня
Squiggly Хвилясте
Home / End Home / End
H / Space (hold) H / Пробіл (утримувати)
Rotation: Поворот:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -296,6 +296,7 @@
Bottom: 下:
Width: 宽:
Height: 高:
Rotation: 旋转:
Points from the page's bottom-left corner. 从页面左下角算起的点数。
Alignment: 对齐:
Default Value: 默认值:
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -254,6 +254,7 @@
Bottom: 下:
Width: 寬:
Height: 高:
Rotation: 旋轉:
Points from the page's bottom-left corner. 從頁面左下角算起的點數。
Alignment: 對齊:
Default Value: 預設值:
Can't render this file because it contains an unexpected character in line 9 and column 16.
+23
View File
@@ -666,6 +666,8 @@ pub struct FieldDraft {
pub font_size: f64,
/// Left, bottom, width, height in points.
pub position: [f64; 4],
/// `/MK /R`: 0, 90, 180 or 270 degrees counterclockwise.
pub rotation: i64,
pub look: Option<FieldLook>,
/// Check boxes and radio buttons: the mark when on (Options tab).
pub check_style: Option<pdfcraft_engine::CheckStyle>,
@@ -709,6 +711,7 @@ impl FieldDraft {
on_state: f.widgets.get(widget).and_then(|w| w.on_state.clone()).unwrap_or_default(),
font_size: da_size(&f.da),
position: [r[0], r[1], r[2] - r[0], r[3] - r[1]],
rotation: f.widgets.get(widget).map(|w| w.rotation).unwrap_or(0),
look: None,
check_style: None,
format: f.actions.format.clone(),
@@ -764,6 +767,7 @@ impl FieldDraft {
let [x, y, w, h] = self.position;
(self.widget, [x, y, x + w.max(4.0), y + h.max(4.0)])
}),
rotation: (self.rotation != o.rotation).then_some((self.widget, self.rotation)),
look: (self.look != o.look).then_some(self.look).flatten(),
check_style: (self.check_style != o.check_style).then_some(self.check_style).flatten(),
format: (self.format != o.format).then(|| self.format.clone()),
@@ -929,6 +933,25 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke
}
});
ui.label(egui::RichText::new(tl!("Points from the page's bottom-left corner.")).small().color(t.text_faint));
ui.add_space(8.0);
ui.horizontal(|ui| {
let l = ui.label(tl!("Rotation:"));
let before = d.rotation;
egui::ComboBox::from_id_salt("field-rotation")
.selected_text(format!("{}°", d.rotation))
.show_ui(ui, |ui| {
for r in [0, 90, 180, 270] {
if ui.selectable_value(&mut d.rotation, r, format!("{r}°")).changed() && (before % 180 == 0) != (r % 180 == 0) {
// Keep the same center. set_props skips its own swap when the rect changed too.
let [x, y, w, h] = d.position;
let (cx, cy) = (x + w / 2.0, y + h / 2.0);
d.position = [cx - h / 2.0, cy - w / 2.0, h, w];
}
}
})
.response
.labelled_by(l.id);
});
}
FieldTab::Format => format_tab(ui, d, t),
FieldTab::Validate => validate_tab(ui, d),
+33
View File
@@ -171,6 +171,39 @@ fn appearance_tab_restyles_the_field() {
assert_eq!((l.style, l.font, l.fill), (BorderStyle::Dashed, FieldFont::Courier, Some([1.0, 1.0, 0.8])));
}
#[test]
fn position_tab_rotates_a_field() {
let mut h = harness();
h.state_mut().open_field_props("city", 0);
h.run_steps(2);
let widget = |h: &Harness<'static, PdfCraftApp>| {
let s = h.state();
let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "city").unwrap();
f.widgets[0].clone()
};
let before = widget(&h);
assert_eq!(before.rotation, 0);
assert!(h.state().field_props.as_ref().unwrap().props().is_none(), "an unchanged draft is not an edit");
h.get_by_label("Position").click();
h.run_steps(2);
h.get_by_label("Rotation:");
h.state_mut().field_props.as_mut().unwrap().rotation = 90;
assert!(h.state().field_props.as_ref().unwrap().props().is_some());
h.get_by_label("OK").click();
h.run_steps(3);
let turned = widget(&h);
assert_eq!(turned.rotation, 90);
let [x0, y0, x1, y1] = before.rect;
let (cx, cy, w, ht) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, x1 - x0, y1 - y0);
let expect = [cx - ht / 2.0, cy - w / 2.0, cx + ht / 2.0, cy + w / 2.0];
assert!(turned.rect.iter().zip(expect).all(|(a, b)| (a - b).abs() < 0.05), "{:?} vs {expect:?}", turned.rect);
h.state_mut().execute("edit.undo");
h.run_steps(3);
let back = widget(&h);
assert_eq!(back.rotation, 0);
assert!(back.rect.iter().zip(before.rect).all(|(a, b)| (a - b).abs() < 0.05), "{:?} vs {:?}", back.rect, before.rect);
}
#[test]
fn options_tab_sets_the_check_box_style() {
// #94: Check Box Style on the Options tab.
+3 -3
View File
@@ -4397,9 +4397,9 @@ tier = "P0"
milestone = "M6"
status = "partial"
commands = ["form.fields"]
tools = ["form_fill"]
evidence = ["crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text"]
notes = "auto-size, comb, multiline, password, quadding, /MK colours; approximate Helvetica metrics, no /MK rotation yet"
tools = ["form_fill", "form_set_props"]
evidence = ["crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/forms/src/tests.rs::rotated_fields_draw_in_their_quadrant", "crates/forms/src/tests.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/prepare.rs::position_tab_rotates_a_field"]
notes = "auto-size, comb, multiline, password, quadding, /MK colours and /MK /R rotation (0, 90, 180, 270); approximate Helvetica metrics"
[[feature]]
id = "form.rich-text-fields"