From dc2114be26e109b65e6b46e569e72177098edcda Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 10:31:58 -0400 Subject: [PATCH] =?UTF-8?q?I:=20PDF=20form=20fields=20=E2=80=94=20text,=20?= =?UTF-8?q?check=20box,=20combo,=20list,=20signature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/doc/src/item.rs | 36 +++++ crates/engine/src/cmd/buttons.rs | 95 +++++++++++ crates/pdf/src/export.rs | 12 ++ crates/pdf/src/forms.rs | 192 +++++++++++++++++++++++ crates/pdf/src/lib.rs | 1 + crates/render/src/lib.rs | 22 +++ crates/ui-egui/src/panels/interactive.rs | 70 +++++++++ docs/parity.md | 6 +- 9 files changed, 432 insertions(+), 4 deletions(-) create mode 100644 crates/pdf/src/forms.rs diff --git a/ROADMAP.md b/ROADMAP.md index 26f33ae..4cc2162 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index e44b120..291e84a 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -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, + #[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, + /// Buttons and Forms: an interactive PDF form field drawn in this frame. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub form_field: Option, /// 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(), diff --git a/crates/engine/src/cmd/buttons.rs b/crates/engine/src/cmd/buttons.rs index 5c23cc5..ef4301c 100644 --- a/crates/engine/src/cmd/buttons.rs +++ b/crates/engine/src/cmd/buttons.rs @@ -50,6 +50,90 @@ pub fn specs() -> Vec { 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 = + 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< 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) { diff --git a/crates/pdf/src/forms.rs b/crates/pdf/src/forms.rs new file mode 100644 index 0000000..d0c2544 --- /dev/null +++ b/crates/pdf/src/forms.rs @@ -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 { + 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)> = 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("'); + 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> { + 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 = kids_str.split_whitespace().filter_map(|t| t.parse().ok()).collect(); + let kids: Vec = 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, 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"<>"); + let mut per_page: Vec> = 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<>/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!("<>\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<>>>>>") + } + FieldKind::ComboBox | FieldKind::ListBox => { + let flags = req | if f.kind == FieldKind::ComboBox { 131072 } else { 0 }; + let opts: Vec = 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 = all.iter().map(|i| format!("{i} 0 R")).collect(); + let close = cat.rfind(">>")?; + let acro = format!("/AcroForm<>>>>>", 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<>\nstartxref\n{xref_at}\n%%EOF\n"); + out.extend_from_slice(x.as_bytes()); + Some(out) +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index e0c0ded..b18ed4b 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -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}; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a1e6396..4cf5c53 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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); diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index 34d002f..58a49fa 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -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 = 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 diff --git a/docs/parity.md b/docs/parity.md index 58e629a..7cd069a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 |