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#[derive(Clone, Debug)]
15pub struct RequestError {
16 code: ErrorCode,
17 message: Box<str>,
18 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 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 pub const fn code(&self) -> ErrorCode {
77 self.code
78 }
79
80 pub const fn field(&self) -> &str {
82 self.request_value().0
83 }
84
85 pub fn asset_id(&self) -> Option<&str> {
87 self.request_value().1.map(String::as_str)
88 }
89
90 pub const fn option(&self) -> Option<&str> {
92 self.request_value().2
93 }
94
95 pub fn details(&self) -> Option<&DiagnosticDetails> {
99 Some(&self.details)
100 }
101
102 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
118#[non_exhaustive]
119#[serde(rename_all = "lowercase")]
120pub enum OutputFormat {
121 Svg,
123 Png,
125 Pdf,
127}
128
129impl OutputFormat {
130 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 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 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 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#[derive(Clone, Copy, Debug, PartialEq)]
184#[non_exhaustive]
185pub enum Sizing {
186 Scale(f64),
188 MaxDimension(f64),
196}
197
198impl Default for Sizing {
200 fn default() -> Self {
201 Self::Scale(1.0)
202 }
203}
204
205#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
207#[non_exhaustive]
208pub enum ColorMode {
209 #[default]
211 Light,
212 Dark,
214}
215
216#[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 #[must_use]
241 pub const fn with_enabled(mut self, enabled: bool) -> Self {
242 self.enabled = enabled;
243 self
244 }
245
246 #[must_use]
248 pub const fn with_clip(mut self, clip: bool) -> Self {
249 self.clip = clip;
250 self
251 }
252
253 #[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 #[must_use]
262 pub const fn with_outline(mut self, outline: bool) -> Self {
263 self.outline = outline;
264 self
265 }
266
267 pub const fn enabled(&self) -> bool {
269 self.enabled
270 }
271
272 pub const fn clip(&self) -> bool {
274 self.clip
275 }
276
277 pub const fn show_name(&self) -> bool {
279 self.show_name
280 }
281
282 pub const fn outline(&self) -> bool {
284 self.outline
285 }
286}
287
288impl Default for FrameOptions {
289 fn default() -> Self {
291 Self {
292 enabled: true,
293 clip: true,
294 show_name: true,
295 outline: true,
296 }
297 }
298}
299
300#[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 #[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 #[must_use]
332 pub const fn with_provenance(mut self, provenance: bool) -> Self {
333 self.provenance = provenance;
334 self
335 }
336
337 #[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 pub const fn embedded_scene(&self) -> bool {
347 self.embedded_scene
348 }
349
350 pub const fn provenance(&self) -> bool {
352 self.provenance
353 }
354
355 pub const fn diagnostic_summary(&self) -> bool {
357 self.diagnostic_summary
358 }
359}
360
361#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
363#[non_exhaustive]
364pub enum PdfRasterFallback {
365 #[default]
367 AllowIsolated,
368 Forbid,
370}
371
372#[derive(Clone, Debug, Default, Eq, PartialEq)]
383pub struct PdfOptions {
384 raster_fallback: PdfRasterFallback,
385}
386
387impl PdfOptions {
388 #[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 pub const fn raster_fallback(&self) -> PdfRasterFallback {
398 self.raster_fallback
399 }
400}
401
402#[derive(Clone, Debug, Eq, PartialEq)]
404pub struct SupplementalAsset {
405 bytes: Vec<u8>,
406 advisory_media_type: Option<String>,
407}
408
409impl SupplementalAsset {
410 pub fn bytes(&self) -> &[u8] {
412 &self.bytes
413 }
414
415 pub fn advisory_media_type(&self) -> Option<&str> {
417 self.advisory_media_type.as_deref()
418 }
419}
420
421#[derive(Clone, Debug, Default, Eq, PartialEq)]
423pub struct SupplementalAssets {
424 entries: BTreeMap<String, SupplementalAsset>,
425}
426
427impl SupplementalAssets {
428 pub fn builder() -> SupplementalAssetsBuilder {
430 SupplementalAssetsBuilder::default()
431 }
432
433 pub fn is_empty(&self) -> bool {
435 self.entries.is_empty()
436 }
437
438 pub fn len(&self) -> usize {
440 self.entries.len()
441 }
442
443 pub fn get(&self, file_id: &str) -> Option<&SupplementalAsset> {
445 self.entries.get(file_id)
446 }
447
448 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#[must_use]
458#[derive(Clone, Debug, Default)]
459pub struct SupplementalAssetsBuilder {
460 entries: Vec<(String, SupplementalAsset)>,
461}
462
463impl SupplementalAssetsBuilder {
464 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 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 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#[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 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 pub const fn format(&self) -> OutputFormat {
575 self.format
576 }
577
578 pub const fn include_background(&self) -> bool {
580 self.include_background
581 }
582
583 pub const fn padding(&self) -> f64 {
585 self.padding
586 }
587
588 pub const fn sizing(&self) -> Sizing {
590 self.sizing
591 }
592
593 pub const fn color_mode(&self) -> ColorMode {
595 self.color_mode
596 }
597
598 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 pub const fn frame_options(&self) -> &FrameOptions {
614 &self.frame_options
615 }
616
617 pub const fn metadata_options(&self) -> &MetadataOptions {
619 &self.metadata_options
620 }
621
622 pub const fn supplemental_assets(&self) -> &SupplementalAssets {
624 &self.supplemental_assets
625 }
626
627 pub const fn resource_limits(&self) -> &ResourceLimits {
629 &self.resource_limits
630 }
631
632 pub const fn pdf_options(&self) -> Option<&PdfOptions> {
634 self.pdf_options.as_ref()
635 }
636}
637
638#[must_use]
640#[derive(Clone, Debug)]
641pub struct RenderRequestBuilder {
642 request: RenderRequest,
643 pdf_options_supplied: bool,
644}
645
646#[derive(Clone, Debug, PartialEq)]
648pub struct FrameRenderRequest {
649 inner: RenderRequest,
650}
651
652impl FrameRenderRequest {
653 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 pub const fn format(&self) -> OutputFormat {
666 self.inner.format()
667 }
668
669 pub const fn include_background(&self) -> bool {
671 self.inner.include_background()
672 }
673
674 pub const fn sizing(&self) -> Sizing {
676 self.inner.sizing()
677 }
678
679 pub const fn color_mode(&self) -> ColorMode {
681 self.inner.color_mode()
682 }
683
684 pub const fn metadata_options(&self) -> &MetadataOptions {
686 self.inner.metadata_options()
687 }
688
689 pub const fn supplemental_assets(&self) -> &SupplementalAssets {
691 self.inner.supplemental_assets()
692 }
693
694 pub const fn resource_limits(&self) -> &ResourceLimits {
696 self.inner.resource_limits()
697 }
698
699 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#[must_use]
711#[derive(Clone, Debug)]
712pub struct FrameRenderRequestBuilder {
713 inner: RenderRequestBuilder,
714}
715
716impl FrameRenderRequestBuilder {
717 pub fn include_background(mut self, include: bool) -> Self {
719 self.inner = self.inner.include_background(include);
720 self
721 }
722
723 pub fn sizing(mut self, sizing: Sizing) -> Self {
725 self.inner = self.inner.sizing(sizing);
726 self
727 }
728
729 pub fn color_mode(mut self, color_mode: ColorMode) -> Self {
731 self.inner = self.inner.color_mode(color_mode);
732 self
733 }
734
735 pub fn metadata_options(mut self, options: MetadataOptions) -> Self {
737 self.inner = self.inner.metadata_options(options);
738 self
739 }
740
741 pub fn supplemental_assets(mut self, assets: SupplementalAssets) -> Self {
743 self.inner = self.inner.supplemental_assets(assets);
744 self
745 }
746
747 pub fn resource_limits(mut self, limits: ResourceLimits) -> Self {
749 self.inner = self.inner.resource_limits(limits);
750 self
751 }
752
753 pub fn pdf_options(mut self, options: PdfOptions) -> Self {
755 self.inner = self.inner.pdf_options(options);
756 self
757 }
758
759 pub fn build(self) -> Result<FrameRenderRequest, RequestError> {
766 self.inner.build().map(|inner| FrameRenderRequest { inner })
767 }
768}
769
770impl RenderRequestBuilder {
771 pub fn include_background(mut self, include: bool) -> Self {
773 self.request.include_background = include;
774 self
775 }
776
777 pub fn padding(mut self, padding: f64) -> Self {
779 self.request.padding = padding;
780 self
781 }
782
783 pub fn sizing(mut self, sizing: Sizing) -> Self {
785 self.request.sizing = sizing;
786 self
787 }
788
789 pub fn color_mode(mut self, color_mode: ColorMode) -> Self {
791 self.request.color_mode = color_mode;
792 self
793 }
794
795 pub fn frame_options(mut self, options: FrameOptions) -> Self {
797 self.request.frame_options = options;
798 self
799 }
800
801 pub fn metadata_options(mut self, options: MetadataOptions) -> Self {
803 self.request.metadata_options = options;
804 self
805 }
806
807 pub fn supplemental_assets(mut self, assets: SupplementalAssets) -> Self {
809 self.request.supplemental_assets = assets;
810 self
811 }
812
813 pub fn resource_limits(mut self, limits: ResourceLimits) -> Self {
815 self.request.resource_limits = limits;
816 self
817 }
818
819 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 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}