use std::collections::BTreeMap;
use std::error::Error;
use std::fmt;
use serde::Serialize;
use crate::color::ColorLowering;
use crate::{DiagnosticDetails, ErrorCode, ErrorDescriptor, ResourceLimits};
#[derive(Clone, Debug)]
pub struct RequestError {
code: ErrorCode,
message: Box<str>,
details: Box<DiagnosticDetails>,
}
impl RequestError {
pub(crate) fn new(code: ErrorCode, message: impl Into<Box<str>>, field: &'static str) -> Self {
Self::with_value(code, message, field, None, None)
}
fn for_option(
code: ErrorCode,
message: impl Into<Box<str>>,
field: &'static str,
option: &'static str,
) -> Self {
Self::with_value(code, message, field, None, Some(option))
}
fn for_asset(
code: ErrorCode,
message: impl Into<Box<str>>,
asset_id: String,
option: Option<&'static str>,
) -> Self {
Self::with_value(code, message, "supplemental_assets", Some(asset_id), option)
}
fn with_value(
code: ErrorCode,
message: impl Into<Box<str>>,
field: &'static str,
asset_id: Option<String>,
option: Option<&'static str>,
) -> Self {
Self {
code,
message: message.into(),
details: Box::new(DiagnosticDetails::RequestValue {
field,
asset_id,
option,
}),
}
}
const fn request_value(&self) -> (&'static str, Option<&String>, Option<&'static str>) {
match &*self.details {
DiagnosticDetails::RequestValue {
field,
asset_id,
option,
} => (field, asset_id.as_ref(), *option),
_ => unreachable!(),
}
}
pub const fn code(&self) -> ErrorCode {
self.code
}
pub const fn field(&self) -> &str {
self.request_value().0
}
pub fn asset_id(&self) -> Option<&str> {
self.request_value().1.map(String::as_str)
}
pub const fn option(&self) -> Option<&str> {
self.request_value().2
}
pub fn details(&self) -> Option<&DiagnosticDetails> {
Some(&self.details)
}
pub fn descriptor(&self) -> ErrorDescriptor {
ErrorDescriptor::request(self.code, self.to_string(), (*self.details).clone())
}
}
impl fmt::Display for RequestError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl Error for RequestError {}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
#[non_exhaustive]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
Svg,
Png,
Pdf,
}
impl OutputFormat {
pub const fn media_type(self) -> &'static str {
match self {
Self::Svg => "image/svg+xml",
Self::Png => "image/png",
Self::Pdf => "application/pdf",
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Svg => "svg",
Self::Png => "png",
Self::Pdf => "pdf",
}
}
const fn feature(self) -> Option<(&'static str, bool)> {
match self {
Self::Svg => None,
Self::Png => Some(("png", cfg!(feature = "png"))),
Self::Pdf => Some(("pdf", cfg!(feature = "pdf"))),
}
}
fn require_enabled(self) -> Result<(), RequestError> {
match self.feature() {
Some((feature, false)) => Err(RequestError::new(
ErrorCode::REQUEST_FORMAT_UNAVAILABLE,
format!(
"{} output is not enabled in this build; enable the `{feature}` feature \
of excalidraw-render",
self.as_str().to_ascii_uppercase()
),
"format",
)),
Some((_, true)) | None => Ok(()),
}
}
}
impl fmt::Display for OutputFormat {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub enum Sizing {
Scale(f64),
MaxDimension(f64),
}
impl Default for Sizing {
fn default() -> Self {
Self::Scale(1.0)
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
pub enum ColorMode {
#[default]
Light,
Dark,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FrameOptions {
enabled: bool,
clip: bool,
show_name: bool,
outline: bool,
}
impl FrameOptions {
#[must_use]
pub const fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
#[must_use]
pub const fn with_clip(mut self, clip: bool) -> Self {
self.clip = clip;
self
}
#[must_use]
pub const fn with_show_name(mut self, show_name: bool) -> Self {
self.show_name = show_name;
self
}
#[must_use]
pub const fn with_outline(mut self, outline: bool) -> Self {
self.outline = outline;
self
}
pub const fn enabled(&self) -> bool {
self.enabled
}
pub const fn clip(&self) -> bool {
self.clip
}
pub const fn show_name(&self) -> bool {
self.show_name
}
pub const fn outline(&self) -> bool {
self.outline
}
}
impl Default for FrameOptions {
fn default() -> Self {
Self {
enabled: true,
clip: true,
show_name: true,
outline: true,
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct MetadataOptions {
embedded_scene: bool,
provenance: bool,
diagnostic_summary: bool,
}
impl MetadataOptions {
#[must_use]
pub const fn with_embedded_scene(mut self, embedded_scene: bool) -> Self {
self.embedded_scene = embedded_scene;
self
}
#[must_use]
pub const fn with_provenance(mut self, provenance: bool) -> Self {
self.provenance = provenance;
self
}
#[must_use]
pub const fn with_diagnostic_summary(mut self, diagnostic_summary: bool) -> Self {
self.diagnostic_summary = diagnostic_summary;
self
}
pub const fn embedded_scene(&self) -> bool {
self.embedded_scene
}
pub const fn provenance(&self) -> bool {
self.provenance
}
pub const fn diagnostic_summary(&self) -> bool {
self.diagnostic_summary
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
pub enum PdfRasterFallback {
#[default]
AllowIsolated,
Forbid,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct PdfOptions {
raster_fallback: PdfRasterFallback,
}
impl PdfOptions {
#[must_use]
pub const fn with_raster_fallback(mut self, raster_fallback: PdfRasterFallback) -> Self {
self.raster_fallback = raster_fallback;
self
}
pub const fn raster_fallback(&self) -> PdfRasterFallback {
self.raster_fallback
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SupplementalAsset {
bytes: Vec<u8>,
advisory_media_type: Option<String>,
}
impl SupplementalAsset {
pub fn bytes(&self) -> &[u8] {
&self.bytes
}
pub fn advisory_media_type(&self) -> Option<&str> {
self.advisory_media_type.as_deref()
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SupplementalAssets {
entries: BTreeMap<String, SupplementalAsset>,
}
impl SupplementalAssets {
pub fn builder() -> SupplementalAssetsBuilder {
SupplementalAssetsBuilder::default()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn get(&self, file_id: &str) -> Option<&SupplementalAsset> {
self.entries.get(file_id)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &SupplementalAsset)> {
self.entries
.iter()
.map(|(file_id, asset)| (file_id.as_str(), asset))
}
}
#[must_use]
#[derive(Clone, Debug, Default)]
pub struct SupplementalAssetsBuilder {
entries: Vec<(String, SupplementalAsset)>,
}
impl SupplementalAssetsBuilder {
pub fn asset(mut self, file_id: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
self.entries.push((
file_id.into(),
SupplementalAsset {
bytes: bytes.into(),
advisory_media_type: None,
},
));
self
}
pub fn asset_with_media_type(
mut self,
file_id: impl Into<String>,
bytes: impl Into<Vec<u8>>,
media_type: impl Into<String>,
) -> Self {
self.entries.push((
file_id.into(),
SupplementalAsset {
bytes: bytes.into(),
advisory_media_type: Some(media_type.into()),
},
));
self
}
pub fn build(self) -> Result<SupplementalAssets, RequestError> {
let mut entries = BTreeMap::new();
for (file_id, asset) in self.entries {
if file_id.is_empty() {
return Err(RequestError::for_asset(
ErrorCode::REQUEST_EMPTY_ASSET_ID,
"supplemental asset ID must not be empty",
file_id,
None,
));
}
if entries.contains_key(&file_id) {
return Err(RequestError::for_asset(
ErrorCode::REQUEST_DUPLICATE_ASSET_ID,
format!("supplemental asset ID {file_id:?} occurs more than once"),
file_id,
None,
));
}
if let Some(media_type) = asset
.advisory_media_type
.as_deref()
.filter(|media_type| !is_valid_media_type(media_type))
{
return Err(RequestError::for_asset(
ErrorCode::REQUEST_INVALID_ASSET_MEDIA_TYPE,
format!("supplemental asset {file_id:?} media type {media_type:?} is invalid"),
file_id,
Some("media_type"),
));
}
entries.insert(file_id, asset);
}
Ok(SupplementalAssets { entries })
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct RenderRequest {
format: OutputFormat,
include_background: bool,
padding: f64,
sizing: Sizing,
color_mode: ColorMode,
frame_options: FrameOptions,
metadata_options: MetadataOptions,
supplemental_assets: SupplementalAssets,
resource_limits: ResourceLimits,
pdf_options: Option<PdfOptions>,
}
impl RenderRequest {
pub fn builder(format: OutputFormat) -> RenderRequestBuilder {
RenderRequestBuilder {
request: Self {
format,
include_background: true,
padding: 10.0,
sizing: Sizing::default(),
color_mode: ColorMode::Light,
frame_options: FrameOptions::default(),
metadata_options: MetadataOptions::default(),
supplemental_assets: SupplementalAssets::default(),
resource_limits: ResourceLimits::default(),
pdf_options: (format == OutputFormat::Pdf).then(PdfOptions::default),
},
pdf_options_supplied: false,
}
}
pub const fn format(&self) -> OutputFormat {
self.format
}
pub const fn include_background(&self) -> bool {
self.include_background
}
pub const fn padding(&self) -> f64 {
self.padding
}
pub const fn sizing(&self) -> Sizing {
self.sizing
}
pub const fn color_mode(&self) -> ColorMode {
self.color_mode
}
pub(crate) fn color_lowering(&self) -> ColorLowering {
match (self.format(), self.color_mode()) {
(OutputFormat::Svg, _) | (_, ColorMode::Light) => ColorLowering::None,
(OutputFormat::Png, ColorMode::Dark) => ColorLowering::Floor,
(OutputFormat::Pdf, ColorMode::Dark) => ColorLowering::Round,
}
}
pub const fn frame_options(&self) -> &FrameOptions {
&self.frame_options
}
pub const fn metadata_options(&self) -> &MetadataOptions {
&self.metadata_options
}
pub const fn supplemental_assets(&self) -> &SupplementalAssets {
&self.supplemental_assets
}
pub const fn resource_limits(&self) -> &ResourceLimits {
&self.resource_limits
}
pub const fn pdf_options(&self) -> Option<&PdfOptions> {
self.pdf_options.as_ref()
}
}
#[must_use]
#[derive(Clone, Debug)]
pub struct RenderRequestBuilder {
request: RenderRequest,
pdf_options_supplied: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct FrameRenderRequest {
inner: RenderRequest,
}
impl FrameRenderRequest {
pub fn builder(format: OutputFormat) -> FrameRenderRequestBuilder {
FrameRenderRequestBuilder {
inner: RenderRequest::builder(format).padding(0.0).frame_options(
FrameOptions::default()
.with_show_name(false)
.with_outline(false),
),
}
}
pub const fn format(&self) -> OutputFormat {
self.inner.format()
}
pub const fn include_background(&self) -> bool {
self.inner.include_background()
}
pub const fn sizing(&self) -> Sizing {
self.inner.sizing()
}
pub const fn color_mode(&self) -> ColorMode {
self.inner.color_mode()
}
pub const fn metadata_options(&self) -> &MetadataOptions {
self.inner.metadata_options()
}
pub const fn supplemental_assets(&self) -> &SupplementalAssets {
self.inner.supplemental_assets()
}
pub const fn resource_limits(&self) -> &ResourceLimits {
self.inner.resource_limits()
}
pub const fn pdf_options(&self) -> Option<&PdfOptions> {
self.inner.pdf_options()
}
pub(crate) const fn as_render_request(&self) -> &RenderRequest {
&self.inner
}
}
#[must_use]
#[derive(Clone, Debug)]
pub struct FrameRenderRequestBuilder {
inner: RenderRequestBuilder,
}
impl FrameRenderRequestBuilder {
pub fn include_background(mut self, include: bool) -> Self {
self.inner = self.inner.include_background(include);
self
}
pub fn sizing(mut self, sizing: Sizing) -> Self {
self.inner = self.inner.sizing(sizing);
self
}
pub fn color_mode(mut self, color_mode: ColorMode) -> Self {
self.inner = self.inner.color_mode(color_mode);
self
}
pub fn metadata_options(mut self, options: MetadataOptions) -> Self {
self.inner = self.inner.metadata_options(options);
self
}
pub fn supplemental_assets(mut self, assets: SupplementalAssets) -> Self {
self.inner = self.inner.supplemental_assets(assets);
self
}
pub fn resource_limits(mut self, limits: ResourceLimits) -> Self {
self.inner = self.inner.resource_limits(limits);
self
}
pub fn pdf_options(mut self, options: PdfOptions) -> Self {
self.inner = self.inner.pdf_options(options);
self
}
pub fn build(self) -> Result<FrameRenderRequest, RequestError> {
self.inner.build().map(|inner| FrameRenderRequest { inner })
}
}
impl RenderRequestBuilder {
pub fn include_background(mut self, include: bool) -> Self {
self.request.include_background = include;
self
}
pub fn padding(mut self, padding: f64) -> Self {
self.request.padding = padding;
self
}
pub fn sizing(mut self, sizing: Sizing) -> Self {
self.request.sizing = sizing;
self
}
pub fn color_mode(mut self, color_mode: ColorMode) -> Self {
self.request.color_mode = color_mode;
self
}
pub fn frame_options(mut self, options: FrameOptions) -> Self {
self.request.frame_options = options;
self
}
pub fn metadata_options(mut self, options: MetadataOptions) -> Self {
self.request.metadata_options = options;
self
}
pub fn supplemental_assets(mut self, assets: SupplementalAssets) -> Self {
self.request.supplemental_assets = assets;
self
}
pub fn resource_limits(mut self, limits: ResourceLimits) -> Self {
self.request.resource_limits = limits;
self
}
pub fn pdf_options(mut self, options: PdfOptions) -> Self {
self.request.pdf_options = Some(options);
self.pdf_options_supplied = true;
self
}
pub fn build(self) -> Result<RenderRequest, RequestError> {
self.request.format.require_enabled()?;
if !self.request.padding.is_finite() || self.request.padding < 0.0 {
return Err(RequestError::new(
ErrorCode::REQUEST_INVALID_PADDING,
format!(
"padding must be finite and nonnegative, got {}",
self.request.padding
),
"padding",
));
}
match self.request.sizing {
Sizing::Scale(scale) if !scale.is_finite() || scale <= 0.0 => {
return Err(RequestError::new(
ErrorCode::REQUEST_INVALID_SCALE,
format!("scale must be finite and positive, got {scale}"),
"scale",
));
}
Sizing::MaxDimension(dimension) if !dimension.is_finite() || dimension <= 0.0 => {
return Err(RequestError::new(
ErrorCode::REQUEST_INVALID_MAX_DIMENSION,
format!("maximum dimension must be finite and positive, got {dimension}"),
"max_dimension",
));
}
Sizing::Scale(_) | Sizing::MaxDimension(_) => {}
}
if self.pdf_options_supplied && self.request.format != OutputFormat::Pdf {
return Err(RequestError::for_option(
ErrorCode::REQUEST_INCOMPATIBLE_FORMAT_OPTIONS,
format!(
"PDF options require PDF output, but the request format is {}",
self.request.format.as_str()
),
"pdf_options",
"raster_fallback",
));
}
Ok(self.request)
}
}
fn is_valid_media_type(value: &str) -> bool {
let mut parts = value.split(';');
let Some(essence) = parts.next() else {
return false;
};
let Some((type_name, subtype_name)) = essence.split_once('/') else {
return false;
};
if subtype_name.contains('/') || !is_token(type_name) || !is_token(subtype_name) {
return false;
}
parts.all(|parameter| {
let parameter = parameter.trim_matches([' ', '\t']);
let Some((name, value)) = parameter.split_once('=') else {
return false;
};
is_token(name.trim_end_matches([' ', '\t']))
&& valid_parameter_value(value.trim_start_matches([' ', '\t']))
})
}
fn is_token(value: &str) -> bool {
!value.is_empty()
&& value.bytes().all(|byte| {
byte.is_ascii_alphanumeric()
|| matches!(
byte,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
})
}
fn valid_parameter_value(value: &str) -> bool {
if is_token(value) {
return true;
}
let Some(quoted) = value
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
else {
return false;
};
let mut escaped = false;
for byte in quoted.bytes() {
if escaped {
if !(b'\t'..=b'~').contains(&byte) {
return false;
}
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'"' || !(byte == b'\t' || (b' '..=b'~').contains(&byte)) {
return false;
}
}
!escaped
}