Skip to main content

moss_core/render/
pdf.rs

1//! PDF embed synthesizer.
2//!
3//! Receives a [`TitleParams`] (Stage 2 dispatcher already parsed it), the
4//! source URL, and an [`AssetSnapshot`]. Emits the final `<object>`-based
5//! PDF embed HTML — preserving the exact byte shape moss-core's `PdfRenderer`
6//! used to emit before Phase 0's Stage 1 migration.
7//!
8//! Pre-Phase-0 byte shape (extracted from `crates/moss-core/src/resolve/
9//! embed_renderer.rs` at commit `689d975e9^`):
10//!
11//! ```text
12//! <object class="moss-embed" data-type="pdf"{data-width?} type="application/pdf"
13//!         data="{src}"{width?}{height?}>
14//!   <a href="{src}">Download {stem}</a>
15//! </object>
16//! ```
17//!
18//! The `<object>` element (rather than `<iframe>`) was chosen by the original
19//! `PdfRenderer` for keyboard-navigation parity and an inline download
20//! fallback in browsers that can't render PDFs natively.
21
22use crate::asset_snapshot::AssetSnapshot;
23use crate::resolve::embed_renderer::{file_stem, html_escape_attr};
24use crate::resolve::title_params::TitleParams;
25
26/// Synthesize PDF embed HTML for `Tag::Link` with `moss:kind=pdf` title.
27///
28/// Params consumed (all optional except `kind=pdf` which the Stage 2
29/// dispatcher already checked):
30///
31/// - `data-width` — canonical wrapper width (`body|wide|page|screen`), spec § P9
32/// - `width` — HTML attribute pixel/percent/vh width (from `|WxH` alias sugar)
33/// - `height` — HTML attribute pixel/percent/vh height (from `|WxH` alias sugar)
34/// - `query` — URL query (appended as `?query` after the path)
35/// - `fragment` — URL fragment (appended as `#fragment` — typically `page=5`)
36///
37/// `assets` is currently unused for PDF (no LQIP or dimension concerns) but
38/// kept in the signature for symmetry with the other synthesizers and
39/// future-proofing.
40#[allow(unused_variables)]
41pub fn synthesize_pdf_html(
42    params: &TitleParams,
43    src: &str,
44    assets: &AssetSnapshot,
45) -> String {
46    // Reconstruct the embed URL from src + query + fragment.
47    // Order is URL-canonical: path?query#fragment.
48    let mut data_url = String::from(src);
49    if let Some(q) = params.get("query") {
50        data_url.push('?');
51        data_url.push_str(q);
52    }
53    if let Some(f) = params.get("fragment") {
54        data_url.push('#');
55        data_url.push_str(f);
56    }
57
58    let data_width_attr = match params.get("data-width") {
59        Some(w) => format!(r#" data-width="{}""#, html_escape_attr(w)),
60        None => String::new(),
61    };
62
63    let html_width_attr = match params.get("width") {
64        Some(w) => format!(r#" width="{}""#, html_escape_attr(w)),
65        None => String::new(),
66    };
67
68    let html_height_attr = match params.get("height") {
69        Some(h) => format!(r#" height="{}""#, html_escape_attr(h)),
70        None => String::new(),
71    };
72
73    let name = file_stem(src);
74
75    // <object> with inline download fallback for browsers that can't render PDFs.
76    // Attribute order matches the pre-Phase-0 PdfRenderer byte shape exactly.
77    format!(
78        "<object class=\"moss-embed\" data-type=\"pdf\"{} type=\"application/pdf\" data=\"{}\"{}{}><a href=\"{}\">Download {}</a></object>",
79        data_width_attr,
80        html_escape_attr(&data_url),
81        html_width_attr,
82        html_height_attr,
83        html_escape_attr(src),
84        html_escape_attr(&name),
85    )
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    fn empty_snapshot() -> AssetSnapshot {
93        AssetSnapshot::new()
94    }
95
96    fn params_with(kvs: &[(&str, &str)]) -> TitleParams {
97        let mut p = TitleParams::default();
98        for (k, v) in kvs {
99            p.insert(*k, *v);
100        }
101        p
102    }
103
104    #[test]
105    fn pdf_basic_shape() {
106        let p = params_with(&[("kind", "pdf")]);
107        let out = synthesize_pdf_html(&p, "doc.pdf", &empty_snapshot());
108        // Pre-Phase-0 byte shape: <object class="moss-embed" data-type="pdf" ...>
109        assert!(
110            out.contains(r#"<object class="moss-embed" data-type="pdf""#),
111            "got: {}",
112            out
113        );
114        assert!(
115            out.contains(r#"type="application/pdf""#),
116            "got: {}",
117            out
118        );
119        // src is bound via `data="..."` on <object>, not `src=`.
120        assert!(out.contains(r#"data="doc.pdf""#), "got: {}", out);
121        // Inline <a> download fallback for browsers that can't render PDFs.
122        assert!(
123            out.contains(r#"<a href="doc.pdf">Download doc</a>"#),
124            "got: {}",
125            out
126        );
127    }
128
129    #[test]
130    fn pdf_with_height_param() {
131        let p = params_with(&[("kind", "pdf"), ("width", "400px"), ("height", "800px")]);
132        let out = synthesize_pdf_html(&p, "doc.pdf", &empty_snapshot());
133        assert!(out.contains(r#"width="400px""#), "got: {}", out);
134        assert!(out.contains(r#"height="800px""#), "got: {}", out);
135    }
136
137    #[test]
138    fn pdf_escapes_url_specials() {
139        let p = params_with(&[("kind", "pdf")]);
140        let out = synthesize_pdf_html(
141            &p,
142            r#"file with "quotes".pdf"#,
143            &empty_snapshot(),
144        );
145        // The `"` in the URL must be HTML-attribute-escaped so the emitted
146        // `data="..."` attribute parses correctly. Escape lands in three
147        // places: `data=` URL, `<a href=` URL, and the stem inside the
148        // "Download ..." link text.
149        assert!(
150            out.contains(r#"data="file with &quot;quotes&quot;.pdf""#),
151            "expected escaped data= attr, got: {}",
152            out
153        );
154        assert!(
155            out.contains(r#"href="file with &quot;quotes&quot;.pdf""#),
156            "expected escaped href= attr, got: {}",
157            out
158        );
159        // No raw, unescaped quote anywhere in attribute-value position.
160        assert!(
161            !out.contains(r#"data="file with ""#),
162            "raw quote leaked into data= attr, got: {}",
163            out
164        );
165    }
166
167    #[test]
168    fn pdf_query_and_fragment_appended_to_url() {
169        // Standard PDF.js viewer convention: #page=5 jumps to page 5.
170        let p = params_with(&[("kind", "pdf"), ("fragment", "page=5")]);
171        let out = synthesize_pdf_html(&p, "report.pdf", &empty_snapshot());
172        assert!(out.contains(r#"data="report.pdf#page=5""#), "got: {}", out);
173        // The download fallback link points at the bare URL (no #page) so
174        // it's a download, not a viewer-navigation request.
175        assert!(out.contains(r#"<a href="report.pdf">"#), "got: {}", out);
176    }
177
178    #[test]
179    fn pdf_query_before_fragment() {
180        let p = params_with(&[
181            ("kind", "pdf"),
182            ("query", "version=2"),
183            ("fragment", "page=5"),
184        ]);
185        let out = synthesize_pdf_html(&p, "report.pdf", &empty_snapshot());
186        // URL-canonical order: path?query#fragment.
187        assert!(
188            out.contains(r#"data="report.pdf?version=2#page=5""#),
189            "got: {}",
190            out
191        );
192    }
193
194    #[test]
195    fn pdf_data_width_attr_when_present() {
196        let p = params_with(&[("kind", "pdf"), ("data-width", "wide")]);
197        let out = synthesize_pdf_html(&p, "doc.pdf", &empty_snapshot());
198        assert!(out.contains(r#"data-width="wide""#), "got: {}", out);
199    }
200
201    #[test]
202    fn pdf_no_data_width_when_absent() {
203        let p = params_with(&[("kind", "pdf")]);
204        let out = synthesize_pdf_html(&p, "doc.pdf", &empty_snapshot());
205        // Themes target `:not([data-width])`; the attr must be absent by default.
206        assert!(!out.contains("data-width"), "got: {}", out);
207    }
208
209    #[test]
210    fn pdf_stem_strips_directory_and_extension() {
211        let p = params_with(&[("kind", "pdf")]);
212        let out = synthesize_pdf_html(&p, "papers/2024/big-report.pdf", &empty_snapshot());
213        // Download fallback uses the file stem, not the full path.
214        assert!(out.contains("Download big-report</a>"), "got: {}", out);
215    }
216}