Skip to main content

excalidraw_render/
request.rs

1use std::collections::BTreeMap;
2use std::error::Error;
3use std::fmt;
4
5use serde::Serialize;
6
7use crate::color::ColorLowering;
8use crate::{DiagnosticDetails, ErrorCode, ErrorDescriptor, ResourceLimits};
9
10/// Indicates that a render request could not be validated.
11///
12/// [`Error::source`] is always `None`: validation has no underlying cause
13/// beyond the stable code and message.
14#[derive(Clone, Debug)]
15pub struct RequestError {
16    code: ErrorCode,
17    message: Box<str>,
18    /// Always [`DiagnosticDetails::RequestValue`].
19    details: Box<DiagnosticDetails>,
20}
21
22impl RequestError {
23    pub(crate) fn new(code: ErrorCode, message: impl Into<Box<str>>, field: &'static str) -> Self {
24        Self::with_value(code, message, field, None, None)
25    }
26
27    fn for_option(
28        code: ErrorCode,
29        message: impl Into<Box<str>>,
30        field: &'static str,
31        option: &'static str,
32    ) -> Self {
33        Self::with_value(code, message, field, None, Some(option))
34    }
35
36    fn for_asset(
37        code: ErrorCode,
38        message: impl Into<Box<str>>,
39        asset_id: String,
40        option: Option<&'static str>,
41    ) -> Self {
42        Self::with_value(code, message, "supplemental_assets", Some(asset_id), option)
43    }
44
45    fn with_value(
46        code: ErrorCode,
47        message: impl Into<Box<str>>,
48        field: &'static str,
49        asset_id: Option<String>,
50        option: Option<&'static str>,
51    ) -> Self {
52        Self {
53            code,
54            message: message.into(),
55            details: Box::new(DiagnosticDetails::RequestValue {
56                field,
57                asset_id,
58                option,
59            }),
60        }
61    }
62
63    /// The rejected field, supplemental asset ID and format option.
64    const fn request_value(&self) -> (&'static str, Option<&String>, Option<&'static str>) {
65        match &*self.details {
66            DiagnosticDetails::RequestValue {
67                field,
68                asset_id,
69                option,
70            } => (field, asset_id.as_ref(), *option),
71            _ => unreachable!(),
72        }
73    }
74
75    /// Returns the stable machine-readable request error code.
76    pub const fn code(&self) -> ErrorCode {
77        self.code
78    }
79
80    /// Returns the rejected canonical request field.
81    pub const fn field(&self) -> &str {
82        self.request_value().0
83    }
84
85    /// Returns the applicable supplemental asset ID.
86    pub fn asset_id(&self) -> Option<&str> {
87        self.request_value().1.map(String::as_str)
88    }
89
90    /// Returns the applicable format-option name.
91    pub const fn option(&self) -> Option<&str> {
92        self.request_value().2
93    }
94
95    /// Returns typed details of this failure: always
96    /// [`DiagnosticDetails::RequestValue`], naming the rejected field and,
97    /// where they apply, the supplemental asset and format option.
98    pub fn details(&self) -> Option<&DiagnosticDetails> {
99        Some(&self.details)
100    }
101
102    /// Returns this failure's stable serializable descriptor.
103    pub fn descriptor(&self) -> ErrorDescriptor {
104        ErrorDescriptor::request(self.code, self.to_string(), (*self.details).clone())
105    }
106}
107
108impl fmt::Display for RequestError {
109    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
110        formatter.write_str(&self.message)
111    }
112}
113
114impl Error for RequestError {}
115
116/// The single artifact format produced by a render request.
117#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
118#[non_exhaustive]
119#[serde(rename_all = "lowercase")]
120pub enum OutputFormat {
121    /// A semantic SVG document. Every build supports it.
122    Svg,
123    /// A canvas-equivalent PNG image. Needs the default `png` feature.
124    Png,
125    /// A one-page vector-first PDF document. Needs the default `pdf` feature.
126    Pdf,
127}
128
129impl OutputFormat {
130    /// Returns the canonical media type for this format.
131    pub const fn media_type(self) -> &'static str {
132        match self {
133            Self::Svg => "image/svg+xml",
134            Self::Png => "image/png",
135            Self::Pdf => "application/pdf",
136        }
137    }
138
139    /// Returns the stable serialized spelling.
140    pub const fn as_str(self) -> &'static str {
141        match self {
142            Self::Svg => "svg",
143            Self::Png => "png",
144            Self::Pdf => "pdf",
145        }
146    }
147
148    /// The Cargo feature that compiles this format in, when it is not part of
149    /// every build, and whether this build enables it.
150    const fn feature(self) -> Option<(&'static str, bool)> {
151        match self {
152            Self::Svg => None,
153            Self::Png => Some(("png", cfg!(feature = "png"))),
154            Self::Pdf => Some(("pdf", cfg!(feature = "pdf"))),
155        }
156    }
157
158    /// Rejects a format whose Cargo feature this build leaves out, before any
159    /// other request validation: no option of the request could be honored.
160    fn require_enabled(self) -> Result<(), RequestError> {
161        match self.feature() {
162            Some((feature, false)) => Err(RequestError::new(
163                ErrorCode::REQUEST_FORMAT_UNAVAILABLE,
164                format!(
165                    "{} output is not enabled in this build; enable the `{feature}` feature \
166                     of excalidraw-render",
167                    self.as_str().to_ascii_uppercase()
168                ),
169                "format",
170            )),
171            Some((_, true)) | None => Ok(()),
172        }
173    }
174}
175
176impl fmt::Display for OutputFormat {
177    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
178        formatter.write_str(self.as_str())
179    }
180}
181
182/// Output sizing selected for one render.
183#[derive(Clone, Copy, Debug, PartialEq)]
184#[non_exhaustive]
185pub enum Sizing {
186    /// Multiplies logical output dimensions by the supplied scale.
187    Scale(f64),
188    /// Downscales PNG and PDF output so its larger side is at most the
189    /// supplied number of logical pixels; never enlarges.
190    ///
191    /// PNG output then measures at most this many pixels on its larger side.
192    /// A PDF page maps one logical pixel to 0.75 points, so its larger side
193    /// is at most three quarters of this many points. SVG output ignores it
194    /// and keeps its logical size; use [`Sizing::Scale`] to resize SVG.
195    MaxDimension(f64),
196}
197
198/// The reference export size: `Scale(1.0)`.
199impl Default for Sizing {
200    fn default() -> Self {
201        Self::Scale(1.0)
202    }
203}
204
205/// Color mode used to interpret scene colors.
206#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
207#[non_exhaustive]
208pub enum ColorMode {
209    /// Reference light-mode colors.
210    #[default]
211    Light,
212    /// Reference dark-mode colors.
213    Dark,
214}
215
216/// Whole-scene frame rendering controls.
217///
218/// The default renders frames the way the reference export does: frames are
219/// enabled, their contents are clipped, and their names and outlines are
220/// drawn. Start from [`FrameOptions::default`] and turn individual controls
221/// off with the `with_*` setters:
222///
223/// ```
224/// use excalidraw_render::FrameOptions;
225///
226/// let options = FrameOptions::default().with_show_name(false).with_outline(false);
227/// assert!(options.enabled() && options.clip());
228/// assert!(!options.show_name() && !options.outline());
229/// ```
230#[derive(Clone, Debug, Eq, PartialEq)]
231pub struct FrameOptions {
232    enabled: bool,
233    clip: bool,
234    show_name: bool,
235    outline: bool,
236}
237
238impl FrameOptions {
239    /// Selects whether frames are rendered at all (default `true`).
240    #[must_use]
241    pub const fn with_enabled(mut self, enabled: bool) -> Self {
242        self.enabled = enabled;
243        self
244    }
245
246    /// Selects whether frame contents are clipped to the frame (default `true`).
247    #[must_use]
248    pub const fn with_clip(mut self, clip: bool) -> Self {
249        self.clip = clip;
250        self
251    }
252
253    /// Selects whether frame names are rendered (default `true`).
254    #[must_use]
255    pub const fn with_show_name(mut self, show_name: bool) -> Self {
256        self.show_name = show_name;
257        self
258    }
259
260    /// Selects whether frame outlines are rendered (default `true`).
261    #[must_use]
262    pub const fn with_outline(mut self, outline: bool) -> Self {
263        self.outline = outline;
264        self
265    }
266
267    /// Returns whether frames are rendered.
268    pub const fn enabled(&self) -> bool {
269        self.enabled
270    }
271
272    /// Returns whether frame contents are clipped.
273    pub const fn clip(&self) -> bool {
274        self.clip
275    }
276
277    /// Returns whether frame names are rendered.
278    pub const fn show_name(&self) -> bool {
279        self.show_name
280    }
281
282    /// Returns whether frame outlines are rendered.
283    pub const fn outline(&self) -> bool {
284        self.outline
285    }
286}
287
288impl Default for FrameOptions {
289    /// Every frame control on: rendered, clipped, named and outlined.
290    fn default() -> Self {
291        Self {
292            enabled: true,
293            clip: true,
294            show_name: true,
295            outline: true,
296        }
297    }
298}
299
300/// Optional artifact metadata controls.
301///
302/// The default embeds no optional metadata. Opt in to each component with the
303/// `with_*` setters:
304///
305/// ```
306/// use excalidraw_render::MetadataOptions;
307///
308/// let options = MetadataOptions::default()
309///     .with_embedded_scene(true)
310///     .with_provenance(true);
311/// assert!(options.embedded_scene() && options.provenance());
312/// assert!(!options.diagnostic_summary());
313/// ```
314#[derive(Clone, Debug, Default, Eq, PartialEq)]
315pub struct MetadataOptions {
316    embedded_scene: bool,
317    provenance: bool,
318    diagnostic_summary: bool,
319}
320
321impl MetadataOptions {
322    /// Selects whether the canonicalized original scene document is embedded
323    /// (default `false`).
324    #[must_use]
325    pub const fn with_embedded_scene(mut self, embedded_scene: bool) -> Self {
326        self.embedded_scene = embedded_scene;
327        self
328    }
329
330    /// Selects whether renderer provenance is embedded (default `false`).
331    #[must_use]
332    pub const fn with_provenance(mut self, provenance: bool) -> Self {
333        self.provenance = provenance;
334        self
335    }
336
337    /// Selects whether the pre-metadata diagnostic summary is embedded
338    /// (default `false`).
339    #[must_use]
340    pub const fn with_diagnostic_summary(mut self, diagnostic_summary: bool) -> Self {
341        self.diagnostic_summary = diagnostic_summary;
342        self
343    }
344
345    /// Returns whether the canonicalized original scene document is embedded.
346    pub const fn embedded_scene(&self) -> bool {
347        self.embedded_scene
348    }
349
350    /// Returns whether renderer provenance is embedded.
351    pub const fn provenance(&self) -> bool {
352        self.provenance
353    }
354
355    /// Returns whether the pre-metadata diagnostic summary is embedded.
356    pub const fn diagnostic_summary(&self) -> bool {
357        self.diagnostic_summary
358    }
359}
360
361/// Policy for PDF-only isolated raster fallback.
362#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
363#[non_exhaustive]
364pub enum PdfRasterFallback {
365    /// Rasterize only the isolated closures allowed by the compatibility contract.
366    #[default]
367    AllowIsolated,
368    /// Fail if an isolated raster fallback closure remains after preflight.
369    Forbid,
370}
371
372/// PDF-specific request controls.
373///
374/// The default allows isolated raster fallback:
375///
376/// ```
377/// use excalidraw_render::{PdfOptions, PdfRasterFallback};
378///
379/// let options = PdfOptions::default().with_raster_fallback(PdfRasterFallback::Forbid);
380/// assert_eq!(options.raster_fallback(), PdfRasterFallback::Forbid);
381/// ```
382#[derive(Clone, Debug, Default, Eq, PartialEq)]
383pub struct PdfOptions {
384    raster_fallback: PdfRasterFallback,
385}
386
387impl PdfOptions {
388    /// Selects the isolated raster-fallback policy (default
389    /// [`PdfRasterFallback::AllowIsolated`]).
390    #[must_use]
391    pub const fn with_raster_fallback(mut self, raster_fallback: PdfRasterFallback) -> Self {
392        self.raster_fallback = raster_fallback;
393        self
394    }
395
396    /// Returns the isolated raster-fallback policy.
397    pub const fn raster_fallback(&self) -> PdfRasterFallback {
398        self.raster_fallback
399    }
400}
401
402/// One owned caller-supplied asset.
403#[derive(Clone, Debug, Eq, PartialEq)]
404pub struct SupplementalAsset {
405    bytes: Vec<u8>,
406    advisory_media_type: Option<String>,
407}
408
409impl SupplementalAsset {
410    /// Returns the owned payload bytes.
411    pub fn bytes(&self) -> &[u8] {
412        &self.bytes
413    }
414
415    /// Returns the caller's advisory media type, when supplied.
416    pub fn advisory_media_type(&self) -> Option<&str> {
417        self.advisory_media_type.as_deref()
418    }
419}
420
421/// Owned caller-supplied assets keyed by scene file ID.
422#[derive(Clone, Debug, Default, Eq, PartialEq)]
423pub struct SupplementalAssets {
424    entries: BTreeMap<String, SupplementalAsset>,
425}
426
427impl SupplementalAssets {
428    /// Starts an empty supplemental-assets builder.
429    pub fn builder() -> SupplementalAssetsBuilder {
430        SupplementalAssetsBuilder::default()
431    }
432
433    /// Returns whether the collection contains no supplemental assets.
434    pub fn is_empty(&self) -> bool {
435        self.entries.is_empty()
436    }
437
438    /// Returns the number of supplemental scene file IDs.
439    pub fn len(&self) -> usize {
440        self.entries.len()
441    }
442
443    /// Returns the supplemental asset for a scene file ID.
444    pub fn get(&self, file_id: &str) -> Option<&SupplementalAsset> {
445        self.entries.get(file_id)
446    }
447
448    /// Iterates assets in ascending scene file-ID UTF-8 byte order.
449    pub fn iter(&self) -> impl Iterator<Item = (&str, &SupplementalAsset)> {
450        self.entries
451            .iter()
452            .map(|(file_id, asset)| (file_id.as_str(), asset))
453    }
454}
455
456/// Builder for an owned deterministic supplemental-asset mapping.
457#[must_use]
458#[derive(Clone, Debug, Default)]
459pub struct SupplementalAssetsBuilder {
460    entries: Vec<(String, SupplementalAsset)>,
461}
462
463impl SupplementalAssetsBuilder {
464    /// Adds owned bytes without an advisory media type.
465    pub fn asset(mut self, file_id: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
466        self.entries.push((
467            file_id.into(),
468            SupplementalAsset {
469                bytes: bytes.into(),
470                advisory_media_type: None,
471            },
472        ));
473        self
474    }
475
476    /// Adds owned bytes with an advisory media type.
477    pub fn asset_with_media_type(
478        mut self,
479        file_id: impl Into<String>,
480        bytes: impl Into<Vec<u8>>,
481        media_type: impl Into<String>,
482    ) -> Self {
483        self.entries.push((
484            file_id.into(),
485            SupplementalAsset {
486                bytes: bytes.into(),
487                advisory_media_type: Some(media_type.into()),
488            },
489        ));
490        self
491    }
492
493    /// Validates and constructs the deterministic asset mapping.
494    ///
495    /// # Errors
496    ///
497    /// Returns a [`RequestError`] naming the first offending asset when a
498    /// scene file ID is empty (`request.empty-asset-id`) or added more than
499    /// once (`request.duplicate-asset-id`), or when an advisory media type is
500    /// malformed (`request.invalid-asset-media-type`).
501    pub fn build(self) -> Result<SupplementalAssets, RequestError> {
502        let mut entries = BTreeMap::new();
503        for (file_id, asset) in self.entries {
504            if file_id.is_empty() {
505                return Err(RequestError::for_asset(
506                    ErrorCode::REQUEST_EMPTY_ASSET_ID,
507                    "supplemental asset ID must not be empty",
508                    file_id,
509                    None,
510                ));
511            }
512            if entries.contains_key(&file_id) {
513                return Err(RequestError::for_asset(
514                    ErrorCode::REQUEST_DUPLICATE_ASSET_ID,
515                    format!("supplemental asset ID {file_id:?} occurs more than once"),
516                    file_id,
517                    None,
518                ));
519            }
520            if let Some(media_type) = asset
521                .advisory_media_type
522                .as_deref()
523                .filter(|media_type| !is_valid_media_type(media_type))
524            {
525                return Err(RequestError::for_asset(
526                    ErrorCode::REQUEST_INVALID_ASSET_MEDIA_TYPE,
527                    format!("supplemental asset {file_id:?} media type {media_type:?} is invalid"),
528                    file_id,
529                    Some("media_type"),
530                ));
531            }
532            entries.insert(file_id, asset);
533        }
534        Ok(SupplementalAssets { entries })
535    }
536}
537
538/// A validated one-format render request.
539#[derive(Clone, Debug, PartialEq)]
540pub struct RenderRequest {
541    format: OutputFormat,
542    include_background: bool,
543    padding: f64,
544    sizing: Sizing,
545    color_mode: ColorMode,
546    frame_options: FrameOptions,
547    metadata_options: MetadataOptions,
548    supplemental_assets: SupplementalAssets,
549    resource_limits: ResourceLimits,
550    pdf_options: Option<PdfOptions>,
551}
552
553impl RenderRequest {
554    /// Starts a validated request builder for exactly one output format.
555    pub fn builder(format: OutputFormat) -> RenderRequestBuilder {
556        RenderRequestBuilder {
557            request: Self {
558                format,
559                include_background: true,
560                padding: 10.0,
561                sizing: Sizing::default(),
562                color_mode: ColorMode::Light,
563                frame_options: FrameOptions::default(),
564                metadata_options: MetadataOptions::default(),
565                supplemental_assets: SupplementalAssets::default(),
566                resource_limits: ResourceLimits::default(),
567                pdf_options: (format == OutputFormat::Pdf).then(PdfOptions::default),
568            },
569            pdf_options_supplied: false,
570        }
571    }
572
573    /// Returns the single selected output format.
574    pub const fn format(&self) -> OutputFormat {
575        self.format
576    }
577
578    /// Returns whether the scene background is included.
579    pub const fn include_background(&self) -> bool {
580        self.include_background
581    }
582
583    /// Returns the logical padding on each side of the scene.
584    pub const fn padding(&self) -> f64 {
585        self.padding
586    }
587
588    /// Returns the selected output sizing behavior.
589    pub const fn sizing(&self) -> Sizing {
590        self.sizing
591    }
592
593    /// Returns the selected color mode.
594    pub const fn color_mode(&self) -> ColorMode {
595        self.color_mode
596    }
597
598    /// How the dark-mode filter is lowered into painted colors. The
599    /// reference's SVG carries `THEME_FILTER` on its root and filters the
600    /// composited image, which no per-color lowering reproduces at antialiased
601    /// pixels, so SVG carries the same root filter and lowers nothing. PNG
602    /// lowers as the canvas floors and PDF as the official SVG rounds (spec
603    /// section 8.1).
604    pub(crate) fn color_lowering(&self) -> ColorLowering {
605        match (self.format(), self.color_mode()) {
606            (OutputFormat::Svg, _) | (_, ColorMode::Light) => ColorLowering::None,
607            (OutputFormat::Png, ColorMode::Dark) => ColorLowering::Floor,
608            (OutputFormat::Pdf, ColorMode::Dark) => ColorLowering::Round,
609        }
610    }
611
612    /// Returns the frame controls.
613    pub const fn frame_options(&self) -> &FrameOptions {
614        &self.frame_options
615    }
616
617    /// Returns the optional metadata controls.
618    pub const fn metadata_options(&self) -> &MetadataOptions {
619        &self.metadata_options
620    }
621
622    /// Returns the owned supplemental assets.
623    pub const fn supplemental_assets(&self) -> &SupplementalAssets {
624        &self.supplemental_assets
625    }
626
627    /// Returns the semantic resource ceilings.
628    pub const fn resource_limits(&self) -> &ResourceLimits {
629        &self.resource_limits
630    }
631
632    /// Returns PDF-specific options for a PDF request.
633    pub const fn pdf_options(&self) -> Option<&PdfOptions> {
634        self.pdf_options.as_ref()
635    }
636}
637
638/// Builder for a validated one-format render request.
639#[must_use]
640#[derive(Clone, Debug)]
641pub struct RenderRequestBuilder {
642    request: RenderRequest,
643    pdf_options_supplied: bool,
644}
645
646/// A validated one-format frame render request.
647#[derive(Clone, Debug, PartialEq)]
648pub struct FrameRenderRequest {
649    inner: RenderRequest,
650}
651
652impl FrameRenderRequest {
653    /// Starts a validated frame request builder for exactly one output format.
654    pub fn builder(format: OutputFormat) -> FrameRenderRequestBuilder {
655        FrameRenderRequestBuilder {
656            inner: RenderRequest::builder(format).padding(0.0).frame_options(
657                FrameOptions::default()
658                    .with_show_name(false)
659                    .with_outline(false),
660            ),
661        }
662    }
663
664    /// Returns the single selected output format.
665    pub const fn format(&self) -> OutputFormat {
666        self.inner.format()
667    }
668
669    /// Returns whether the scene background is included.
670    pub const fn include_background(&self) -> bool {
671        self.inner.include_background()
672    }
673
674    /// Returns the selected output sizing behavior.
675    pub const fn sizing(&self) -> Sizing {
676        self.inner.sizing()
677    }
678
679    /// Returns the selected color mode.
680    pub const fn color_mode(&self) -> ColorMode {
681        self.inner.color_mode()
682    }
683
684    /// Returns the optional metadata controls.
685    pub const fn metadata_options(&self) -> &MetadataOptions {
686        self.inner.metadata_options()
687    }
688
689    /// Returns the owned supplemental assets.
690    pub const fn supplemental_assets(&self) -> &SupplementalAssets {
691        self.inner.supplemental_assets()
692    }
693
694    /// Returns the semantic resource ceilings applied to this frame.
695    pub const fn resource_limits(&self) -> &ResourceLimits {
696        self.inner.resource_limits()
697    }
698
699    /// Returns PDF-specific options for a PDF request.
700    pub const fn pdf_options(&self) -> Option<&PdfOptions> {
701        self.inner.pdf_options()
702    }
703
704    pub(crate) const fn as_render_request(&self) -> &RenderRequest {
705        &self.inner
706    }
707}
708
709/// Builder for a validated one-format frame render request.
710#[must_use]
711#[derive(Clone, Debug)]
712pub struct FrameRenderRequestBuilder {
713    inner: RenderRequestBuilder,
714}
715
716impl FrameRenderRequestBuilder {
717    /// Selects whether the scene background is included.
718    pub fn include_background(mut self, include: bool) -> Self {
719        self.inner = self.inner.include_background(include);
720        self
721    }
722
723    /// Selects explicit scale or downscale-only maximum-dimension sizing.
724    pub fn sizing(mut self, sizing: Sizing) -> Self {
725        self.inner = self.inner.sizing(sizing);
726        self
727    }
728
729    /// Selects light or dark color interpretation.
730    pub fn color_mode(mut self, color_mode: ColorMode) -> Self {
731        self.inner = self.inner.color_mode(color_mode);
732        self
733    }
734
735    /// Replaces all optional artifact metadata controls.
736    pub fn metadata_options(mut self, options: MetadataOptions) -> Self {
737        self.inner = self.inner.metadata_options(options);
738        self
739    }
740
741    /// Replaces all owned supplemental assets.
742    pub fn supplemental_assets(mut self, assets: SupplementalAssets) -> Self {
743        self.inner = self.inner.supplemental_assets(assets);
744        self
745    }
746
747    /// Replaces the validated semantic resource ceilings.
748    pub fn resource_limits(mut self, limits: ResourceLimits) -> Self {
749        self.inner = self.inner.resource_limits(limits);
750        self
751    }
752
753    /// Replaces PDF-specific controls.
754    pub fn pdf_options(mut self, options: PdfOptions) -> Self {
755        self.inner = self.inner.pdf_options(options);
756        self
757    }
758
759    /// Validates and constructs the frame request.
760    ///
761    /// # Errors
762    ///
763    /// Returns a [`RequestError`] for the same disabled format, invalid
764    /// sizing and format options as [`RenderRequestBuilder::build`].
765    pub fn build(self) -> Result<FrameRenderRequest, RequestError> {
766        self.inner.build().map(|inner| FrameRenderRequest { inner })
767    }
768}
769
770impl RenderRequestBuilder {
771    /// Selects whether the scene background is included.
772    pub fn include_background(mut self, include: bool) -> Self {
773        self.request.include_background = include;
774        self
775    }
776
777    /// Selects logical padding on each side of the scene.
778    pub fn padding(mut self, padding: f64) -> Self {
779        self.request.padding = padding;
780        self
781    }
782
783    /// Selects explicit scale or downscale-only maximum-dimension sizing.
784    pub fn sizing(mut self, sizing: Sizing) -> Self {
785        self.request.sizing = sizing;
786        self
787    }
788
789    /// Selects light or dark color interpretation.
790    pub fn color_mode(mut self, color_mode: ColorMode) -> Self {
791        self.request.color_mode = color_mode;
792        self
793    }
794
795    /// Replaces all frame controls.
796    pub fn frame_options(mut self, options: FrameOptions) -> Self {
797        self.request.frame_options = options;
798        self
799    }
800
801    /// Replaces all optional artifact metadata controls.
802    pub fn metadata_options(mut self, options: MetadataOptions) -> Self {
803        self.request.metadata_options = options;
804        self
805    }
806
807    /// Replaces all owned supplemental assets.
808    pub fn supplemental_assets(mut self, assets: SupplementalAssets) -> Self {
809        self.request.supplemental_assets = assets;
810        self
811    }
812
813    /// Replaces the validated semantic resource ceilings.
814    pub fn resource_limits(mut self, limits: ResourceLimits) -> Self {
815        self.request.resource_limits = limits;
816        self
817    }
818
819    /// Replaces PDF-specific controls.
820    pub fn pdf_options(mut self, options: PdfOptions) -> Self {
821        self.request.pdf_options = Some(options);
822        self.pdf_options_supplied = true;
823        self
824    }
825
826    /// Validates and constructs the request.
827    ///
828    /// # Errors
829    ///
830    /// Returns a [`RequestError`] when the output format's Cargo feature is
831    /// not enabled in this build (`request.format-unavailable`; see the
832    /// [crate documentation](crate#cargo-features)), the padding is negative
833    /// or not finite (`request.invalid-padding`), the [`Sizing`] scale or
834    /// maximum dimension is not finite and positive (`request.invalid-scale`,
835    /// `request.invalid-max-dimension`), or [`PdfOptions`] were supplied for
836    /// another output format (`request.incompatible-format-options`).
837    pub fn build(self) -> Result<RenderRequest, RequestError> {
838        self.request.format.require_enabled()?;
839
840        if !self.request.padding.is_finite() || self.request.padding < 0.0 {
841            return Err(RequestError::new(
842                ErrorCode::REQUEST_INVALID_PADDING,
843                format!(
844                    "padding must be finite and nonnegative, got {}",
845                    self.request.padding
846                ),
847                "padding",
848            ));
849        }
850
851        match self.request.sizing {
852            Sizing::Scale(scale) if !scale.is_finite() || scale <= 0.0 => {
853                return Err(RequestError::new(
854                    ErrorCode::REQUEST_INVALID_SCALE,
855                    format!("scale must be finite and positive, got {scale}"),
856                    "scale",
857                ));
858            }
859            Sizing::MaxDimension(dimension) if !dimension.is_finite() || dimension <= 0.0 => {
860                return Err(RequestError::new(
861                    ErrorCode::REQUEST_INVALID_MAX_DIMENSION,
862                    format!("maximum dimension must be finite and positive, got {dimension}"),
863                    "max_dimension",
864                ));
865            }
866            Sizing::Scale(_) | Sizing::MaxDimension(_) => {}
867        }
868
869        if self.pdf_options_supplied && self.request.format != OutputFormat::Pdf {
870            return Err(RequestError::for_option(
871                ErrorCode::REQUEST_INCOMPATIBLE_FORMAT_OPTIONS,
872                format!(
873                    "PDF options require PDF output, but the request format is {}",
874                    self.request.format.as_str()
875                ),
876                "pdf_options",
877                "raster_fallback",
878            ));
879        }
880
881        Ok(self.request)
882    }
883}
884
885fn is_valid_media_type(value: &str) -> bool {
886    let mut parts = value.split(';');
887    let Some(essence) = parts.next() else {
888        return false;
889    };
890    let Some((type_name, subtype_name)) = essence.split_once('/') else {
891        return false;
892    };
893    if subtype_name.contains('/') || !is_token(type_name) || !is_token(subtype_name) {
894        return false;
895    }
896
897    parts.all(|parameter| {
898        let parameter = parameter.trim_matches([' ', '\t']);
899        let Some((name, value)) = parameter.split_once('=') else {
900            return false;
901        };
902        is_token(name.trim_end_matches([' ', '\t']))
903            && valid_parameter_value(value.trim_start_matches([' ', '\t']))
904    })
905}
906
907fn is_token(value: &str) -> bool {
908    !value.is_empty()
909        && value.bytes().all(|byte| {
910            byte.is_ascii_alphanumeric()
911                || matches!(
912                    byte,
913                    b'!' | b'#'
914                        | b'$'
915                        | b'%'
916                        | b'&'
917                        | b'\''
918                        | b'*'
919                        | b'+'
920                        | b'-'
921                        | b'.'
922                        | b'^'
923                        | b'_'
924                        | b'`'
925                        | b'|'
926                        | b'~'
927                )
928        })
929}
930
931fn valid_parameter_value(value: &str) -> bool {
932    if is_token(value) {
933        return true;
934    }
935    let Some(quoted) = value
936        .strip_prefix('"')
937        .and_then(|value| value.strip_suffix('"'))
938    else {
939        return false;
940    };
941
942    let mut escaped = false;
943    for byte in quoted.bytes() {
944        if escaped {
945            if !(b'\t'..=b'~').contains(&byte) {
946                return false;
947            }
948            escaped = false;
949        } else if byte == b'\\' {
950            escaped = true;
951        } else if byte == b'"' || !(byte == b'\t' || (b' '..=b'~').contains(&byte)) {
952            return false;
953        }
954    }
955    !escaped
956}