mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
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:
1 parent
2d24de1c16
commit
c9d0619226
28 files changed
+402
-34
No files matched your search
+13
-13
@@ -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
@@ -3641,6 +3641,7 @@ dependencies = [
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-render",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
|
||||
@@ -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%).
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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\"." },
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -17,3 +17,4 @@ thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
hayro-syntax = { workspace = true }
|
||||
pdfcraft-render = { workspace = true }
|
||||
@@ -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.
|
||||
@@ -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
@@ -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)?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user