Skip to main content

browser_commander/browser/
chromiumoxide_adapter.rs

1//! Chromiumoxide-backed [`EngineAdapter`] implementation.
2//!
3//! Wraps a live [`chromiumoxide::Page`] and the owning [`chromiumoxide::Browser`]
4//! so that browser-commander operations (goto, click, fill, evaluate, ...) can
5//! be executed against a real Chromium instance launched via the Chrome
6//! DevTools Protocol.
7
8use std::path::PathBuf;
9use std::sync::Arc;
10use std::time::Duration;
11
12use async_trait::async_trait;
13use chromiumoxide::cdp::browser_protocol::page::PrintToPdfParams;
14use chromiumoxide::{Browser as CdpBrowser, Page as CdpPage};
15use futures::stream::BoxStream;
16use tokio::sync::Mutex;
17use tokio::task::JoinHandle;
18
19use crate::browser::chromiumoxide_trace;
20use crate::browser::media::ColorScheme;
21use crate::browser::storage_state::{restore_script, StorageState};
22use crate::core::engine::{
23    ElementInfo, EngineAdapter, EngineError, EngineType, PdfOptions, TraceEngineEvent,
24};
25
26/// A [`EngineAdapter`] that drives a Chromium browser through
27/// `chromiumoxide`.
28///
29/// Obtained from [`launch_browser`](super::launcher::launch_browser).
30/// The adapter owns the browser handle along with the background task that
31/// services CDP events; dropping the adapter (or calling
32/// [`ChromiumoxidePage::close`]) terminates the browser process.
33pub struct ChromiumoxidePage {
34    page: CdpPage,
35    browser: Arc<Mutex<Option<CdpBrowser>>>,
36    handler_task: Arc<Mutex<Option<JoinHandle<()>>>>,
37    user_data_dir: PathBuf,
38}
39
40impl ChromiumoxidePage {
41    pub(crate) fn new(
42        page: CdpPage,
43        browser: CdpBrowser,
44        handler_task: JoinHandle<()>,
45        user_data_dir: PathBuf,
46    ) -> Self {
47        Self {
48            page,
49            browser: Arc::new(Mutex::new(Some(browser))),
50            handler_task: Arc::new(Mutex::new(Some(handler_task))),
51            user_data_dir,
52        }
53    }
54
55    /// Access the underlying [`chromiumoxide::Page`] for engine-specific
56    /// operations that are not yet covered by the unified API.
57    pub fn raw_page(&self) -> &CdpPage {
58        &self.page
59    }
60
61    /// The resolved user data directory used for this browser session.
62    pub fn user_data_dir(&self) -> &PathBuf {
63        &self.user_data_dir
64    }
65
66    /// Emulate a CSS `prefers-color-scheme` media feature on the live page.
67    pub async fn set_color_scheme(&self, scheme: Option<&ColorScheme>) -> Result<(), EngineError> {
68        use chromiumoxide::cdp::browser_protocol::emulation::{
69            MediaFeature, SetEmulatedMediaParams,
70        };
71
72        let features = match scheme {
73            Some(cs) => vec![MediaFeature {
74                name: "prefers-color-scheme".to_string(),
75                value: cs.as_str().to_string(),
76            }],
77            None => Vec::new(),
78        };
79
80        self.page
81            .execute(SetEmulatedMediaParams::builder().features(features).build())
82            .await
83            .map_err(to_engine_error)?;
84        Ok(())
85    }
86
87    /// Close the browser and terminate the background CDP handler task.
88    ///
89    /// Idempotent: calling more than once is a no-op.
90    pub async fn close(&self) -> Result<(), EngineError> {
91        let browser = self.browser.lock().await.take();
92        if let Some(mut browser) = browser {
93            let _ = browser.close().await;
94            let _ = browser.wait().await;
95        }
96        let handle = self.handler_task.lock().await.take();
97        if let Some(handle) = handle {
98            handle.abort();
99            let _ = handle.await;
100        }
101        Ok(())
102    }
103}
104
105impl std::fmt::Debug for ChromiumoxidePage {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        f.debug_struct("ChromiumoxidePage")
108            .field("user_data_dir", &self.user_data_dir)
109            .finish()
110    }
111}
112
113impl Drop for ChromiumoxidePage {
114    fn drop(&mut self) {
115        // Best-effort cleanup without blocking the current runtime. If the
116        // user did not call `close()` explicitly, abort the background task
117        // so the Tokio runtime can terminate cleanly.
118        if let Ok(mut guard) = self.handler_task.try_lock() {
119            if let Some(handle) = guard.take() {
120                handle.abort();
121            }
122        }
123    }
124}
125
126fn to_engine_error(err: impl std::fmt::Display) -> EngineError {
127    EngineError::Browser(err.to_string())
128}
129
130async fn eval_value(page: &CdpPage, script: String) -> Result<serde_json::Value, EngineError> {
131    let result = page.evaluate(script).await.map_err(to_engine_error)?;
132    Ok(result.value().cloned().unwrap_or(serde_json::Value::Null))
133}
134
135fn js_selector_call(selector: &str, body: &str) -> String {
136    // Build a JS snippet that invokes `body` on the element matched by
137    // `selector`, returning `null` when nothing matches. `body` must be a JS
138    // expression or statement sequence referring to `el` as the element.
139    format!(
140        r#"(() => {{
141            const el = document.querySelector({});
142            if (!el) return null;
143            {}
144        }})()"#,
145        serde_json::to_string(selector).unwrap_or_else(|_| "\"\"".to_string()),
146        body
147    )
148}
149
150#[async_trait]
151impl EngineAdapter for ChromiumoxidePage {
152    fn engine_type(&self) -> EngineType {
153        EngineType::Chromiumoxide
154    }
155
156    async fn url(&self) -> Result<String, EngineError> {
157        let url = self.page.url().await.map_err(to_engine_error)?;
158        Ok(url.unwrap_or_default())
159    }
160
161    async fn goto(&self, url: &str) -> Result<(), EngineError> {
162        self.page.goto(url).await.map_err(to_engine_error)?;
163        self.page
164            .wait_for_navigation()
165            .await
166            .map_err(to_engine_error)?;
167        Ok(())
168    }
169
170    async fn query_selector(&self, selector: &str) -> Result<Option<ElementInfo>, EngineError> {
171        match self.page.find_element(selector).await {
172            Ok(element) => {
173                let tag_name = element
174                    .attribute("tagName")
175                    .await
176                    .map_err(to_engine_error)?
177                    .unwrap_or_else(|| "UNKNOWN".to_string());
178                let text = element.inner_text().await.map_err(to_engine_error)?;
179                let bounding_box = element
180                    .bounding_box()
181                    .await
182                    .ok()
183                    .map(|b| (b.x, b.y, b.width, b.height));
184                Ok(Some(ElementInfo {
185                    tag_name,
186                    text_content: text,
187                    is_visible: bounding_box.is_some(),
188                    is_enabled: true,
189                    bounding_box,
190                }))
191            }
192            Err(_) => Ok(None),
193        }
194    }
195
196    async fn query_selector_all(&self, selector: &str) -> Result<Vec<ElementInfo>, EngineError> {
197        let elements = self
198            .page
199            .find_elements(selector)
200            .await
201            .map_err(to_engine_error)?;
202        let mut infos = Vec::with_capacity(elements.len());
203        for element in elements {
204            let tag_name = element
205                .attribute("tagName")
206                .await
207                .map_err(to_engine_error)?
208                .unwrap_or_else(|| "UNKNOWN".to_string());
209            let text = element.inner_text().await.map_err(to_engine_error)?;
210            let bounding_box = element
211                .bounding_box()
212                .await
213                .ok()
214                .map(|b| (b.x, b.y, b.width, b.height));
215            infos.push(ElementInfo {
216                tag_name,
217                text_content: text,
218                is_visible: bounding_box.is_some(),
219                is_enabled: true,
220                bounding_box,
221            });
222        }
223        Ok(infos)
224    }
225
226    async fn count(&self, selector: &str) -> Result<usize, EngineError> {
227        let script = format!(
228            "document.querySelectorAll({}).length",
229            serde_json::to_string(selector).unwrap_or_else(|_| "\"\"".to_string())
230        );
231        let value = eval_value(&self.page, script).await?;
232        Ok(value.as_u64().unwrap_or(0) as usize)
233    }
234
235    async fn click(&self, selector: &str) -> Result<(), EngineError> {
236        let element = self
237            .page
238            .find_element(selector)
239            .await
240            .map_err(to_engine_error)?;
241        element.click().await.map_err(to_engine_error)?;
242        Ok(())
243    }
244
245    async fn mouse_click(&self, x: f64, y: f64) -> Result<(), EngineError> {
246        // CDP input is delivered at raw viewport coordinates, so nothing is
247        // scrolled into view - which is exactly what `ClickScroll::None` asks
248        // for. The caller has already hit-tested the point.
249        self.page
250            .click(chromiumoxide::layout::Point::new(x, y))
251            .await
252            .map_err(to_engine_error)?;
253        Ok(())
254    }
255
256    async fn fill(&self, selector: &str, text: &str) -> Result<(), EngineError> {
257        // Clear the current value, then type the new text.
258        let clear_script = js_selector_call(
259            selector,
260            "el.focus(); if ('value' in el) { el.value = ''; \
261             el.dispatchEvent(new Event('input', {bubbles:true})); } return true;",
262        );
263        eval_value(&self.page, clear_script).await?;
264        let element = self
265            .page
266            .find_element(selector)
267            .await
268            .map_err(to_engine_error)?;
269        element.click().await.map_err(to_engine_error)?;
270        element.type_str(text).await.map_err(to_engine_error)?;
271        Ok(())
272    }
273
274    async fn type_text(&self, selector: &str, text: &str) -> Result<(), EngineError> {
275        let element = self
276            .page
277            .find_element(selector)
278            .await
279            .map_err(to_engine_error)?;
280        element.click().await.map_err(to_engine_error)?;
281        element.type_str(text).await.map_err(to_engine_error)?;
282        Ok(())
283    }
284
285    async fn text_content(&self, selector: &str) -> Result<Option<String>, EngineError> {
286        match self.page.find_element(selector).await {
287            Ok(element) => element.inner_text().await.map_err(to_engine_error),
288            Err(_) => Ok(None),
289        }
290    }
291
292    async fn input_value(&self, selector: &str) -> Result<Option<String>, EngineError> {
293        let script = js_selector_call(selector, "return 'value' in el ? el.value : null;");
294        let value = eval_value(&self.page, script).await?;
295        Ok(match value {
296            serde_json::Value::Null => None,
297            serde_json::Value::String(s) => Some(s),
298            other => Some(other.to_string()),
299        })
300    }
301
302    async fn get_attribute(
303        &self,
304        selector: &str,
305        attribute: &str,
306    ) -> Result<Option<String>, EngineError> {
307        let script = js_selector_call(
308            selector,
309            &format!(
310                "return el.getAttribute({});",
311                serde_json::to_string(attribute).unwrap_or_else(|_| "\"\"".to_string())
312            ),
313        );
314        let value = eval_value(&self.page, script).await?;
315        Ok(match value {
316            serde_json::Value::Null => None,
317            serde_json::Value::String(s) => Some(s),
318            other => Some(other.to_string()),
319        })
320    }
321
322    async fn is_visible(&self, selector: &str) -> Result<bool, EngineError> {
323        let script = js_selector_call(
324            selector,
325            "const style = window.getComputedStyle(el); \
326             if (style.display === 'none' || style.visibility === 'hidden') return false; \
327             const rect = el.getBoundingClientRect(); \
328             return rect.width > 0 && rect.height > 0;",
329        );
330        let value = eval_value(&self.page, script).await?;
331        Ok(value.as_bool().unwrap_or(false))
332    }
333
334    async fn is_enabled(&self, selector: &str) -> Result<bool, EngineError> {
335        let script = js_selector_call(selector, "return !el.disabled;");
336        let value = eval_value(&self.page, script).await?;
337        Ok(value.as_bool().unwrap_or(false))
338    }
339
340    async fn wait_for_selector(&self, selector: &str, timeout_ms: u64) -> Result<(), EngineError> {
341        let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms);
342        loop {
343            if self.page.find_element(selector).await.is_ok() {
344                return Ok(());
345            }
346            if std::time::Instant::now() >= deadline {
347                return Err(EngineError::Timeout(format!(
348                    "wait_for_selector: {} not found in {}ms",
349                    selector, timeout_ms
350                )));
351            }
352            tokio::time::sleep(Duration::from_millis(100)).await;
353        }
354    }
355
356    async fn scroll_into_view(&self, selector: &str) -> Result<(), EngineError> {
357        let element = self
358            .page
359            .find_element(selector)
360            .await
361            .map_err(to_engine_error)?;
362        element.scroll_into_view().await.map_err(to_engine_error)?;
363        Ok(())
364    }
365
366    async fn evaluate(&self, script: &str) -> Result<serde_json::Value, EngineError> {
367        eval_value(&self.page, script.to_string()).await
368    }
369
370    async fn read_browser_version_page(&self) -> Result<serde_json::Value, EngineError> {
371        let page = {
372            let browser = self.browser.lock().await;
373            browser
374                .as_ref()
375                .ok_or_else(|| EngineError::Browser("browser is closed".into()))?
376                .new_page("chrome://version")
377                .await
378                .map_err(to_engine_error)?
379        };
380        let read = tokio::time::timeout(Duration::from_secs(10), async {
381            loop {
382                let value = eval_value(&page, crate::parity::VERSION_EXPRESSION.into()).await?;
383                if !value.is_null() {
384                    return Ok::<_, EngineError>(value);
385                }
386                tokio::time::sleep(Duration::from_millis(100)).await;
387            }
388        })
389        .await
390        .map_err(|_| EngineError::Timeout("chrome://version did not render".into()));
391        let _ = page.close().await;
392        read?
393    }
394
395    async fn restore_storage_state(&self, value: serde_json::Value) -> Result<(), EngineError> {
396        use chromiumoxide::cdp::browser_protocol::network::CookieParam;
397
398        let state: StorageState = serde_json::from_value(value).map_err(to_engine_error)?;
399        let cookies = state
400            .cookies
401            .iter()
402            .map(|cookie| {
403                let mut value = cookie.clone();
404                if value.get("expires").and_then(serde_json::Value::as_f64) == Some(-1.0) {
405                    value.as_object_mut().map(|cookie| cookie.remove("expires"));
406                }
407                serde_json::from_value::<CookieParam>(value).map_err(to_engine_error)
408            })
409            .collect::<Result<Vec<_>, _>>()?;
410        if !cookies.is_empty() {
411            let browser = self.browser.lock().await;
412            let browser = browser
413                .as_ref()
414                .ok_or_else(|| EngineError::Browser("browser is closed".to_string()))?;
415            browser
416                .set_cookies(cookies)
417                .await
418                .map_err(to_engine_error)?;
419        }
420        if !state.origins.is_empty() {
421            let script = restore_script(&state).map_err(to_engine_error)?;
422            self.page
423                .evaluate_on_new_document(script.clone())
424                .await
425                .map_err(to_engine_error)?;
426            eval_value(&self.page, script).await?;
427        }
428        Ok(())
429    }
430
431    async fn export_storage_state(&self) -> Result<serde_json::Value, EngineError> {
432        let cookies = {
433            let browser = self.browser.lock().await;
434            let browser = browser
435                .as_ref()
436                .ok_or_else(|| EngineError::Browser("browser is closed".to_string()))?;
437            browser.get_cookies().await.map_err(to_engine_error)?
438        };
439        let cookies = cookies
440            .into_iter()
441            .map(|cookie| {
442                let value = serde_json::to_value(cookie).map_err(to_engine_error)?;
443                let mut result = serde_json::Map::new();
444                for key in [
445                    "name", "value", "domain", "path", "expires", "httpOnly", "secure", "sameSite",
446                ] {
447                    if let Some(value) = value.get(key) {
448                        result.insert(key.to_string(), value.clone());
449                    }
450                }
451                Ok(serde_json::Value::Object(result))
452            })
453            .collect::<Result<Vec<_>, EngineError>>()?;
454        let origin = eval_value(
455            &self.page,
456            "(() => { if (location.origin === 'null') return null; \
457             return { origin: location.origin, localStorage: \
458             Array.from({ length: localStorage.length }, (_, index) => { \
459             const name = localStorage.key(index); \
460             return { name, value: localStorage.getItem(name) }; }) }; })()"
461                .to_string(),
462        )
463        .await?;
464        let origins = if origin.is_null() {
465            vec![]
466        } else {
467            vec![origin]
468        };
469        Ok(serde_json::json!({ "cookies": cookies, "origins": origins }))
470    }
471
472    async fn screenshot(&self) -> Result<Vec<u8>, EngineError> {
473        use chromiumoxide::page::ScreenshotParams;
474        self.page
475            .screenshot(ScreenshotParams::builder().build())
476            .await
477            .map_err(to_engine_error)
478    }
479
480    async fn pdf(&self, options: PdfOptions) -> Result<Vec<u8>, EngineError> {
481        let mut builder = PrintToPdfParams::builder().print_background(options.print_background);
482        if let Some(scale) = options.scale {
483            builder = builder.scale(scale);
484        }
485        // Margins are interpreted as inches. browser-commander accepts CSS
486        // margin strings; translate common units so callers can pass
487        // e.g. "1cm" / "0.5in" / "10mm".
488        if let Some(v) = css_length_to_inches(options.margin_top.as_deref()) {
489            builder = builder.margin_top(v);
490        }
491        if let Some(v) = css_length_to_inches(options.margin_bottom.as_deref()) {
492            builder = builder.margin_bottom(v);
493        }
494        if let Some(v) = css_length_to_inches(options.margin_left.as_deref()) {
495            builder = builder.margin_left(v);
496        }
497        if let Some(v) = css_length_to_inches(options.margin_right.as_deref()) {
498            builder = builder.margin_right(v);
499        }
500        if let Some(format) = options.format.as_deref() {
501            if let Some((w, h)) = paper_format_inches(format) {
502                builder = builder.paper_width(w).paper_height(h);
503            }
504        }
505        let params = builder.build();
506        let bytes = self.page.pdf(params).await.map_err(to_engine_error)?;
507        if let Some(path) = options.path {
508            tokio::fs::write(path, &bytes)
509                .await
510                .map_err(|e| EngineError::Browser(format!("failed to write pdf: {}", e)))?;
511        }
512        Ok(bytes)
513    }
514
515    async fn bring_to_front(&self) -> Result<(), EngineError> {
516        self.page.bring_to_front().await.map_err(to_engine_error)?;
517        Ok(())
518    }
519
520    async fn wait_for_navigation(&self, _timeout_ms: u64) -> Result<(), EngineError> {
521        self.page
522            .wait_for_navigation()
523            .await
524            .map_err(to_engine_error)?;
525        Ok(())
526    }
527
528    async fn keyboard_press(&self, key: &str) -> Result<(), EngineError> {
529        use chromiumoxide::cdp::browser_protocol::input::{
530            DispatchKeyEventParams, DispatchKeyEventType,
531        };
532        self.page
533            .execute(
534                DispatchKeyEventParams::builder()
535                    .r#type(DispatchKeyEventType::KeyDown)
536                    .key(key.to_string())
537                    .build()
538                    .map_err(EngineError::Browser)?,
539            )
540            .await
541            .map_err(to_engine_error)?;
542        self.page
543            .execute(
544                DispatchKeyEventParams::builder()
545                    .r#type(DispatchKeyEventType::KeyUp)
546                    .key(key.to_string())
547                    .build()
548                    .map_err(EngineError::Browser)?,
549            )
550            .await
551            .map_err(to_engine_error)?;
552        Ok(())
553    }
554
555    async fn keyboard_type(&self, text: &str) -> Result<(), EngineError> {
556        use chromiumoxide::cdp::browser_protocol::input::{
557            DispatchKeyEventParams, DispatchKeyEventType,
558        };
559        for ch in text.chars() {
560            self.page
561                .execute(
562                    DispatchKeyEventParams::builder()
563                        .r#type(DispatchKeyEventType::Char)
564                        .text(ch.to_string())
565                        .build()
566                        .map_err(EngineError::Browser)?,
567                )
568                .await
569                .map_err(to_engine_error)?;
570        }
571        Ok(())
572    }
573
574    async fn keyboard_down(&self, key: &str) -> Result<(), EngineError> {
575        use chromiumoxide::cdp::browser_protocol::input::{
576            DispatchKeyEventParams, DispatchKeyEventType,
577        };
578        self.page
579            .execute(
580                DispatchKeyEventParams::builder()
581                    .r#type(DispatchKeyEventType::KeyDown)
582                    .key(key.to_string())
583                    .build()
584                    .map_err(EngineError::Browser)?,
585            )
586            .await
587            .map_err(to_engine_error)?;
588        Ok(())
589    }
590
591    async fn keyboard_up(&self, key: &str) -> Result<(), EngineError> {
592        use chromiumoxide::cdp::browser_protocol::input::{
593            DispatchKeyEventParams, DispatchKeyEventType,
594        };
595        self.page
596            .execute(
597                DispatchKeyEventParams::builder()
598                    .r#type(DispatchKeyEventType::KeyUp)
599                    .key(key.to_string())
600                    .build()
601                    .map_err(EngineError::Browser)?,
602            )
603            .await
604            .map_err(to_engine_error)?;
605        Ok(())
606    }
607
608    async fn add_init_script(&self, script: &str) -> Result<Option<String>, EngineError> {
609        chromiumoxide_trace::add_init_script(&self.page, script).await
610    }
611
612    async fn remove_init_script(&self, identifier: &str) -> Result<(), EngineError> {
613        chromiumoxide_trace::remove_init_script(&self.page, identifier).await
614    }
615
616    async fn trace_events(&self) -> Option<BoxStream<'static, TraceEngineEvent>> {
617        chromiumoxide_trace::trace_events(&self.page).await
618    }
619}
620
621/// Convert a CSS length (`"1in"`, `"2cm"`, `"10mm"`, `"72px"`) to inches for
622/// the CDP PDF API. Returns `None` on unknown/missing units.
623fn css_length_to_inches(value: Option<&str>) -> Option<f64> {
624    let raw = value?.trim();
625    if raw.is_empty() {
626        return None;
627    }
628    let (num_str, unit) = raw
629        .find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-'))
630        .map(|idx| raw.split_at(idx))
631        .unwrap_or((raw, "in"));
632    let value: f64 = num_str.parse().ok()?;
633    Some(match unit.trim() {
634        "in" | "" => value,
635        "cm" => value / 2.54,
636        "mm" => value / 25.4,
637        "px" => value / 96.0,
638        "pt" => value / 72.0,
639        _ => return None,
640    })
641}
642
643/// Convert common paper format names into `(width, height)` in inches.
644fn paper_format_inches(name: &str) -> Option<(f64, f64)> {
645    let n = name.trim().to_ascii_lowercase();
646    Some(match n.as_str() {
647        "letter" => (8.5, 11.0),
648        "legal" => (8.5, 14.0),
649        "tabloid" => (11.0, 17.0),
650        "ledger" => (17.0, 11.0),
651        "a0" => (33.1, 46.8),
652        "a1" => (23.4, 33.1),
653        "a2" => (16.54, 23.4),
654        "a3" => (11.7, 16.54),
655        "a4" => (8.27, 11.69),
656        "a5" => (5.83, 8.27),
657        "a6" => (4.13, 5.83),
658        _ => return None,
659    })
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    #[test]
667    fn css_length_to_inches_handles_common_units() {
668        assert_eq!(css_length_to_inches(Some("1in")), Some(1.0));
669        assert_eq!(css_length_to_inches(Some("2.54cm")), Some(1.0));
670        assert_eq!(css_length_to_inches(Some("25.4mm")), Some(1.0));
671        assert_eq!(css_length_to_inches(Some("96px")), Some(1.0));
672        assert_eq!(css_length_to_inches(Some("72pt")), Some(1.0));
673    }
674
675    #[test]
676    fn css_length_to_inches_rejects_unknown_units() {
677        assert_eq!(css_length_to_inches(Some("10xx")), None);
678        assert_eq!(css_length_to_inches(None), None);
679        assert_eq!(css_length_to_inches(Some("")), None);
680    }
681
682    #[test]
683    fn css_length_to_inches_defaults_to_inches() {
684        // A bare number is treated as inches.
685        assert_eq!(css_length_to_inches(Some("1")), Some(1.0));
686    }
687
688    #[test]
689    fn paper_format_inches_known_formats() {
690        assert_eq!(paper_format_inches("A4"), Some((8.27, 11.69)));
691        assert_eq!(paper_format_inches("letter"), Some((8.5, 11.0)));
692        assert_eq!(paper_format_inches("Legal"), Some((8.5, 14.0)));
693    }
694
695    #[test]
696    fn paper_format_inches_unknown() {
697        assert!(paper_format_inches("weird").is_none());
698    }
699}