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:
Brandon Thomas 2026-10-03 10:31:58 -04:00
parent 33a56dfe32
commit dc2114be26
9 changed files with 432 additions and 4 deletions

View File

@ -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.

View File

@ -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(),

View File

@ -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());
}
}

View File

@ -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) {

192
crates/pdf/src/forms.rs Normal file
View File

@ -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)
}

View File

@ -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};

View File

@ -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);

View File

@ -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

View File

@ -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 |