typst_library/model/
quote.rs

1use crate::diag::SourceResult;
2use crate::engine::Engine;
3use crate::foundations::{
4    cast, elem, Content, Depth, Label, NativeElement, Packed, Show, ShowSet, Smart,
5    StyleChain, Styles, TargetElem,
6};
7use crate::html::{attr, tag, HtmlElem};
8use crate::introspection::Locatable;
9use crate::layout::{
10    Alignment, BlockBody, BlockElem, Em, HElem, PadElem, Spacing, VElem,
11};
12use crate::model::{CitationForm, CiteElem, Destination, LinkElem, LinkTarget};
13use crate::text::{SmartQuoteElem, SmartQuotes, SpaceElem, TextElem};
14
15/// Displays a quote alongside an optional attribution.
16///
17/// # Example
18/// ```example
19/// Plato is often misquoted as the author of #quote[I know that I know
20/// nothing], however, this is a derivation form his original quote:
21///
22/// #set quote(block: true)
23///
24/// #quote(attribution: [Plato])[
25///   ... ἔοικα γοῦν τούτου γε σμικρῷ τινι αὐτῷ τούτῳ σοφώτερος εἶναι, ὅτι
26///   ἃ μὴ οἶδα οὐδὲ οἴομαι εἰδέναι.
27/// ]
28/// #quote(attribution: [from the Henry Cary literal translation of 1897])[
29///   ... I seem, then, in just this little thing to be wiser than this man at
30///   any rate, that what I do not know I do not think I know either.
31/// ]
32/// ```
33///
34/// By default block quotes are padded left and right by `{1em}`, alignment and
35/// padding can be controlled with show rules:
36/// ```example
37/// #set quote(block: true)
38/// #show quote: set align(center)
39/// #show quote: set pad(x: 5em)
40///
41/// #quote[
42///   You cannot pass... I am a servant of the Secret Fire, wielder of the
43///   flame of Anor. You cannot pass. The dark fire will not avail you,
44///   flame of Udûn. Go back to the Shadow! You cannot pass.
45/// ]
46/// ```
47#[elem(Locatable, ShowSet, Show)]
48pub struct QuoteElem {
49    /// Whether this is a block quote.
50    ///
51    /// ```example
52    /// An inline citation would look like
53    /// this: #quote(
54    ///   attribution: [René Descartes]
55    /// )[
56    ///   cogito, ergo sum
57    /// ], and a block equation like this:
58    /// #quote(
59    ///   block: true,
60    ///   attribution: [JFK]
61    /// )[
62    ///   Ich bin ein Berliner.
63    /// ]
64    /// ```
65    block: bool,
66
67    /// Whether double quotes should be added around this quote.
68    ///
69    /// The double quotes used are inferred from the `quotes` property on
70    /// [smartquote], which is affected by the `lang` property on [text].
71    ///
72    /// - `{true}`: Wrap this quote in double quotes.
73    /// - `{false}`: Do not wrap this quote in double quotes.
74    /// - `{auto}`: Infer whether to wrap this quote in double quotes based on
75    ///   the `block` property. If `block` is `{false}`, double quotes are
76    ///   automatically added.
77    ///
78    /// ```example
79    /// #set text(lang: "de")
80    ///
81    /// Ein deutsch-sprechender Author
82    /// zitiert unter umständen JFK:
83    /// #quote[Ich bin ein Berliner.]
84    ///
85    /// #set text(lang: "en")
86    ///
87    /// And an english speaking one may
88    /// translate the quote:
89    /// #quote[I am a Berliner.]
90    /// ```
91    quotes: Smart<bool>,
92
93    /// The attribution of this quote, usually the author or source. Can be a
94    /// label pointing to a bibliography entry or any content. By default only
95    /// displayed for block quotes, but can be changed using a `{show}` rule.
96    ///
97    /// ```example
98    /// #quote(attribution: [René Descartes])[
99    ///   cogito, ergo sum
100    /// ]
101    ///
102    /// #show quote.where(block: false): it => {
103    ///   ["] + h(0pt, weak: true) + it.body + h(0pt, weak: true) + ["]
104    ///   if it.attribution != none [ (#it.attribution)]
105    /// }
106    ///
107    /// #quote(
108    ///   attribution: link("https://typst.app/home")[typst.com]
109    /// )[
110    ///   Compose papers faster
111    /// ]
112    ///
113    /// #set quote(block: true)
114    ///
115    /// #quote(attribution: <tolkien54>)[
116    ///   You cannot pass... I am a servant
117    ///   of the Secret Fire, wielder of the
118    ///   flame of Anor. You cannot pass. The
119    ///   dark fire will not avail you, flame
120    ///   of Udûn. Go back to the Shadow! You
121    ///   cannot pass.
122    /// ]
123    ///
124    /// #bibliography("works.bib", style: "apa")
125    /// ```
126    #[borrowed]
127    attribution: Option<Attribution>,
128
129    /// The quote.
130    #[required]
131    body: Content,
132
133    /// The nesting depth.
134    #[internal]
135    #[fold]
136    #[ghost]
137    depth: Depth,
138}
139
140/// Attribution for a [quote](QuoteElem).
141#[derive(Debug, Clone, PartialEq, Hash)]
142pub enum Attribution {
143    Content(Content),
144    Label(Label),
145}
146
147cast! {
148    Attribution,
149    self => match self {
150        Self::Content(content) => content.into_value(),
151        Self::Label(label) => label.into_value(),
152    },
153    content: Content => Self::Content(content),
154    label: Label => Self::Label(label),
155}
156
157impl Show for Packed<QuoteElem> {
158    #[typst_macros::time(name = "quote", span = self.span())]
159    fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
160        let mut realized = self.body.clone();
161        let block = self.block(styles);
162        let html = TargetElem::target_in(styles).is_html();
163
164        if self.quotes(styles) == Smart::Custom(true) || !block {
165            let quotes = SmartQuotes::get(
166                SmartQuoteElem::quotes_in(styles),
167                TextElem::lang_in(styles),
168                TextElem::region_in(styles),
169                SmartQuoteElem::alternative_in(styles),
170            );
171
172            // Alternate between single and double quotes.
173            let Depth(depth) = QuoteElem::depth_in(styles);
174            let double = depth % 2 == 0;
175
176            if !html {
177                // Add zero-width weak spacing to make the quotes "sticky".
178                let hole = HElem::hole().pack();
179                realized = Content::sequence([hole.clone(), realized, hole]);
180            }
181            realized = Content::sequence([
182                TextElem::packed(quotes.open(double)),
183                realized,
184                TextElem::packed(quotes.close(double)),
185            ])
186            .styled(QuoteElem::set_depth(Depth(1)));
187        }
188
189        let attribution = self.attribution(styles);
190
191        if block {
192            realized = if html {
193                let mut elem = HtmlElem::new(tag::blockquote).with_body(Some(realized));
194                if let Some(Attribution::Content(attribution)) = attribution {
195                    if let Some(link) = attribution.to_packed::<LinkElem>() {
196                        if let LinkTarget::Dest(Destination::Url(url)) = &link.dest {
197                            elem = elem.with_attr(attr::cite, url.clone().into_inner());
198                        }
199                    }
200                }
201                elem.pack()
202            } else {
203                BlockElem::new().with_body(Some(BlockBody::Content(realized))).pack()
204            }
205            .spanned(self.span());
206
207            if let Some(attribution) = attribution.as_ref() {
208                let attribution = match attribution {
209                    Attribution::Content(content) => content.clone(),
210                    Attribution::Label(label) => CiteElem::new(*label)
211                        .with_form(Some(CitationForm::Prose))
212                        .pack()
213                        .spanned(self.span()),
214                };
215                let attribution = Content::sequence([
216                    TextElem::packed('—'),
217                    SpaceElem::shared().clone(),
218                    attribution,
219                ]);
220
221                if html {
222                    realized += attribution;
223                } else {
224                    // Bring the attribution a bit closer to the quote.
225                    let gap = Spacing::Rel(Em::new(0.9).into());
226                    let v = VElem::new(gap).with_weak(true).pack();
227                    realized += v;
228                    realized += BlockElem::new()
229                        .with_body(Some(BlockBody::Content(attribution)))
230                        .pack()
231                        .aligned(Alignment::END);
232                }
233            }
234
235            if !html {
236                realized = PadElem::new(realized).pack();
237            }
238        } else if let Some(Attribution::Label(label)) = attribution {
239            realized += SpaceElem::shared().clone()
240                + CiteElem::new(*label).pack().spanned(self.span());
241        }
242
243        Ok(realized)
244    }
245}
246
247impl ShowSet for Packed<QuoteElem> {
248    fn show_set(&self, styles: StyleChain) -> Styles {
249        let mut out = Styles::new();
250        if self.block(styles) {
251            out.set(PadElem::set_left(Em::new(1.0).into()));
252            out.set(PadElem::set_right(Em::new(1.0).into()));
253            out.set(BlockElem::set_above(Smart::Custom(Em::new(2.4).into())));
254            out.set(BlockElem::set_below(Smart::Custom(Em::new(1.8).into())));
255        }
256        out
257    }
258}