I: PDF form fields — text, check box, combo, list, signature
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
33a56dfe32
commit
dc2114be26
|
|
@ -86,7 +86,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
|||
- Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change.
|
||||
- Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; dragging on the canvas isn't there yet).
|
||||
- Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF.
|
||||
- Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields need AcroForm support the PDF writer lacks.
|
||||
- Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X).
|
||||
- Power Zoom (Hand tool: press and hold still for half a second, or Alt-press): zoom out to the spread, aim the red view rectangle, release to zoom back in there.
|
||||
- Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF.
|
||||
- Live Distribute (Space while dragging a selection handle; `transform.resize {distribute}`): objects keep their size and spread with the bounds.
|
||||
|
|
|
|||
|
|
@ -467,6 +467,38 @@ pub struct ObjectLiquid {
|
|||
pub pin_right: bool,
|
||||
}
|
||||
|
||||
/// A PDF form field.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FormField {
|
||||
pub kind: FieldKind,
|
||||
/// Field name (unique in the exported PDF).
|
||||
pub name: String,
|
||||
/// Default value: the text, the chosen option, or "On"/"" for a check box.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub value: String,
|
||||
/// Choices of a combo box or list box.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub options: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub multiline: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub required: bool,
|
||||
/// Font size of the field's text (0 = auto).
|
||||
#[serde(default)]
|
||||
pub font_size: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FieldKind {
|
||||
TextField,
|
||||
CheckBox,
|
||||
ComboBox,
|
||||
ListBox,
|
||||
Signature,
|
||||
}
|
||||
|
||||
/// The source and template of a live caption.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -916,6 +948,9 @@ pub struct Item {
|
|||
/// Liquid Layout (object-based pages): pins and resize permissions.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub liquid: Option<ObjectLiquid>,
|
||||
/// Buttons and Forms: an interactive PDF form field drawn in this frame.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub form_field: Option<FormField>,
|
||||
/// Live caption: this text frame shows `template` filled from the source object's metadata,
|
||||
/// kept current as the source changes.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -980,6 +1015,7 @@ impl Item {
|
|||
button: None,
|
||||
pdf_hidden_layers: Vec::new(),
|
||||
live_caption: None,
|
||||
form_field: None,
|
||||
media: None,
|
||||
liquid: None,
|
||||
states: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -50,6 +50,90 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
q["action"] = json!("none");
|
||||
s.execute("button.set", &q)
|
||||
}),
|
||||
cmd!(
|
||||
"form.set",
|
||||
"Convert to Form Field",
|
||||
["Object", "Interactive"],
|
||||
None,
|
||||
"{ids?, kind: textField|checkBox|comboBox|listBox|signature, name?, value?, options?: [string], multiline?, required?, fontSize?} — interactive PDF form fields",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let ids = targets(s, p)?;
|
||||
let kind: designcraft_doc::FieldKind = serde_json::from_value(p.get("kind").cloned().unwrap_or(json!("textField")))
|
||||
.map_err(|_| bad("form.set", "`kind`: textField|checkBox|comboBox|listBox|signature"))?;
|
||||
let p = p.clone();
|
||||
s.edit(|d, _| {
|
||||
let mut taken: Vec<String> =
|
||||
d.all_items().into_iter().filter_map(|i| d.item(i)?.form_field.as_ref().map(|f| f.name.clone())).collect();
|
||||
for (k, id) in ids.iter().enumerate() {
|
||||
let Some(it) = d.item_mut(*id) else { continue };
|
||||
let mut f = it.form_field.clone().unwrap_or(designcraft_doc::FormField {
|
||||
kind,
|
||||
name: String::new(),
|
||||
value: String::new(),
|
||||
options: vec![],
|
||||
multiline: false,
|
||||
required: false,
|
||||
font_size: 0.0,
|
||||
});
|
||||
f.kind = kind;
|
||||
if let Some(v) = p.get("name").and_then(Value::as_str) {
|
||||
f.name = if ids.len() > 1 { format!("{v}{}", k + 1) } else { v.to_string() };
|
||||
}
|
||||
if f.name.is_empty() {
|
||||
// A unique default name.
|
||||
let base = match kind {
|
||||
designcraft_doc::FieldKind::TextField => "Text Field",
|
||||
designcraft_doc::FieldKind::CheckBox => "Check Box",
|
||||
designcraft_doc::FieldKind::ComboBox => "Combo Box",
|
||||
designcraft_doc::FieldKind::ListBox => "List Box",
|
||||
designcraft_doc::FieldKind::Signature => "Signature",
|
||||
};
|
||||
let mut n = 1;
|
||||
while taken.contains(&format!("{base} {n}")) {
|
||||
n += 1;
|
||||
}
|
||||
f.name = format!("{base} {n}");
|
||||
}
|
||||
taken.push(f.name.clone());
|
||||
if let Some(v) = p.get("value").and_then(Value::as_str) {
|
||||
f.value = v.to_string();
|
||||
}
|
||||
if let Some(v) = p.get("options").and_then(Value::as_array) {
|
||||
f.options = v.iter().filter_map(Value::as_str).map(str::to_string).collect();
|
||||
}
|
||||
if let Some(v) = p.get("multiline").and_then(Value::as_bool) {
|
||||
f.multiline = v;
|
||||
}
|
||||
if let Some(v) = p.get("required").and_then(Value::as_bool) {
|
||||
f.required = v;
|
||||
}
|
||||
if let Some(v) = p.get("fontSize").and_then(Value::as_f64) {
|
||||
f.font_size = v.max(0.0);
|
||||
}
|
||||
it.form_field = Some(f);
|
||||
}
|
||||
Ok(json!({"fields": ids.len()}))
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!("form.clear", "Convert to Object", [], None, "{ids?} — no longer a form field", has_selection, |s, p| {
|
||||
let ids = targets(s, p)?;
|
||||
s.edit(|d, _| {
|
||||
for id in &ids {
|
||||
if let Some(it) = d.item_mut(*id) {
|
||||
it.form_field = None;
|
||||
}
|
||||
}
|
||||
Ok(Value::Null)
|
||||
})
|
||||
}),
|
||||
cmd!(query "form.list", "Form Fields", [], None, "{} → [{id, kind, name, value, options}]", super::has_doc, |s, _| {
|
||||
let d = &s.doc()?.doc;
|
||||
Ok(Value::Array(
|
||||
d.all_items().into_iter().filter_map(|id| { let it = d.item(id)?; let f = it.form_field.as_ref()?; Some(json!({"id": id.0, "kind": f.kind, "name": f.name, "value": f.value, "options": f.options})) }).collect(),
|
||||
))
|
||||
}),
|
||||
cmd!(query "button.list", "Buttons", [], None, "{} → [{id, name, action}]", super::has_doc, |s, _| {
|
||||
let d = &s.doc()?.doc;
|
||||
let mut out = Vec::new();
|
||||
|
|
@ -84,6 +168,17 @@ mod tests {
|
|||
let with = links(&mut s);
|
||||
s.execute("button.clear", &json!({"ids": [id]})).unwrap();
|
||||
assert!(with > links(&mut s), "the button exports a link annotation");
|
||||
// Form fields: a text field and a check box become AcroForm widgets.
|
||||
let t = s.execute("frame.create", &json!({"rect": [72, 300, 300, 330]})).unwrap()["id"].clone();
|
||||
let c = s.execute("frame.create", &json!({"rect": [72, 350, 90, 368]})).unwrap()["id"].clone();
|
||||
s.execute("form.set", &json!({"ids": [t], "kind": "textField", "name": "Your name", "value": "Ada"})).unwrap();
|
||||
s.execute("form.set", &json!({"ids": [c], "kind": "checkBox", "value": "On"})).unwrap();
|
||||
assert_eq!(s.execute("form.list", &json!({})).unwrap().as_array().unwrap().len(), 2);
|
||||
let r = s.execute("file.exportPdf", &json!({})).unwrap();
|
||||
let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap());
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
assert!(text.contains("/AcroForm<</Fields[") && text.contains("/FT/Tx") && text.contains("(Your name)") && text.contains("/AS/On"), "fields");
|
||||
assert_eq!(designcraft_render::pdf_page_count(&bytes), Some(3), "the update still reads");
|
||||
assert!(s.execute("button.list", &json!({})).unwrap().as_array().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,6 +202,18 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions)
|
|||
}
|
||||
let mut bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
|
||||
let mut warnings = ex.warnings;
|
||||
// Form fields (Buttons and Forms), not in PDF/X.
|
||||
let fields = crate::forms::collect(doc, &sheets);
|
||||
if !fields.is_empty() {
|
||||
if opts.standard == Standard::PdfX4 {
|
||||
warnings.push("PDF/X-4: form fields were left out".into());
|
||||
} else {
|
||||
match crate::forms::add_fields(&bytes, &fields) {
|
||||
Some(b) => bytes = b,
|
||||
None => warnings.push("form fields couldn't be added to this PDF".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
if opts.standard == Standard::PdfX4 {
|
||||
// Output intent and PDF/X identification, then check the result.
|
||||
match crate::pdfx::make_pdfx4(&bytes, &title) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
//! Interactive PDF forms (Buttons and Forms: text fields, check boxes, combo and list boxes,
|
||||
//! signature fields). krilla doesn't write AcroForm fields, so they're added as an incremental
|
||||
//! update: widget annotations on their pages, the page `/Annots` and the catalog `/AcroForm`.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use designcraft_doc::{Document, FieldKind, FormField, Item, SpreadRef};
|
||||
use designcraft_geom::Affine;
|
||||
|
||||
use crate::export::Sheet;
|
||||
use crate::transitions::{int_after, object, rfind};
|
||||
|
||||
/// A field placed on an output page: (page index, PDF rectangle, field).
|
||||
pub(crate) type Placed = (usize, [f64; 4], FormField);
|
||||
|
||||
/// The form fields of every exported page, in PDF coordinates (origin bottom left).
|
||||
pub(crate) fn collect(doc: &Document, sheets: &[Sheet]) -> Vec<Placed> {
|
||||
let mut out = Vec::new();
|
||||
for (pi, sh) in sheets.iter().enumerate() {
|
||||
let Some(sp) = doc.spread(SpreadRef::Doc(sh.spread)) else { continue };
|
||||
let mut stack: Vec<(Affine, &std::sync::Arc<Item>)> = sp.items.iter().map(|it| (Affine::IDENTITY, it)).collect();
|
||||
while let Some((xf, it)) = stack.pop() {
|
||||
stack.extend(it.shown_children().map(|c| (xf * it.xf, c)));
|
||||
let Some(f) = &it.form_field else { continue };
|
||||
if it.hidden {
|
||||
continue;
|
||||
}
|
||||
let r = xf.transform_rect_bbox(it.bounds()).intersect(sh.bleed);
|
||||
if r.width() <= 0.0 || r.height() <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let h = sh.media.height();
|
||||
out.push((pi, [r.x0 - sh.media.x0, h - (r.y1 - sh.media.y0), r.x1 - sh.media.x0, h - (r.y0 - sh.media.y0)], f.clone()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A PDF text string: literal for ASCII, UTF-16BE otherwise.
|
||||
fn pdf_string(s: &str) -> String {
|
||||
if s.is_ascii() {
|
||||
let mut o = String::from("(");
|
||||
for c in s.chars() {
|
||||
if matches!(c, '(' | ')' | '\\') {
|
||||
o.push('\\');
|
||||
}
|
||||
o.push(c);
|
||||
}
|
||||
o.push(')');
|
||||
return o;
|
||||
}
|
||||
let mut o = String::from("<FEFF");
|
||||
for u in s.encode_utf16() {
|
||||
let _ = write!(o, "{u:04X}");
|
||||
}
|
||||
o.push('>');
|
||||
o
|
||||
}
|
||||
|
||||
/// Add `fields` to `pdf` (page indices in output order). `None` when the file's structure isn't
|
||||
/// the simple kind this understands.
|
||||
pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed]) -> Option<Vec<u8>> {
|
||||
if fields.is_empty() {
|
||||
return Some(pdf.to_vec());
|
||||
}
|
||||
let t_at = rfind(pdf, b"trailer")?;
|
||||
let trailer = String::from_utf8_lossy(&pdf[t_at..]).to_string();
|
||||
let size = int_after(&trailer, "/Size")?;
|
||||
let root = int_after(&trailer, "/Root")?;
|
||||
let prev = int_after(&trailer, "startxref")?;
|
||||
let info = int_after(&trailer, "/Info");
|
||||
let id = trailer.find("/ID").and_then(|i| trailer[i..].find(']').map(|j| trailer[i..i + j + 1].to_string()));
|
||||
let cat = String::from_utf8_lossy(&pdf[object(pdf, root)?]).to_string();
|
||||
let pages_id = int_after(&cat, "/Pages")?;
|
||||
let pages = String::from_utf8_lossy(&pdf[object(pdf, pages_id)?]).to_string();
|
||||
let kids_str = &pages[pages.find("/Kids")? + 5..];
|
||||
let kids_str = &kids_str[kids_str.find('[')? + 1..kids_str.find(']')?];
|
||||
let nums: Vec<usize> = kids_str.split_whitespace().filter_map(|t| t.parse().ok()).collect();
|
||||
let kids: Vec<usize> = nums.chunks(2).map(|c| c[0]).collect();
|
||||
|
||||
let mut out = pdf.to_vec();
|
||||
if !out.ends_with(b"\n") {
|
||||
out.push(b'\n');
|
||||
}
|
||||
let mut entries: Vec<(usize, usize)> = Vec::new();
|
||||
let mut next = size;
|
||||
let mut put = |out: &mut Vec<u8>, id: usize, body: &[u8]| {
|
||||
entries.push((id, out.len()));
|
||||
out.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
|
||||
out.extend_from_slice(body);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
};
|
||||
let font = next;
|
||||
next += 1;
|
||||
put(&mut out, font, b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding/WinAnsiEncoding>>");
|
||||
let mut per_page: Vec<Vec<usize>> = vec![Vec::new(); kids.len()];
|
||||
let mut all = Vec::new();
|
||||
for (page, r, f) in fields {
|
||||
let Some(&page_ref) = kids.get(*page) else { continue };
|
||||
let rect = format!("[{:.2} {:.2} {:.2} {:.2}]", r[0], r[1], r[2], r[3]);
|
||||
let (w, h) = (r[2] - r[0], r[3] - r[1]);
|
||||
let fs = if f.font_size > 0.0 { f.font_size } else { 0.0 };
|
||||
let common = format!(
|
||||
"/Type/Annot/Subtype/Widget/T{}/Rect{rect}/F 4/P {page_ref} 0 R/MK<</BC[0.45 0.45 0.45]/BG[1 1 1]>>/DA(/Helv {fs:.1} Tf 0 g)",
|
||||
pdf_string(&f.name)
|
||||
);
|
||||
let req = if f.required { 2 } else { 0 };
|
||||
let body = match f.kind {
|
||||
FieldKind::TextField => {
|
||||
let flags = req | if f.multiline { 4096 } else { 0 };
|
||||
format!("<<{common}/FT/Tx/Ff {flags}/V{}/DV{}>>", pdf_string(&f.value), pdf_string(&f.value))
|
||||
}
|
||||
FieldKind::CheckBox => {
|
||||
// Appearances: an empty box, and a box with a check mark.
|
||||
let border = format!("0.45 G 1 w 0.5 0.5 {:.2} {:.2} re S", (w - 1.0).max(0.0), (h - 1.0).max(0.0));
|
||||
let check = format!(
|
||||
"{border} 0 g {:.2} w 1 J 1 j {:.2} {:.2} m {:.2} {:.2} l {:.2} {:.2} l S",
|
||||
(w.min(h) * 0.12).max(0.8),
|
||||
w * 0.22,
|
||||
h * 0.52,
|
||||
w * 0.42,
|
||||
h * 0.28,
|
||||
w * 0.78,
|
||||
h * 0.76
|
||||
);
|
||||
let (on, off) = (next, next + 1);
|
||||
next += 2;
|
||||
for (oid, content) in [(on, check), (off, border)] {
|
||||
put(
|
||||
&mut out,
|
||||
oid,
|
||||
format!("<</Type/XObject/Subtype/Form/BBox[0 0 {w:.2} {h:.2}]/Length {}>>\nstream\n{content}\nendstream", content.len())
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
let state = if f.value.is_empty() || f.value == "Off" { "Off" } else { "On" };
|
||||
format!("<<{common}/FT/Btn/Ff {req}/V/{state}/AS/{state}/AP<</N<</On {on} 0 R/Off {off} 0 R>>>>>>")
|
||||
}
|
||||
FieldKind::ComboBox | FieldKind::ListBox => {
|
||||
let flags = req | if f.kind == FieldKind::ComboBox { 131072 } else { 0 };
|
||||
let opts: Vec<String> = f.options.iter().map(|o| pdf_string(o)).collect();
|
||||
format!("<<{common}/FT/Ch/Ff {flags}/Opt[{}]/V{}>>", opts.join(" "), pdf_string(&f.value))
|
||||
}
|
||||
FieldKind::Signature => format!("<<{common}/FT/Sig>>"),
|
||||
};
|
||||
let fid = next;
|
||||
next += 1;
|
||||
put(&mut out, fid, body.as_bytes());
|
||||
per_page[*page].push(fid);
|
||||
all.push(fid);
|
||||
}
|
||||
// Pages list their widgets.
|
||||
for (k, ids) in per_page.iter().enumerate() {
|
||||
if ids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let refs: String = ids.iter().map(|i| format!(" {i} 0 R")).collect();
|
||||
let body = String::from_utf8_lossy(&pdf[object(pdf, kids[k])?]).to_string();
|
||||
let new = if let Some(a) = body.find("/Annots[") {
|
||||
let close = a + body[a..].find(']')?;
|
||||
format!("{}{refs}{}", &body[..close], &body[close..])
|
||||
} else if let Some(arr_id) = int_after(&body, "/Annots") {
|
||||
// An indirect array: re-state it with the widgets added.
|
||||
let arr = String::from_utf8_lossy(&pdf[object(pdf, arr_id)?]).to_string();
|
||||
let close = arr.rfind(']')?;
|
||||
put(&mut out, arr_id, format!("{}{refs}{}", arr[..close].trim(), &arr[close..]).trim().as_bytes());
|
||||
body.clone()
|
||||
} else {
|
||||
let close = body.rfind(">>")?;
|
||||
format!("{}/Annots[{}]{}", &body[..close], refs.trim(), &body[close..])
|
||||
};
|
||||
if new != body {
|
||||
put(&mut out, kids[k], new.trim().as_bytes());
|
||||
}
|
||||
}
|
||||
// The catalog's form.
|
||||
let refs: Vec<String> = all.iter().map(|i| format!("{i} 0 R")).collect();
|
||||
let close = cat.rfind(">>")?;
|
||||
let acro = format!("/AcroForm<</Fields[{}]/NeedAppearances true/DA(/Helv 0 Tf 0 g)/DR<</Font<</Helv {font} 0 R>>>>>>", refs.join(" "));
|
||||
put(&mut out, root, format!("{}{acro}{}", cat[..close].trim(), &cat[close..]).trim().as_bytes());
|
||||
let xref_at = out.len();
|
||||
entries.sort();
|
||||
let mut x = String::from("xref\n");
|
||||
for (oid, off) in &entries {
|
||||
let _ = write!(x, "{oid} 1\n{off:010} 00000 n\r\n");
|
||||
}
|
||||
let info = info.map(|i| format!("/Info {i} 0 R")).unwrap_or_default();
|
||||
let id = id.unwrap_or_default();
|
||||
let _ = write!(x, "trailer\n<</Size {next}/Root {root} 0 R{info}{id}/Prev {prev}>>\nstartxref\n{xref_at}\n%%EOF\n");
|
||||
out.extend_from_slice(x.as_bytes());
|
||||
Some(out)
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ mod links;
|
|||
mod marks;
|
||||
mod text;
|
||||
|
||||
mod forms;
|
||||
mod pdfx;
|
||||
mod transitions;
|
||||
pub use export::{BookletKind, BookletOptions, booklet_pairs, export_booklet, export_pdf, export_pdf_with_report, merge_pdfs, sheet_spreads};
|
||||
|
|
|
|||
|
|
@ -538,6 +538,28 @@ impl Renderer {
|
|||
}
|
||||
_ => {}
|
||||
}
|
||||
// Form fields (screen only; PDF viewers draw their own): a tint, a check mark.
|
||||
if let Some(ff) = &it.form_field
|
||||
&& !f.opts.printing_only
|
||||
{
|
||||
let r = it.inner_bounds();
|
||||
ctx.set_transform(f.view * xf);
|
||||
ctx.set_paint(peniko::Color::from_rgba8(80, 140, 255, 40));
|
||||
ctx.fill_rect(&r);
|
||||
ctx.set_paint(peniko::Color::from_rgba8(60, 110, 220, 200));
|
||||
ctx.set_stroke(kurbo::Stroke::new(0.75));
|
||||
ctx.stroke_path(&r.to_path(0.1));
|
||||
if ff.kind == designcraft_doc::FieldKind::CheckBox && !ff.value.is_empty() && ff.value != "Off" {
|
||||
let (w, h) = (r.width(), r.height());
|
||||
let mut c = BezPath::new();
|
||||
c.move_to((r.x0 + w * 0.22, r.y0 + h * 0.48));
|
||||
c.line_to((r.x0 + w * 0.42, r.y0 + h * 0.72));
|
||||
c.line_to((r.x0 + w * 0.78, r.y0 + h * 0.24));
|
||||
ctx.set_paint(peniko::Color::BLACK);
|
||||
ctx.set_stroke(kurbo::Stroke::new((w.min(h) * 0.12).max(0.8)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&c);
|
||||
}
|
||||
}
|
||||
// Stroke.
|
||||
if !it.stroke.is_none() {
|
||||
let op = overprints(f, &it.stroke.swatch, it.stroke.tint, it.stroke.overprint);
|
||||
|
|
|
|||
|
|
@ -278,6 +278,76 @@ pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) {
|
|||
app.status(format!("Buttons: {e}"));
|
||||
}
|
||||
ui.label(egui::RichText::new("Buttons act in interactive PDF export.").size(10.5).color(t.text_dim));
|
||||
// Form field.
|
||||
ui.separator();
|
||||
let ff = app.session.active().and_then(|d| d.doc.item(id)).and_then(|it| it.form_field.clone());
|
||||
use designcraft_doc::FieldKind as K;
|
||||
let kinds = [
|
||||
(None, "Not a form field"),
|
||||
(Some(K::TextField), "Text Field"),
|
||||
(Some(K::CheckBox), "Check Box"),
|
||||
(Some(K::ComboBox), "Combo Box"),
|
||||
(Some(K::ListBox), "List Box"),
|
||||
(Some(K::Signature), "Signature Field"),
|
||||
];
|
||||
let cur = ff.as_ref().map(|f| f.kind);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Form Field");
|
||||
egui::ComboBox::from_id_salt("form_kind").selected_text(kinds.iter().find(|k| k.0 == cur).map_or("", |k| k.1)).show_ui(ui, |ui| {
|
||||
for (k, l) in kinds {
|
||||
if ui.selectable_label(k == cur, l).clicked() {
|
||||
let _ = match k {
|
||||
Some(k) => app.run("form.set", json!({"kind": k})),
|
||||
None => app.run("form.clear", json!({})),
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(f) = ff {
|
||||
let key = egui::Id::new(("form_edit", id.0));
|
||||
let (mut name, mut value, mut opts): (String, String, String) =
|
||||
ui.data(|d| d.get_temp(key)).unwrap_or((f.name.clone(), f.value.clone(), f.options.join(", ")));
|
||||
let mut commit = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Name");
|
||||
commit |= ui.text_edit_singleline(&mut name).lost_focus();
|
||||
});
|
||||
if f.kind == K::CheckBox {
|
||||
let mut on = !f.value.is_empty() && f.value != "Off";
|
||||
if ui.checkbox(&mut on, "Checked by default").changed() {
|
||||
let _ = app.run("form.set", json!({"kind": f.kind, "value": if on { "On" } else { "" }}));
|
||||
}
|
||||
} else if f.kind != K::Signature {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Default");
|
||||
commit |= ui.text_edit_singleline(&mut value).lost_focus();
|
||||
});
|
||||
}
|
||||
if matches!(f.kind, K::ComboBox | K::ListBox) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Choices");
|
||||
commit |= ui.text_edit_singleline(&mut opts).on_hover_text("Separated by commas").lost_focus();
|
||||
});
|
||||
}
|
||||
let mut req = f.required;
|
||||
if ui.checkbox(&mut req, "Required").changed() {
|
||||
let _ = app.run("form.set", json!({"kind": f.kind, "required": req}));
|
||||
}
|
||||
if f.kind == K::TextField {
|
||||
let mut ml = f.multiline;
|
||||
if ui.checkbox(&mut ml, "Multiline").changed() {
|
||||
let _ = app.run("form.set", json!({"kind": f.kind, "multiline": ml}));
|
||||
}
|
||||
}
|
||||
if commit {
|
||||
let options: Vec<String> = opts.split(',').map(|o| o.trim().to_string()).filter(|o| !o.is_empty()).collect();
|
||||
let _ = app.run("form.set", json!({"kind": f.kind, "name": name, "value": value, "options": options}));
|
||||
ui.data_mut(|d| d.remove::<(String, String, String)>(key));
|
||||
} else {
|
||||
ui.data_mut(|d| d.insert_temp(key, (name, value, opts)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Liquid Layout panel: the current page's rule, the selection's pins (object-based), the page's
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
|
|||
|---|---|---|---|---|---|
|
||||
| P0 | 102 | 100 | 2 | 0 | 99% |
|
||||
| P1 | 97 | 94 | 3 | 0 | 98% |
|
||||
| P2 | 76 | 60 | 16 | 0 | 89% |
|
||||
| P2 | 76 | 61 | 15 | 0 | 90% |
|
||||
|
||||
| Area | Parity |
|
||||
|---|---|
|
||||
|
|
@ -28,7 +28,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
|
|||
| Styles | 100% |
|
||||
| Tables | 98% |
|
||||
| Long documents | 100% |
|
||||
| Interactivity & digital | 90% |
|
||||
| Interactivity & digital | 95% |
|
||||
| Output & production | 90% |
|
||||
| XML & automation | 89% |
|
||||
| View & navigation | 98% |
|
||||
|
|
@ -270,7 +270,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print)
|
|||
| Long documents | Captions | P2 | D |
|
||||
| Interactivity & digital | Hyperlinks panel | P1 | D |
|
||||
| Interactivity & digital | Bookmarks | P1 | D |
|
||||
| Interactivity & digital | Buttons and Forms | P2 | P |
|
||||
| Interactivity & digital | Buttons and Forms | P2 | D |
|
||||
| Interactivity & digital | Object States | P2 | D |
|
||||
| Interactivity & digital | Articles panel | P2 | D |
|
||||
| Interactivity & digital | Object Export Options | P2 | P |
|
||||
|
|
|
|||
Loading…
Reference in New Issue