typst_pdf/
lib.rs

1//! Exporting of Typst documents into PDFs.
2
3mod catalog;
4mod color;
5mod color_font;
6mod content;
7mod embed;
8mod extg;
9mod font;
10mod gradient;
11mod image;
12mod named_destination;
13mod outline;
14mod page;
15mod resources;
16mod tiling;
17
18use std::collections::{BTreeMap, HashMap};
19use std::fmt::{self, Debug, Formatter};
20use std::hash::Hash;
21use std::ops::{Deref, DerefMut};
22
23use base64::Engine;
24use ecow::EcoString;
25use pdf_writer::{Chunk, Name, Pdf, Ref, Str, TextStr};
26use serde::{Deserialize, Serialize};
27use typst_library::diag::{bail, SourceResult, StrResult};
28use typst_library::foundations::{Datetime, Smart};
29use typst_library::layout::{Abs, Em, PageRanges, PagedDocument, Transform};
30use typst_library::text::Font;
31use typst_library::visualize::Image;
32use typst_syntax::Span;
33use typst_utils::Deferred;
34
35use crate::catalog::write_catalog;
36use crate::color::{alloc_color_functions_refs, ColorFunctionRefs};
37use crate::color_font::{write_color_fonts, ColorFontSlice};
38use crate::embed::write_embedded_files;
39use crate::extg::{write_graphic_states, ExtGState};
40use crate::font::write_fonts;
41use crate::gradient::{write_gradients, PdfGradient};
42use crate::image::write_images;
43use crate::named_destination::{write_named_destinations, NamedDestinations};
44use crate::page::{alloc_page_refs, traverse_pages, write_page_tree, EncodedPage};
45use crate::resources::{
46    alloc_resources_refs, write_resource_dictionaries, Resources, ResourcesRefs,
47};
48use crate::tiling::{write_tilings, PdfTiling};
49
50/// Export a document into a PDF file.
51///
52/// Returns the raw bytes making up the PDF file.
53#[typst_macros::time(name = "pdf")]
54pub fn pdf(document: &PagedDocument, options: &PdfOptions) -> SourceResult<Vec<u8>> {
55    PdfBuilder::new(document, options)
56        .phase(|builder| builder.run(traverse_pages))?
57        .phase(|builder| {
58            Ok(GlobalRefs {
59                color_functions: builder.run(alloc_color_functions_refs)?,
60                pages: builder.run(alloc_page_refs)?,
61                resources: builder.run(alloc_resources_refs)?,
62            })
63        })?
64        .phase(|builder| {
65            Ok(References {
66                named_destinations: builder.run(write_named_destinations)?,
67                fonts: builder.run(write_fonts)?,
68                color_fonts: builder.run(write_color_fonts)?,
69                images: builder.run(write_images)?,
70                gradients: builder.run(write_gradients)?,
71                tilings: builder.run(write_tilings)?,
72                ext_gs: builder.run(write_graphic_states)?,
73                embedded_files: builder.run(write_embedded_files)?,
74            })
75        })?
76        .phase(|builder| builder.run(write_page_tree))?
77        .phase(|builder| builder.run(write_resource_dictionaries))?
78        .export_with(write_catalog)
79}
80
81/// Settings for PDF export.
82#[derive(Debug, Default)]
83pub struct PdfOptions<'a> {
84    /// If not `Smart::Auto`, shall be a string that uniquely and stably
85    /// identifies the document. It should not change between compilations of
86    /// the same document.  **If you cannot provide such a stable identifier,
87    /// just pass `Smart::Auto` rather than trying to come up with one.** The
88    /// CLI, for example, does not have a well-defined notion of a long-lived
89    /// project and as such just passes `Smart::Auto`.
90    ///
91    /// If an `ident` is given, the hash of it will be used to create a PDF
92    /// document identifier (the identifier itself is not leaked). If `ident` is
93    /// `Auto`, a hash of the document's title and author is used instead (which
94    /// is reasonably unique and stable).
95    pub ident: Smart<&'a str>,
96    /// If not `None`, shall be the creation timestamp of the document. It will
97    /// only be used if `set document(date: ..)` is `auto`.
98    pub timestamp: Option<Timestamp>,
99    /// Specifies which ranges of pages should be exported in the PDF. When
100    /// `None`, all pages should be exported.
101    pub page_ranges: Option<PageRanges>,
102    /// A list of PDF standards that Typst will enforce conformance with.
103    pub standards: PdfStandards,
104}
105
106/// A timestamp with timezone information.
107#[derive(Debug, Clone, Copy)]
108pub struct Timestamp {
109    /// The datetime of the timestamp.
110    pub(crate) datetime: Datetime,
111    /// The timezone of the timestamp.
112    pub(crate) timezone: Timezone,
113}
114
115impl Timestamp {
116    /// Create a new timestamp with a given datetime and UTC suffix.
117    pub fn new_utc(datetime: Datetime) -> Self {
118        Self { datetime, timezone: Timezone::UTC }
119    }
120
121    /// Create a new timestamp with a given datetime, and a local timezone offset.
122    pub fn new_local(datetime: Datetime, whole_minute_offset: i32) -> Option<Self> {
123        let hour_offset = (whole_minute_offset / 60).try_into().ok()?;
124        // Note: the `%` operator in Rust is the remainder operator, not the
125        // modulo operator. The remainder operator can return negative results.
126        // We can simply apply `abs` here because we assume the `minute_offset`
127        // will have the same sign as `hour_offset`.
128        let minute_offset = (whole_minute_offset % 60).abs().try_into().ok()?;
129        match (hour_offset, minute_offset) {
130            // Only accept valid timezone offsets with `-23 <= hours <= 23`,
131            // and `0 <= minutes <= 59`.
132            (-23..=23, 0..=59) => Some(Self {
133                datetime,
134                timezone: Timezone::Local { hour_offset, minute_offset },
135            }),
136            _ => None,
137        }
138    }
139}
140
141/// A timezone.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum Timezone {
144    /// The UTC timezone.
145    UTC,
146    /// The local timezone offset from UTC. And the `minute_offset` will have
147    /// same sign as `hour_offset`.
148    Local { hour_offset: i8, minute_offset: u8 },
149}
150
151/// Encapsulates a list of compatible PDF standards.
152#[derive(Clone)]
153pub struct PdfStandards {
154    /// For now, we simplify to just PDF/A. But it can be more fine-grained in
155    /// the future.
156    pub(crate) pdfa: bool,
157    /// Whether the standard allows for embedding any kind of file into the PDF.
158    /// We disallow this for PDF/A-2, since it only allows embedding
159    /// PDF/A-1 and PDF/A-2 documents.
160    pub(crate) embedded_files: bool,
161    /// Part of the PDF/A standard.
162    pub(crate) pdfa_part: Option<(i32, &'static str)>,
163}
164
165impl PdfStandards {
166    /// Validates a list of PDF standards for compatibility and returns their
167    /// encapsulated representation.
168    pub fn new(list: &[PdfStandard]) -> StrResult<Self> {
169        let a2b = list.contains(&PdfStandard::A_2b);
170        let a3b = list.contains(&PdfStandard::A_3b);
171
172        if a2b && a3b {
173            bail!("PDF cannot conform to A-2B and A-3B at the same time")
174        }
175
176        let pdfa = a2b || a3b;
177        Ok(Self {
178            pdfa,
179            embedded_files: !a2b,
180            pdfa_part: pdfa.then_some((if a2b { 2 } else { 3 }, "B")),
181        })
182    }
183}
184
185impl Debug for PdfStandards {
186    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
187        f.pad("PdfStandards(..)")
188    }
189}
190
191impl Default for PdfStandards {
192    fn default() -> Self {
193        Self { pdfa: false, embedded_files: true, pdfa_part: None }
194    }
195}
196
197/// A PDF standard that Typst can enforce conformance with.
198///
199/// Support for more standards is planned.
200#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
201#[allow(non_camel_case_types)]
202#[non_exhaustive]
203pub enum PdfStandard {
204    /// PDF 1.7.
205    #[serde(rename = "1.7")]
206    V_1_7,
207    /// PDF/A-2b.
208    #[serde(rename = "a-2b")]
209    A_2b,
210    /// PDF/A-3b.
211    #[serde(rename = "a-3b")]
212    A_3b,
213}
214
215/// A struct to build a PDF following a fixed succession of phases.
216///
217/// This type uses generics to represent its current state. `S` (for "state") is
218/// all data that was produced by the previous phases, that is now read-only.
219///
220/// Phase after phase, this state will be transformed. Each phase corresponds to
221/// a call to the [eponymous function](`PdfBuilder::phase`) and produces a new
222/// part of the state, that will be aggregated with all other information, for
223/// consumption during the next phase.
224///
225/// In other words: this struct follows the **typestate pattern**. This prevents
226/// you from using data that is not yet available, at the type level.
227///
228/// Each phase consists of processes, that can read the state of the previous
229/// phases, and construct a part of the new state.
230///
231/// A final step, that has direct access to the global reference allocator and
232/// PDF document, can be run with [`PdfBuilder::export_with`].
233struct PdfBuilder<S> {
234    /// The context that has been accumulated so far.
235    state: S,
236    /// A global bump allocator.
237    alloc: Ref,
238    /// The PDF document that is being written.
239    pdf: Pdf,
240}
241
242/// The initial state: we are exploring the document, collecting all resources
243/// that will be necessary later. The content of the pages is also built during
244/// this phase.
245struct WithDocument<'a> {
246    /// The Typst document that is exported.
247    document: &'a PagedDocument,
248    /// Settings for PDF export.
249    options: &'a PdfOptions<'a>,
250}
251
252/// At this point, resources were listed, but they don't have any reference
253/// associated with them.
254///
255/// This phase allocates some global references.
256struct WithResources<'a> {
257    document: &'a PagedDocument,
258    options: &'a PdfOptions<'a>,
259    /// The content of the pages encoded as PDF content streams.
260    ///
261    /// The pages are at the index corresponding to their page number, but they
262    /// may be `None` if they are not in the range specified by
263    /// `exported_pages`.
264    pages: Vec<Option<EncodedPage>>,
265    /// The PDF resources that are used in the content of the pages.
266    resources: Resources<()>,
267}
268
269/// Global references.
270struct GlobalRefs {
271    /// References for color conversion functions.
272    color_functions: ColorFunctionRefs,
273    /// Reference for pages.
274    ///
275    /// Items of this vector are `None` if the corresponding page is not
276    /// exported.
277    pages: Vec<Option<Ref>>,
278    /// References for the resource dictionaries.
279    resources: ResourcesRefs,
280}
281
282impl<'a> From<(WithDocument<'a>, (Vec<Option<EncodedPage>>, Resources<()>))>
283    for WithResources<'a>
284{
285    fn from(
286        (previous, (pages, resources)): (
287            WithDocument<'a>,
288            (Vec<Option<EncodedPage>>, Resources<()>),
289        ),
290    ) -> Self {
291        Self {
292            document: previous.document,
293            options: previous.options,
294            pages,
295            resources,
296        }
297    }
298}
299
300/// At this point, the resources have been collected, and global references have
301/// been allocated.
302///
303/// We are now writing objects corresponding to resources, and giving them references,
304/// that will be collected in [`References`].
305struct WithGlobalRefs<'a> {
306    document: &'a PagedDocument,
307    options: &'a PdfOptions<'a>,
308    pages: Vec<Option<EncodedPage>>,
309    /// Resources are the same as in previous phases, but each dictionary now has a reference.
310    resources: Resources,
311    /// Global references that were just allocated.
312    globals: GlobalRefs,
313}
314
315impl<'a> From<(WithResources<'a>, GlobalRefs)> for WithGlobalRefs<'a> {
316    fn from((previous, globals): (WithResources<'a>, GlobalRefs)) -> Self {
317        Self {
318            document: previous.document,
319            options: previous.options,
320            pages: previous.pages,
321            resources: previous.resources.with_refs(&globals.resources),
322            globals,
323        }
324    }
325}
326
327/// The references that have been assigned to each object.
328struct References {
329    /// List of named destinations, each with an ID.
330    named_destinations: NamedDestinations,
331    /// The IDs of written fonts.
332    fonts: HashMap<Font, Ref>,
333    /// The IDs of written color fonts.
334    color_fonts: HashMap<ColorFontSlice, Ref>,
335    /// The IDs of written images.
336    images: HashMap<Image, Ref>,
337    /// The IDs of written gradients.
338    gradients: HashMap<PdfGradient, Ref>,
339    /// The IDs of written tilings.
340    tilings: HashMap<PdfTiling, Ref>,
341    /// The IDs of written external graphics states.
342    ext_gs: HashMap<ExtGState, Ref>,
343    /// The names and references for embedded files.
344    embedded_files: BTreeMap<EcoString, Ref>,
345}
346
347/// At this point, the references have been assigned to all resources. The page
348/// tree is going to be written, and given a reference. It is also at this point that
349/// the page contents is actually written.
350struct WithRefs<'a> {
351    document: &'a PagedDocument,
352    options: &'a PdfOptions<'a>,
353    globals: GlobalRefs,
354    pages: Vec<Option<EncodedPage>>,
355    resources: Resources,
356    /// References that were allocated for resources.
357    references: References,
358}
359
360impl<'a> From<(WithGlobalRefs<'a>, References)> for WithRefs<'a> {
361    fn from((previous, references): (WithGlobalRefs<'a>, References)) -> Self {
362        Self {
363            document: previous.document,
364            options: previous.options,
365            globals: previous.globals,
366            pages: previous.pages,
367            resources: previous.resources,
368            references,
369        }
370    }
371}
372
373/// In this phase, we write resource dictionaries.
374///
375/// Each sub-resource gets its own isolated resource dictionary.
376struct WithEverything<'a> {
377    document: &'a PagedDocument,
378    options: &'a PdfOptions<'a>,
379    globals: GlobalRefs,
380    pages: Vec<Option<EncodedPage>>,
381    resources: Resources,
382    references: References,
383    /// Reference that was allocated for the page tree.
384    page_tree_ref: Ref,
385}
386
387impl<'a> From<(WithEverything<'a>, ())> for WithEverything<'a> {
388    fn from((this, _): (WithEverything<'a>, ())) -> Self {
389        this
390    }
391}
392
393impl<'a> From<(WithRefs<'a>, Ref)> for WithEverything<'a> {
394    fn from((previous, page_tree_ref): (WithRefs<'a>, Ref)) -> Self {
395        Self {
396            document: previous.document,
397            options: previous.options,
398            globals: previous.globals,
399            resources: previous.resources,
400            references: previous.references,
401            pages: previous.pages,
402            page_tree_ref,
403        }
404    }
405}
406
407impl<'a> PdfBuilder<WithDocument<'a>> {
408    /// Start building a PDF for a Typst document.
409    fn new(document: &'a PagedDocument, options: &'a PdfOptions<'a>) -> Self {
410        Self {
411            alloc: Ref::new(1),
412            pdf: Pdf::new(),
413            state: WithDocument { document, options },
414        }
415    }
416}
417
418impl<S> PdfBuilder<S> {
419    /// Start a new phase, and save its output in the global state.
420    fn phase<NS, B, O>(mut self, builder: B) -> SourceResult<PdfBuilder<NS>>
421    where
422        // New state
423        NS: From<(S, O)>,
424        // Builder
425        B: Fn(&mut Self) -> SourceResult<O>,
426    {
427        let output = builder(&mut self)?;
428        Ok(PdfBuilder {
429            state: NS::from((self.state, output)),
430            alloc: self.alloc,
431            pdf: self.pdf,
432        })
433    }
434
435    /// Run a step with the current state, merges its output into the PDF file,
436    /// and renumbers any references it returned.
437    fn run<P, O>(&mut self, process: P) -> SourceResult<O>
438    where
439        // Process
440        P: Fn(&S) -> SourceResult<(PdfChunk, O)>,
441        // Output
442        O: Renumber,
443    {
444        let (chunk, mut output) = process(&self.state)?;
445        // Allocate a final reference for each temporary one
446        let allocated = chunk.alloc.get() - TEMPORARY_REFS_START;
447        let offset = TEMPORARY_REFS_START - self.alloc.get();
448
449        // Merge the chunk into the PDF, using the new references
450        chunk.renumber_into(&mut self.pdf, |mut r| {
451            r.renumber(offset);
452
453            r
454        });
455
456        // Also update the references in the output
457        output.renumber(offset);
458
459        self.alloc = Ref::new(self.alloc.get() + allocated);
460
461        Ok(output)
462    }
463
464    /// Finalize the PDF export and returns the buffer representing the
465    /// document.
466    fn export_with<P>(mut self, process: P) -> SourceResult<Vec<u8>>
467    where
468        P: Fn(S, &mut Pdf, &mut Ref) -> SourceResult<()>,
469    {
470        process(self.state, &mut self.pdf, &mut self.alloc)?;
471        Ok(self.pdf.finish())
472    }
473}
474
475/// A reference or collection of references that can be re-numbered,
476/// to become valid in a global scope.
477trait Renumber {
478    /// Renumber this value by shifting any references it contains by `offset`.
479    fn renumber(&mut self, offset: i32);
480}
481
482impl Renumber for () {
483    fn renumber(&mut self, _offset: i32) {}
484}
485
486impl Renumber for Ref {
487    fn renumber(&mut self, offset: i32) {
488        if self.get() >= TEMPORARY_REFS_START {
489            *self = Ref::new(self.get() - offset);
490        }
491    }
492}
493
494impl<R: Renumber> Renumber for Vec<R> {
495    fn renumber(&mut self, offset: i32) {
496        for item in self {
497            item.renumber(offset);
498        }
499    }
500}
501
502impl<T: Eq + Hash, R: Renumber> Renumber for HashMap<T, R> {
503    fn renumber(&mut self, offset: i32) {
504        for v in self.values_mut() {
505            v.renumber(offset);
506        }
507    }
508}
509
510impl<T: Ord, R: Renumber> Renumber for BTreeMap<T, R> {
511    fn renumber(&mut self, offset: i32) {
512        for v in self.values_mut() {
513            v.renumber(offset);
514        }
515    }
516}
517
518impl<R: Renumber> Renumber for Option<R> {
519    fn renumber(&mut self, offset: i32) {
520        if let Some(r) = self {
521            r.renumber(offset)
522        }
523    }
524}
525
526impl<T, R: Renumber> Renumber for (T, R) {
527    fn renumber(&mut self, offset: i32) {
528        self.1.renumber(offset)
529    }
530}
531
532/// A portion of a PDF file.
533struct PdfChunk {
534    /// The actual chunk.
535    chunk: Chunk,
536    /// A local allocator.
537    alloc: Ref,
538}
539
540/// Any reference below that value was already allocated before and
541/// should not be rewritten. Anything above was allocated in the current
542/// chunk, and should be remapped.
543///
544/// This is a constant (large enough to avoid collisions) and not
545/// dependent on self.alloc to allow for better memoization of steps, if
546/// needed in the future.
547const TEMPORARY_REFS_START: i32 = 1_000_000_000;
548
549/// A part of a PDF document.
550impl PdfChunk {
551    /// Start writing a new part of the document.
552    fn new() -> Self {
553        PdfChunk {
554            chunk: Chunk::new(),
555            alloc: Ref::new(TEMPORARY_REFS_START),
556        }
557    }
558
559    /// Allocate a reference that is valid in the context of this chunk.
560    ///
561    /// References allocated with this function should be [renumbered](`Renumber::renumber`)
562    /// before being used in other chunks. This is done automatically if these
563    /// references are stored in the global `PdfBuilder` state.
564    fn alloc(&mut self) -> Ref {
565        self.alloc.bump()
566    }
567}
568
569impl Deref for PdfChunk {
570    type Target = Chunk;
571
572    fn deref(&self) -> &Self::Target {
573        &self.chunk
574    }
575}
576
577impl DerefMut for PdfChunk {
578    fn deref_mut(&mut self) -> &mut Self::Target {
579        &mut self.chunk
580    }
581}
582
583/// Compress data with the DEFLATE algorithm.
584fn deflate(data: &[u8]) -> Vec<u8> {
585    const COMPRESSION_LEVEL: u8 = 6;
586    miniz_oxide::deflate::compress_to_vec_zlib(data, COMPRESSION_LEVEL)
587}
588
589/// Memoized and deferred version of [`deflate`] specialized for a page's content
590/// stream.
591#[comemo::memoize]
592fn deflate_deferred(content: Vec<u8>) -> Deferred<Vec<u8>> {
593    Deferred::new(move || deflate(&content))
594}
595
596/// Create a base64-encoded hash of the value.
597fn hash_base64<T: Hash>(value: &T) -> String {
598    base64::engine::general_purpose::STANDARD
599        .encode(typst_utils::hash128(value).to_be_bytes())
600}
601
602/// Additional methods for [`Abs`].
603trait AbsExt {
604    /// Convert an to a number of points.
605    fn to_f32(self) -> f32;
606}
607
608impl AbsExt for Abs {
609    fn to_f32(self) -> f32 {
610        self.to_pt() as f32
611    }
612}
613
614/// Additional methods for [`Em`].
615trait EmExt {
616    /// Convert an em length to a number of PDF font units.
617    fn to_font_units(self) -> f32;
618}
619
620impl EmExt for Em {
621    fn to_font_units(self) -> f32 {
622        1000.0 * self.get() as f32
623    }
624}
625
626trait NameExt<'a> {
627    /// The maximum length of a name in PDF/A.
628    const PDFA_LIMIT: usize = 127;
629}
630
631impl<'a> NameExt<'a> for Name<'a> {}
632
633/// Additional methods for [`Str`].
634trait StrExt<'a>: Sized {
635    /// The maximum length of a string in PDF/A.
636    const PDFA_LIMIT: usize = 32767;
637
638    /// Create a string that satisfies the constraints of PDF/A.
639    #[allow(unused)]
640    fn trimmed(string: &'a [u8]) -> Self;
641}
642
643impl<'a> StrExt<'a> for Str<'a> {
644    fn trimmed(string: &'a [u8]) -> Self {
645        Self(&string[..string.len().min(Self::PDFA_LIMIT)])
646    }
647}
648
649/// Additional methods for [`TextStr`].
650trait TextStrExt<'a>: Sized {
651    /// The maximum length of a string in PDF/A.
652    const PDFA_LIMIT: usize = Str::PDFA_LIMIT;
653
654    /// Create a text string that satisfies the constraints of PDF/A.
655    fn trimmed(string: &'a str) -> Self;
656}
657
658impl<'a> TextStrExt<'a> for TextStr<'a> {
659    fn trimmed(string: &'a str) -> Self {
660        Self(&string[..string.len().min(Self::PDFA_LIMIT)])
661    }
662}
663
664/// Extension trait for [`Content`](pdf_writer::Content).
665trait ContentExt {
666    fn save_state_checked(&mut self) -> SourceResult<()>;
667}
668
669impl ContentExt for pdf_writer::Content {
670    fn save_state_checked(&mut self) -> SourceResult<()> {
671        self.save_state();
672        if self.state_nesting_depth() > 28 {
673            bail!(
674                Span::detached(),
675                "maximum PDF grouping depth exceeding";
676                hint: "try to avoid excessive nesting of layout containers",
677            );
678        }
679        Ok(())
680    }
681}
682
683/// Convert to an array of floats.
684fn transform_to_array(ts: Transform) -> [f32; 6] {
685    [
686        ts.sx.get() as f32,
687        ts.ky.get() as f32,
688        ts.kx.get() as f32,
689        ts.sy.get() as f32,
690        ts.tx.to_f32(),
691        ts.ty.to_f32(),
692    ]
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698
699    #[test]
700    fn test_timestamp_new_local() {
701        let dummy_datetime = Datetime::from_ymd_hms(2024, 12, 17, 10, 10, 10).unwrap();
702        let test = |whole_minute_offset, expect_timezone| {
703            assert_eq!(
704                Timestamp::new_local(dummy_datetime, whole_minute_offset)
705                    .unwrap()
706                    .timezone,
707                expect_timezone
708            );
709        };
710
711        // Valid timezone offsets
712        test(0, Timezone::Local { hour_offset: 0, minute_offset: 0 });
713        test(480, Timezone::Local { hour_offset: 8, minute_offset: 0 });
714        test(-480, Timezone::Local { hour_offset: -8, minute_offset: 0 });
715        test(330, Timezone::Local { hour_offset: 5, minute_offset: 30 });
716        test(-210, Timezone::Local { hour_offset: -3, minute_offset: 30 });
717        test(-720, Timezone::Local { hour_offset: -12, minute_offset: 0 }); // AoE
718
719        // Corner cases
720        test(315, Timezone::Local { hour_offset: 5, minute_offset: 15 });
721        test(-225, Timezone::Local { hour_offset: -3, minute_offset: 45 });
722        test(1439, Timezone::Local { hour_offset: 23, minute_offset: 59 });
723        test(-1439, Timezone::Local { hour_offset: -23, minute_offset: 59 });
724
725        // Invalid timezone offsets
726        assert!(Timestamp::new_local(dummy_datetime, 1440).is_none());
727        assert!(Timestamp::new_local(dummy_datetime, -1440).is_none());
728        assert!(Timestamp::new_local(dummy_datetime, i32::MAX).is_none());
729        assert!(Timestamp::new_local(dummy_datetime, i32::MIN).is_none());
730    }
731}