Files
designcraft/crates/pdf/src/forms.rs
T
aiongg e3d7b44f4c fix(pdf): form fields, buttons and media that don't print stay out of the PDF (#726)
Fields, buttons and video or sound are collected with the page content's
inclusion rule: the item's layer is visible and printable, and neither the
item nor any group around it is hidden or non-printing.
2026-10-13 14:18:35 +00:00

258 lines
11 KiB
Rust

//! Interactive PDF forms (Buttons and Forms: text fields, check boxes, combo and list boxes,
//! signature fields) and media (placed video and sound). krilla writes neither, so they're added
//! as an incremental update: widget and screen annotations on their pages, the page `/Annots`, the
//! catalog `/AcroForm`, and the media files embedded for rendition actions.
use std::fmt::Write as _;
use designcraft_doc::{Document, FieldKind, FormField, Item, MediaOptions};
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);
/// A placed video or sound on an output page: (page index, PDF rectangle, file name, MIME type,
/// bytes, options).
pub(crate) type PlacedMedia = (usize, [f64; 4], String, String, std::sync::Arc<Vec<u8>>, MediaOptions);
/// Every item of the exported pages that goes into the PDF, with its PDF rectangle (origin bottom
/// left).
fn visit(doc: &Document, sheets: &[Sheet], mut f: impl FnMut(usize, [f64; 4], &Item)) {
for (pi, sh) in sheets.iter().enumerate() {
for (xf, it) in crate::export::exported_items(doc, sh.spread) {
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();
f(pi, [r.x0 - sh.media.x0, h - (r.y1 - sh.media.y0), r.x1 - sh.media.x0, h - (r.y0 - sh.media.y0)], it);
}
}
}
/// The form fields of every exported page.
pub(crate) fn collect(doc: &Document, sheets: &[Sheet]) -> Vec<Placed> {
let mut out = Vec::new();
visit(doc, sheets, |pi, r, it| {
if let Some(f) = &it.form_field {
out.push((pi, r, f.clone()));
}
});
out
}
/// The video and sound of every exported page (those whose file is in the document).
pub(crate) fn collect_media(doc: &Document, sheets: &[Sheet]) -> Vec<PlacedMedia> {
let mut out = Vec::new();
visit(doc, sheets, |pi, r, it| {
if let (Some(m), designcraft_doc::Content::Graphic(g)) = (&it.media, &it.content)
&& let Some(a) = doc.assets.get(&g.asset).filter(|a| !a.data.is_empty())
{
out.push((pi, r, a.name.clone(), a.mime.clone(), a.data.clone(), m.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` and `media` 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], media: &[PlacedMedia]) -> Option<Vec<u8>> {
if fields.is_empty() && media.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 mut per_page: Vec<Vec<usize>> = vec![Vec::new(); kids.len()];
// Media: the file embedded, a rendition of it, and a screen annotation that plays it (on
// click, and on page open when asked).
for (page, r, name, mime, data, m) in media {
let Some(&page_ref) = kids.get(*page) else { continue };
let (file, spec, rendition, screen) = (next, next + 1, next + 2, next + 3);
next += 4;
let mut body = format!("<</Type/EmbeddedFile/Subtype/{}/Length {}>>\nstream\n", mime.replace('/', "#2F"), data.len()).into_bytes();
body.extend_from_slice(data);
body.extend_from_slice(b"\nendstream");
put(&mut out, file, &body);
put(&mut out, spec, format!("<</Type/Filespec/F{}/UF{}/EF<</F {file} 0 R>>>>", pdf_string(name), pdf_string(name)).as_bytes());
// Play parameters: player controls, and a repeat count of 0 (forever) when looping.
let params = format!("<</BE<</C {}/RC {}>>>>", m.controls, if m.looping { 0 } else { 1 });
put(
&mut out,
rendition,
format!(
"<</Type/Rendition/S/MR/N{}/C<</Type/MediaClip/S/MCD/N{}/CT{}/D {spec} 0 R/P<</TF(TEMPACCESS)>>>>/P{params}>>",
pdf_string(name),
pdf_string(name),
pdf_string(mime)
)
.as_bytes(),
);
let play = format!("<</S/Rendition/OP 0/AN {screen} 0 R/R {rendition} 0 R>>");
let on_open = if m.play_on_page_load { format!("/AA<</PO{play}>>") } else { String::new() };
put(
&mut out,
screen,
format!(
"<</Type/Annot/Subtype/Screen/Rect[{:.2} {:.2} {:.2} {:.2}]/P {page_ref} 0 R/F 4/T{}/A{play}{on_open}>>",
r[0],
r[1],
r[2],
r[3],
pdf_string(name)
)
.as_bytes(),
);
per_page[*page].push(screen);
}
let font = next;
if !fields.is_empty() {
next += 1;
put(&mut out, font, b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding/WinAnsiEncoding>>");
}
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.
if !all.is_empty() {
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)
}