pdfrum_form/script/transcript.rs
1//! What a script asked the host to do, as data the host reads.
2//!
3//! **A value, not a trait.** The script does not need the host's answer to
4//! continue, so the line goes on a list and the host reads the list on its own
5//! schedule.
6//!
7//! It is also the conformance comparison surface: a golden run compares the
8//! oracle's stdout, line-wise and byte-exact, against these lines rendered.
9//! [`TranscriptLine::render`] is that contract rather than a debugging
10//! convenience.
11
12use std::fmt::Write as _;
13
14/// One thing a script asked the host to do.
15///
16/// Ten shapes, because the goldens carry ten. Each variant's `render` output
17/// is quoted from the callback that produces it.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum TranscriptLine {
20 /// `app.alert` — 1933 of the 2004 golden lines.
21 ///
22 /// The message is printed with a conditional decoration: see
23 /// [`TranscriptLine::render`].
24 Alert {
25 /// `cTitle`, defaulting to the literal `"Alert"`.
26 title: String,
27 /// `cMsg`. An array argument is joined first — see `app.alert`.
28 message: String,
29 /// `nIcon`, defaulting to 0.
30 icon: i32,
31 /// `nType`, defaulting to 0.
32 button: i32,
33 },
34 /// `app.beep(n)` — `ExampleAppBeep`.
35 Beep(i32),
36 /// `app.response(...)` — `ExampleAppResponse`. The host answers with the
37 /// empty string; nothing is prompted.
38 Response {
39 /// `cQuestion`.
40 question: String,
41 /// `cTitle`.
42 title: String,
43 /// `cDefault`.
44 default_value: String,
45 /// `cLabel`.
46 label: String,
47 /// `bPassword`.
48 password: bool,
49 },
50 /// `app.mailMsg` / `Doc.mailDoc` / `Doc.mailForm` — `ExampleDocMail`.
51 /// **Nothing is sent**: no network, no MAPI, no process spawn.
52 MailMsg {
53 /// `bUI`.
54 ui: bool,
55 /// `To`.
56 to: String,
57 /// `cc`.
58 cc: String,
59 /// `bcc`.
60 bcc: String,
61 /// `cSubject`.
62 subject: String,
63 /// `cMsg`.
64 body: String,
65 },
66 /// `Doc.print` — `ExampleDocPrint`. **Nothing is printed.**
67 Print {
68 /// `bUI`.
69 ui: bool,
70 /// `nStart`.
71 start: i32,
72 /// `nEnd`.
73 end: i32,
74 /// `bSilent`.
75 silent: bool,
76 /// `bShrinkToFit`.
77 shrink_to_fit: bool,
78 /// `bPrintAsImage`.
79 print_as_image: bool,
80 /// `bReverse`.
81 reverse: bool,
82 /// `bAnnotations`.
83 annotations: bool,
84 },
85 /// `Doc.submitForm` — `ExampleDocSubmitForm`.
86 ///
87 /// **The most dangerous entry point in the object model, and the one
88 /// where "hand back the data, do not act on it" matters most.** No request
89 /// is made; the URL and the bytes come back here for a host to decide
90 /// about.
91 SubmitForm {
92 /// Where the script wanted to post.
93 url: String,
94 /// What it wanted to post.
95 data: Vec<u8>,
96 },
97 /// `Doc.gotoNamedDest` and page navigation — `ExampleDocGotoPage`.
98 GotoPage(i32),
99 /// A named action fired from a non-JavaScript callback —
100 /// `ExampleNamedAction`. `named_action.in` is its one fixture.
101 NamedAction(String),
102 /// `console.println`. **The oracle discards it**, so this variant renders
103 /// to nothing and is carried only so a host can see what a script
104 /// logged.
105 ConsolePrintln(String),
106 /// An `AF*` function's own alert, which is not routed through `app.alert`.
107 ///
108 /// The **function's own name** is the title, always with `icon = 3` and
109 /// `button = 0` — so these render in the decorated form and appear
110 /// *unprefixed by `Alert:`*, interleaved with the plain lines. Getting
111 /// that interleaving right is a scoring requirement.
112 FunctionAlert {
113 /// The function's own name, e.g. `AFNumber_Keystroke`.
114 caller: String,
115 /// The message.
116 message: String,
117 },
118}
119
120/// The literal `cTitle` an `app.alert` with no title takes.
121pub const DEFAULT_ALERT_TITLE: &str = "Alert";
122
123impl TranscriptLine {
124 /// The line the oracle would print, without its newline.
125 ///
126 /// `None` for a shape that prints nothing — [`TranscriptLine::ConsolePrintln`]
127 /// alone, because the oracle's `console` methods are empty.
128 ///
129 /// # The alert decoration is conditional, and it is exact
130 ///
131 /// Three forms, chosen by what differs from the default:
132 ///
133 /// | condition | line |
134 /// |---|---|
135 /// | default title, icon and type | `Alert: <msg>` |
136 /// | non-default title only | `<title>: <msg>` |
137 /// | non-default icon **or** type | `<title>[icon=N,type=N]: <msg>` |
138 ///
139 /// An `AF*` alert always takes the third form with `icon=3,type=0`, so it
140 /// reads `AFNumber_Keystroke[icon=3,type=0]: The input value is invalid.`
141 /// with no `Alert:` prefix.
142 #[must_use]
143 pub fn render(&self) -> Option<String> {
144 let mut out = String::new();
145 match self {
146 TranscriptLine::Alert {
147 title,
148 message,
149 icon,
150 button,
151 } => {
152 if *icon != 0 || *button != 0 {
153 let _ = write!(out, "{title}[icon={icon},type={button}]: {message}");
154 } else {
155 let _ = write!(out, "{title}: {message}");
156 }
157 }
158 TranscriptLine::FunctionAlert { caller, message } => {
159 // `AlertIfPossible` is always icon 3, type 0.
160 let _ = write!(out, "{caller}[icon=3,type=0]: {message}");
161 }
162 TranscriptLine::Beep(kind) => {
163 let _ = write!(out, "BEEP!!! {kind}");
164 }
165 TranscriptLine::Response {
166 question,
167 title,
168 default_value,
169 label,
170 password,
171 } => {
172 // `"%ls: %ls, …"` — **title first, then the question**, and
173 // `length` is the *buffer* the host offered rather than
174 // anything the script said. `pdfium_test` always passes 2048.
175 let _ = write!(
176 out,
177 "{title}: {question}, defaultValue={default_value}, label={label}, \
178 isPassword={}, length=2048",
179 u8::from(*password)
180 );
181 }
182 TranscriptLine::MailMsg {
183 ui,
184 to,
185 cc,
186 bcc,
187 subject,
188 body,
189 } => {
190 let _ = write!(
191 out,
192 "Mail Msg: {}, to={to}, cc={cc}, bcc={bcc}, subject={subject}, body={body}",
193 u8::from(*ui)
194 );
195 }
196 TranscriptLine::Print {
197 ui,
198 start,
199 end,
200 silent,
201 shrink_to_fit,
202 print_as_image,
203 reverse,
204 annotations,
205 } => {
206 let _ = write!(
207 out,
208 "Doc Print: {}, {start}, {end}, {}, {}, {}, {}, {}",
209 u8::from(*ui),
210 u8::from(*silent),
211 u8::from(*shrink_to_fit),
212 u8::from(*print_as_image),
213 u8::from(*reverse),
214 u8::from(*annotations)
215 );
216 }
217 TranscriptLine::SubmitForm { url, data } => {
218 // Two lines: the header, then **every byte on one line**,
219 // each as `" %02x"` — a *leading* space, no wrapping and no
220 // indent, however long the form is. `submitform_expected.txt`
221 // carries a 174-byte dump as a single line.
222 let _ = writeln!(
223 out,
224 "Doc Submit Form: url={url} + {} data bytes:",
225 data.len()
226 );
227 for byte in data {
228 let _ = write!(out, " {byte:02x}");
229 }
230 }
231 TranscriptLine::GotoPage(page) => {
232 let _ = write!(out, "Goto Page: {page}");
233 }
234 TranscriptLine::NamedAction(name) => {
235 let _ = write!(out, "Execute named action: {name}");
236 }
237 // Upstream's `console` is four empty functions.
238 TranscriptLine::ConsolePrintln(_) => return None,
239 }
240 Some(out)
241 }
242
243 /// A default-decoration alert: the shape 1922 of the 2004 golden lines
244 /// take.
245 #[must_use]
246 pub fn alert(message: impl Into<String>) -> TranscriptLine {
247 TranscriptLine::Alert {
248 title: DEFAULT_ALERT_TITLE.to_string(),
249 message: message.into(),
250 icon: 0,
251 button: 0,
252 }
253 }
254}
255
256/// Renders a whole transcript the way the oracle writes stdout: one line
257/// each, newline-terminated, lines that print nothing omitted.
258#[must_use]
259pub fn render(lines: &[TranscriptLine]) -> String {
260 let mut out = String::new();
261 for line in lines {
262 if let Some(rendered) = line.render() {
263 out.push_str(&rendered);
264 out.push('\n');
265 }
266 }
267 out
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 /// The default form, which is 1922 of the 2004 golden lines.
275 #[test]
276 fn a_default_alert_is_prefixed_with_the_literal_alert() {
277 assert_eq!(
278 TranscriptLine::alert("Hello").render().as_deref(),
279 Some("Alert: Hello")
280 );
281 }
282
283 /// A non-default title replaces the prefix rather than adding to it.
284 #[test]
285 fn a_titled_alert_prints_its_own_title() {
286 let line = TranscriptLine::Alert {
287 title: "Warning".to_string(),
288 message: "careful".to_string(),
289 icon: 0,
290 button: 0,
291 };
292 assert_eq!(line.render().as_deref(), Some("Warning: careful"));
293 }
294
295 /// A non-default icon **or** type adds the bracket, and both are printed
296 /// whichever one differs.
297 #[test]
298 fn a_decorated_alert_prints_both_numbers() {
299 let with_icon = TranscriptLine::Alert {
300 title: "Alert".to_string(),
301 message: "m".to_string(),
302 icon: 3,
303 button: 0,
304 };
305 assert_eq!(
306 with_icon.render().as_deref(),
307 Some("Alert[icon=3,type=0]: m")
308 );
309
310 let with_type = TranscriptLine::Alert {
311 title: "Alert".to_string(),
312 message: "m".to_string(),
313 icon: 0,
314 button: 2,
315 };
316 assert_eq!(
317 with_type.render().as_deref(),
318 Some("Alert[icon=0,type=2]: m")
319 );
320 }
321
322 /// An `AF*` alert takes the third form with the function's own name and
323 /// no `Alert:` prefix — `public_methods_expected.txt`'s interleaving.
324 #[test]
325 fn a_function_alert_is_titled_by_its_caller() {
326 let line = TranscriptLine::FunctionAlert {
327 caller: "AFNumber_Keystroke".to_string(),
328 message: "The input value is invalid.".to_string(),
329 };
330 assert_eq!(
331 line.render().as_deref(),
332 Some("AFNumber_Keystroke[icon=3,type=0]: The input value is invalid.")
333 );
334 }
335
336 /// `console.println` prints nothing, because upstream's four console
337 /// methods are empty — a golden expecting output would pin behaviour
338 /// PDFium does not have.
339 #[test]
340 fn console_output_is_discarded_as_upstream_discards_it() {
341 assert_eq!(
342 TranscriptLine::ConsolePrintln("x".to_string()).render(),
343 None
344 );
345 // And it is omitted from the rendered transcript rather than leaving
346 // a blank line, which a line-wise diff would see.
347 let rendered = render(&[
348 TranscriptLine::alert("one"),
349 TranscriptLine::ConsolePrintln("hidden".to_string()),
350 TranscriptLine::alert("two"),
351 ]);
352 assert_eq!(rendered, "Alert: one\nAlert: two\n");
353 }
354
355 #[test]
356 fn the_other_seven_shapes_render_their_golden_form() {
357 assert_eq!(
358 TranscriptLine::Beep(2).render().as_deref(),
359 Some("BEEP!!! 2")
360 );
361 assert_eq!(
362 TranscriptLine::GotoPage(4).render().as_deref(),
363 Some("Goto Page: 4")
364 );
365 assert_eq!(
366 TranscriptLine::NamedAction("Print".to_string())
367 .render()
368 .as_deref(),
369 Some("Execute named action: Print")
370 );
371 assert_eq!(
372 TranscriptLine::Response {
373 question: "q".to_string(),
374 title: "t".to_string(),
375 default_value: String::new(),
376 label: String::new(),
377 password: false,
378 }
379 .render()
380 .as_deref(),
381 Some("t: q, defaultValue=, label=, isPassword=0, length=2048")
382 );
383 assert_eq!(
384 TranscriptLine::MailMsg {
385 ui: true,
386 to: String::new(),
387 cc: String::new(),
388 bcc: String::new(),
389 subject: String::new(),
390 body: String::new(),
391 }
392 .render()
393 .as_deref(),
394 Some("Mail Msg: 1, to=, cc=, bcc=, subject=, body=")
395 );
396 assert_eq!(
397 TranscriptLine::Print {
398 ui: false,
399 start: 0,
400 end: 0,
401 silent: false,
402 shrink_to_fit: false,
403 print_as_image: false,
404 reverse: false,
405 annotations: false,
406 }
407 .render()
408 .as_deref(),
409 Some("Doc Print: 0, 0, 0, 0, 0, 0, 0, 0")
410 );
411 }
412
413 /// **The dump does not wrap.** Every byte goes on one line as `" %02x"`,
414 /// however long the form is — `submitform_expected.txt` carries 174 bytes
415 /// as a single line, and a reimplementation that wrapped at sixteen would
416 /// fail a byte-exact diff on both of the two fixtures that reach it.
417 #[test]
418 fn a_submitted_form_dumps_every_byte_on_one_line() {
419 let line = TranscriptLine::SubmitForm {
420 url: "https://example.com".to_string(),
421 // `submitform.in`'s shorter dump, verbatim: "name=Tralfaz&age=12".
422 data: b"name=Tralfaz&age=12".to_vec(),
423 };
424 let rendered = line.render().unwrap_or_default();
425 let mut lines = rendered.lines();
426 assert_eq!(
427 lines.next(),
428 Some("Doc Submit Form: url=https://example.com + 19 data bytes:")
429 );
430 assert_eq!(
431 lines.next(),
432 Some(" 6e 61 6d 65 3d 54 72 61 6c 66 61 7a 26 61 67 65 3d 31 32")
433 );
434 assert_eq!(lines.next(), None);
435 }
436}