Skip to main content

excalidraw_render/
compatibility.rs

1use std::fmt;
2
3use serde::ser::SerializeStruct;
4use serde::Serialize;
5
6use crate::outcome::{RenderError, RenderErrorCategory, RenderOutcome};
7use crate::request::{OutputFormat, RenderRequest};
8use crate::scene::SceneDocument;
9
10// Hidden, with the accessors that return the identity: this release implements
11// a single revision, so there is nothing to choose between, and the identity
12// joins the supported API when a second revision exists. The CLI prints it for
13// `--version --verbose`.
14/// Identifies the static-export profile a renderer release implements.
15///
16/// This release implements exactly one revision, [`REVISION_1`]. During 0.x a
17/// revision may change in any release and no historical revision is
18/// retained; pin the package version and dependency lockfile when historical
19/// output bytes are required.
20///
21/// [`REVISION_1`]: CompatibilityRevision::REVISION_1
22#[doc(hidden)]
23#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
24#[serde(transparent)]
25pub struct CompatibilityRevision(u64);
26
27impl CompatibilityRevision {
28    /// Compatibility revision 1, bound to Excalidraw v0.18.1.
29    pub const REVISION_1: Self = Self(1);
30
31    /// Returns the numeric compatibility revision.
32    pub const fn get(self) -> u64 {
33        self.0
34    }
35}
36
37impl fmt::Display for CompatibilityRevision {
38    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
39        self.0.fmt(formatter)
40    }
41}
42
43/// Immutable identity of the reference profile a renderer reproduces.
44// Hidden, like `CompatibilityRevision`.
45#[doc(hidden)]
46#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
47pub struct ReferenceProfileIdentity {
48    compatibility_revision: CompatibilityRevision,
49    excalidraw_release: &'static str,
50    reference_profile_digest: &'static str,
51}
52
53impl ReferenceProfileIdentity {
54    pub(crate) const fn new(
55        compatibility_revision: CompatibilityRevision,
56        excalidraw_release: &'static str,
57        reference_profile_digest: &'static str,
58    ) -> Self {
59        Self {
60            compatibility_revision,
61            excalidraw_release,
62            reference_profile_digest,
63        }
64    }
65
66    /// Returns the compatibility revision bound to this reference profile.
67    pub const fn compatibility_revision(&self) -> CompatibilityRevision {
68        self.compatibility_revision
69    }
70
71    /// Returns the pinned official Excalidraw release.
72    pub const fn excalidraw_release(&self) -> &'static str {
73        self.excalidraw_release
74    }
75
76    /// Returns the canonical SHA-256 digest of the reference profile.
77    pub const fn reference_profile_digest(&self) -> &'static str {
78        self.reference_profile_digest
79    }
80}
81
82/// Immutable renderer for this release's compatibility revision.
83///
84/// A renderer holds no per-render state: it is cheap to construct, `Clone`,
85/// `Send` and `Sync`, and one instance can serve concurrent renders.
86#[derive(Clone, Debug)]
87pub struct Renderer {
88    catalog: &'static crate::catalog::RevisionCatalog,
89}
90
91impl Renderer {
92    /// Constructs a renderer.
93    pub fn new() -> Self {
94        Self {
95            catalog: &crate::catalog::REVISION_1,
96        }
97    }
98
99    /// Returns the reference profile scene exports reproduce.
100    #[doc(hidden)]
101    pub const fn profile_identity(&self) -> &'static ReferenceProfileIdentity {
102        self.catalog.profile()
103    }
104
105    /// Returns the reference profile frame exports reproduce. It shares the
106    /// scene profile's revision and release but has its own digest.
107    #[doc(hidden)]
108    pub const fn frame_profile_identity(&self) -> &'static ReferenceProfileIdentity {
109        self.catalog.frame_profile()
110    }
111
112    /// Renders one admitted scene into the request's selected output format.
113    ///
114    /// # Errors
115    ///
116    /// Returns a [`RenderError`] when no complete artifact can be produced.
117    /// Its [`category`](RenderError::category) says why:
118    ///
119    /// - [`Input`](RenderErrorCategory::Input): the scene has no finite
120    ///   bounds (`scene.unbounded-geometry`).
121    /// - [`Capability`](RenderErrorCategory::Capability): a font the scene
122    ///   needs is not in this build (`render.font-unavailable`; see the
123    ///   crate's `cjk` feature).
124    /// - [`ResourceLimit`](RenderErrorCategory::ResourceLimit): the render
125    ///   crosses one of the request's [`ResourceLimits`](crate::ResourceLimits)
126    ///   (a `resource.*` code), or a required allocation failed
127    ///   (`resource.memory-unavailable`).
128    /// - [`NoMeaningfulArtifact`](RenderErrorCategory::NoMeaningfulArtifact):
129    ///   a PDF raster fallback was forbidden by the request or could not be
130    ///   isolated (`pdf.raster-fallback-*`).
131    /// - [`Encoding`](RenderErrorCategory::Encoding): writing the artifact
132    ///   failed (an `encode.*` code).
133    /// - [`Internal`](RenderErrorCategory::Internal): a renderer invariant
134    ///   failed (`render.internal-failure`).
135    ///
136    /// The error carries the complete [report](RenderError::report)
137    /// accumulated before the failure.
138    pub fn render(
139        &self,
140        scene: &SceneDocument,
141        request: &RenderRequest,
142    ) -> Result<RenderOutcome, RenderError> {
143        crate::render::render(self.catalog, scene, request)
144    }
145
146    /// Renders one discovered frame using reference frame-export semantics.
147    ///
148    /// # Errors
149    ///
150    /// Returns a [`RenderError`] in the same categories as
151    /// [`render`](Self::render). A frame whose viewport falls outside
152    /// `max_coordinate_magnitude` fails with
153    /// `resource.coordinate-range-exceeded`.
154    pub fn render_frame(
155        &self,
156        frame: crate::FrameRef<'_>,
157        request: &crate::FrameRenderRequest,
158    ) -> Result<RenderOutcome, RenderError> {
159        crate::render::render_frame(self.catalog, frame, request)
160    }
161
162    /// Starts lazy source-ordered rendering for selected frames.
163    ///
164    /// # Errors
165    ///
166    /// Returns a [`FrameBatchError`](crate::FrameBatchError) before rendering
167    /// anything when a selected frame belongs to another scene document
168    /// (`frame-batch.foreign-target`), when the same frame is selected twice
169    /// (`frame-batch.duplicate-target`), or when the preparation the frames
170    /// share cannot be completed (`frame-batch.shared-preparation-failed`).
171    /// Each frame's own render failure is reported by its
172    /// [`FrameRenderEntry`](crate::FrameRenderEntry) instead.
173    pub fn render_frames<'scene, 'request>(
174        &self,
175        scene: &'scene SceneDocument,
176        frames: impl IntoIterator<Item = crate::FrameRef<'scene>>,
177        request: &'request crate::FrameRenderRequest,
178    ) -> Result<crate::FrameRenderCursor<'scene, 'request>, crate::FrameBatchError> {
179        crate::FrameRenderCursor::new(self.catalog, scene, frames, request)
180    }
181}
182
183impl Default for Renderer {
184    fn default() -> Self {
185        Self::new()
186    }
187}
188
189/// Stable family of a typed renderer failure.
190#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
191#[non_exhaustive]
192#[serde(rename_all = "snake_case")]
193pub enum ErrorFamily {
194    /// Scene-document bytes could not be admitted.
195    SceneParse,
196    /// A render request is invalid.
197    Request,
198    /// Rendering failed after request validation.
199    Render,
200    /// A lazy frame batch could not be constructed.
201    FrameBatch,
202}
203
204impl ErrorFamily {
205    /// Returns the stable serialized spelling.
206    pub const fn as_str(self) -> &'static str {
207        match self {
208            Self::SceneParse => "scene_parse",
209            Self::Request => "request",
210            Self::Render => "render",
211            Self::FrameBatch => "frame_batch",
212        }
213    }
214}
215
216impl fmt::Display for ErrorFamily {
217    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
218        formatter.write_str(self.as_str())
219    }
220}
221
222/// String-backed stable error code.
223#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
224#[serde(transparent)]
225pub struct ErrorCode(&'static str);
226
227impl ErrorCode {
228    /// Request padding is negative or non-finite.
229    pub const REQUEST_INVALID_PADDING: Self = Self("request.invalid-padding");
230
231    /// Request scale is non-positive or non-finite.
232    pub const REQUEST_INVALID_SCALE: Self = Self("request.invalid-scale");
233
234    /// Request maximum dimension is non-positive or non-finite.
235    pub const REQUEST_INVALID_MAX_DIMENSION: Self = Self("request.invalid-max-dimension");
236
237    /// Format-specific options were supplied for another output format.
238    pub const REQUEST_INCOMPATIBLE_FORMAT_OPTIONS: Self =
239        Self("request.incompatible-format-options");
240
241    /// A supplemental asset has an empty scene file ID.
242    pub const REQUEST_EMPTY_ASSET_ID: Self = Self("request.empty-asset-id");
243
244    /// A supplemental scene file ID occurs more than once.
245    pub const REQUEST_DUPLICATE_ASSET_ID: Self = Self("request.duplicate-asset-id");
246
247    /// A supplemental asset advisory media type is malformed.
248    pub const REQUEST_INVALID_ASSET_MEDIA_TYPE: Self = Self("request.invalid-asset-media-type");
249
250    /// A semantic resource ceiling is zero, invalid, inconsistent, or too large.
251    pub const REQUEST_INVALID_RESOURCE_LIMIT: Self = Self("request.invalid-resource-limit");
252
253    /// The requested output format's Cargo feature (`png` or `pdf`) is not
254    /// enabled in this build.
255    pub const REQUEST_FORMAT_UNAVAILABLE: Self = Self("request.format-unavailable");
256
257    /// Finite whole-scene geometry could not be established.
258    pub const SCENE_UNBOUNDED_GEOMETRY: Self = Self("scene.unbounded-geometry");
259    /// A required PDF raster fallback was forbidden by the request.
260    pub const PDF_RASTER_FALLBACK_FORBIDDEN: Self = Self("pdf.raster-fallback-forbidden");
261    /// A PDF raster fallback could not be isolated safely.
262    pub const PDF_RASTER_FALLBACK_UNISOLATABLE: Self = Self("pdf.raster-fallback-unisolatable");
263    /// No meaningful complete artifact could be produced.
264    pub const RENDER_NO_MEANINGFUL_ARTIFACT: Self = Self("render.no-meaningful-artifact");
265    /// A demanded bundled font is unavailable in this build.
266    pub const RENDER_FONT_UNAVAILABLE: Self = Self("render.font-unavailable");
267    /// SVG encoding failed.
268    pub const ENCODE_SVG_FAILED: Self = Self("encode.svg-failed");
269    /// A required direct-SVG font subset could not be produced or validated.
270    pub const ENCODE_SVG_FONT_SUBSET_FAILED: Self = Self("encode.svg-font-subset-failed");
271    /// PNG encoding failed.
272    pub const ENCODE_PNG_FAILED: Self = Self("encode.png-failed");
273    /// PDF encoding failed.
274    pub const ENCODE_PDF_FAILED: Self = Self("encode.pdf-failed");
275    /// An internal renderer invariant failed.
276    pub const RENDER_INTERNAL_FAILURE: Self = Self("render.internal-failure");
277    /// Admitted scene bytes exceed the configured semantic ceiling.
278    pub const RESOURCE_SCENE_BYTES_EXCEEDED: Self = Self("resource.scene-bytes-exceeded");
279    /// Source element occurrences exceed the configured ceiling.
280    pub const RESOURCE_ELEMENT_COUNT_EXCEEDED: Self = Self("resource.element-count-exceeded");
281    /// Asset payload occurrences exceed the configured ceiling.
282    pub const RESOURCE_ASSET_COUNT_EXCEEDED: Self = Self("resource.asset-count-exceeded");
283    /// Aggregate asset bytes exceed the configured ceiling.
284    pub const RESOURCE_TOTAL_ASSET_BYTES_EXCEEDED: Self =
285        Self("resource.total-asset-bytes-exceeded");
286    /// Relationship traversal exceeds the configured depth.
287    pub const RESOURCE_RELATIONSHIP_DEPTH_EXCEEDED: Self =
288        Self("resource.relationship-depth-exceeded");
289    /// Aggregate geometry exceeds the configured segment ceiling.
290    pub const RESOURCE_TOTAL_GEOMETRY_EXCEEDED: Self = Self("resource.total-geometry-exceeded");
291    /// Aggregate text exceeds the configured scalar ceiling.
292    pub const RESOURCE_TOTAL_TEXT_EXCEEDED: Self = Self("resource.total-text-exceeded");
293    /// Aggregate decoded pixels exceed the configured ceiling.
294    pub const RESOURCE_TOTAL_DECODED_PIXELS_EXCEEDED: Self =
295        Self("resource.total-decoded-pixels-exceeded");
296    /// The required main raster surface exceeds a side or area ceiling.
297    pub const RESOURCE_RASTER_SURFACE_EXCEEDED: Self = Self("resource.raster-surface-exceeded");
298    /// Aggregate fallback rasterization exceeds the configured pixel ceiling.
299    pub const RESOURCE_TOTAL_RASTERIZED_PIXELS_EXCEEDED: Self =
300        Self("resource.total-rasterized-pixels-exceeded");
301    /// Logical or scaled SVG dimensions exceed the configured ceiling.
302    pub const RESOURCE_SVG_DIMENSIONS_EXCEEDED: Self = Self("resource.svg-dimensions-exceeded");
303    /// PDF page dimensions exceed the configured point ceiling.
304    pub const RESOURCE_PDF_PAGE_DIMENSIONS_EXCEEDED: Self =
305        Self("resource.pdf-page-dimensions-exceeded");
306    /// Diagnostic occurrences exceed the configured ceiling.
307    pub const RESOURCE_DIAGNOSTIC_COUNT_EXCEEDED: Self = Self("resource.diagnostic-count-exceeded");
308    /// Canonical logical working bytes exceed the configured ceiling.
309    pub const RESOURCE_WORKING_BYTES_EXCEEDED: Self = Self("resource.working-bytes-exceeded");
310    /// Complete artifact bytes exceed the configured ceiling.
311    pub const RESOURCE_OUTPUT_BYTES_EXCEEDED: Self = Self("resource.output-bytes-exceeded");
312    /// Aggregate semantic work exceeds the configured ceiling.
313    pub const RESOURCE_TOTAL_WORK_EXCEEDED: Self = Self("resource.total-work-exceeded");
314    /// A selected frame has no viewport within the coordinate ceiling.
315    pub const RESOURCE_COORDINATE_RANGE_EXCEEDED: Self = Self("resource.coordinate-range-exceeded");
316    /// A required physical allocation was unavailable.
317    pub const RESOURCE_MEMORY_UNAVAILABLE: Self = Self("resource.memory-unavailable");
318
319    /// A selected frame belongs to another scene document.
320    pub const FRAME_BATCH_FOREIGN_TARGET: Self = Self("frame-batch.foreign-target");
321
322    /// The same frame occurrence was selected more than once.
323    pub const FRAME_BATCH_DUPLICATE_TARGET: Self = Self("frame-batch.duplicate-target");
324
325    /// Shared target-neutral frame preparation could not be completed.
326    pub const FRAME_BATCH_SHARED_PREPARATION_FAILED: Self =
327        Self("frame-batch.shared-preparation-failed");
328
329    /// The scene-document bytes are not valid UTF-8.
330    pub const SCENE_INVALID_UTF8: Self = Self("scene.invalid-utf8");
331
332    /// The scene-document bytes do not contain valid JSON syntax.
333    pub const SCENE_INVALID_JSON: Self = Self("scene.invalid-json");
334
335    /// The scene envelope does not have the required version-2 shape.
336    pub const SCENE_INVALID_ENVELOPE: Self = Self("scene.invalid-envelope");
337
338    /// The input is a recognized file that is not a scene document: a PNG,
339    /// SVG or PDF export, or an Excalidraw library or clipboard payload.
340    pub const SCENE_UNSUPPORTED_INPUT: Self = Self("scene.unsupported-input");
341
342    /// The scene-document bytes exceed the fixed parser input ceiling.
343    pub const SCENE_INPUT_TOO_LARGE: Self = Self("scene.input-too-large");
344
345    /// The JSON nesting exceeds the fixed parser depth ceiling.
346    pub const SCENE_JSON_DEPTH_EXCEEDED: Self = Self("scene.json-depth-exceeded");
347
348    /// The JSON structure exceeds the fixed parser entry ceiling.
349    pub const SCENE_JSON_STRUCTURE_EXCEEDED: Self = Self("scene.json-structure-exceeded");
350
351    /// A decoded string or the decoded-string aggregate exceeds its ceiling.
352    pub const SCENE_STRING_TOO_LARGE: Self = Self("scene.string-too-large");
353
354    /// An object key exceeds the fixed parser key ceiling.
355    pub const SCENE_OBJECT_KEY_TOO_LARGE: Self = Self("scene.object-key-too-large");
356
357    /// A number token exceeds the fixed parser token ceiling.
358    pub const SCENE_NUMBER_TOKEN_TOO_LARGE: Self = Self("scene.number-token-too-large");
359
360    /// A later object member duplicates an earlier decoded key.
361    pub const SCENE_DUPLICATE_OBJECT_KEY: Self = Self("scene.duplicate-object-key");
362
363    /// Canonical parser live bytes exceed the fixed parser memory ceiling.
364    pub const SCENE_PARSE_MEMORY_EXCEEDED: Self = Self("scene.parse-memory-exceeded");
365
366    /// Returns the stable serialized spelling.
367    pub const fn as_str(self) -> &'static str {
368        self.0
369    }
370}
371
372impl fmt::Display for ErrorCode {
373    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
374        formatter.write_str(self.as_str())
375    }
376}
377
378/// Typed details attached to an [`ErrorDescriptor`] or a
379/// [`CompatibilityDiagnostic`](crate::CompatibilityDiagnostic).
380///
381/// Errors and diagnostics share one detail vocabulary; each code fixes
382/// which variant, if any, it carries.
383#[derive(Clone, Debug, PartialEq, Serialize)]
384#[non_exhaustive]
385#[serde(tag = "kind", rename_all = "snake_case")]
386pub enum DiagnosticDetails {
387    /// Identifies a fixed scene-parser ceiling crossing.
388    #[non_exhaustive]
389    ParseLimit {
390        /// The canonical parser-limit field.
391        field: &'static str,
392        /// The fixed configured ceiling.
393        configured: u64,
394        /// The unit measured by this ceiling.
395        value_kind: ValueKind,
396        /// The measured attempted or lower-bound value.
397        measurement: Measurement,
398    },
399    /// Identifies the rejected request field and any applicable asset or option.
400    #[non_exhaustive]
401    RequestValue {
402        /// The canonical request field.
403        field: &'static str,
404        /// The applicable supplemental asset ID.
405        asset_id: Option<String>,
406        /// The applicable format-option name.
407        option: Option<&'static str>,
408    },
409    /// Describes a compatibility recovery and optional renderer recipe.
410    #[non_exhaustive]
411    Compatibility {
412        /// The recovery applied to retain a meaningful artifact.
413        recovery: Recovery,
414        /// The stable renderer recipe identity, when applicable.
415        recipe_id: Option<String>,
416    },
417    /// Describes an authoritative or supplemental asset recovery.
418    #[non_exhaustive]
419    Asset {
420        /// Scene file ID of the affected asset.
421        asset_id: String,
422        /// Caller-claimed advisory media type.
423        claimed_media_type: Option<String>,
424        /// Media type established from the payload bytes.
425        validated_media_type: Option<String>,
426        /// Recovery applied to the asset occurrence.
427        recovery: Recovery,
428        /// Stable renderer recipe identity, when applicable.
429        recipe_id: Option<String>,
430    },
431    /// Describes a fatal or recoverable semantic resource crossing.
432    #[non_exhaustive]
433    ResourceLimit {
434        /// Canonical resource-limit field.
435        field: &'static str,
436        /// Configured semantic ceiling.
437        configured: ResourceValue,
438        /// Unit measured by the ceiling.
439        value_kind: ValueKind,
440        /// Measured attempted or lower-bound value.
441        measurement: ResourceMeasurement,
442        /// Recovery applied for a local crossing; null for a fatal crossing.
443        recovery: Option<Recovery>,
444    },
445    /// Identifies a physical allocation that could not be satisfied.
446    #[non_exhaustive]
447    MemoryUnavailable {
448        /// Stable category of allocation subject.
449        subject_kind: String,
450        /// Stable identity of the allocation subject.
451        subject: String,
452    },
453    /// Identifies one missing Unicode scalar and its substitution.
454    #[non_exhaustive]
455    MissingGlyph {
456        /// Missing Unicode scalar value.
457        codepoint: u32,
458        /// Required substitution recovery.
459        recovery: Recovery,
460    },
461    /// Identifies a demanded bundled font unavailable in this build.
462    #[non_exhaustive]
463    FontUnavailable {
464        /// Resolved bundled family required by the render.
465        resolved_family: String,
466        /// Official source chunk identity.
467        asset_identity: String,
468    },
469    /// Identifies a fatal direct-SVG font subset operation.
470    #[non_exhaustive]
471    SvgFontSubsetFailure {
472        /// First failed subset pipeline stage.
473        stage: SvgFontSubsetStage,
474        /// Resolved bundled family being derived.
475        resolved_family: String,
476        /// Official source chunk identity.
477        asset_identity: String,
478    },
479    /// Identifies an optional metadata component failure.
480    #[non_exhaustive]
481    Metadata {
482        /// Metadata component being processed.
483        component: MetadataComponent,
484        /// Operation that failed.
485        operation: MetadataOperation,
486        /// Required optional-metadata recovery.
487        recovery: Recovery,
488    },
489    /// Identifies one nested source occurrence and recovery.
490    #[non_exhaustive]
491    NestedSource {
492        /// Asset occurrence chain from the scene to the nested source.
493        asset_chain: Vec<AssetChainEntry>,
494        /// Canonical nested paths that caused the event.
495        paths: Vec<NestedPath>,
496        /// Recovery applied to the nested source.
497        recovery: Recovery,
498    },
499    /// A measured resource crossing with its exact nested source identity.
500    #[non_exhaustive]
501    NestedResourceLimit {
502        /// Canonical limit field and configured ceiling.
503        field: &'static str,
504        /// Configured semantic ceiling.
505        configured: ResourceValue,
506        /// Unit measured by the ceiling.
507        value_kind: ValueKind,
508        /// Attempted or bounded lower-bound measurement.
509        measurement: ResourceMeasurement,
510        /// Enclosing authoritative asset occurrences.
511        asset_chain: Vec<AssetChainEntry>,
512        /// Exact rejected nested resource slot.
513        paths: Vec<NestedPath>,
514        /// Placeholder or omission selected at that slot.
515        recovery: Recovery,
516        /// Manifested nested-resource recipe for a placeholder, otherwise null.
517        recipe_id: Option<String>,
518    },
519    /// Identifies a repaired media claim on a nested image reference.
520    #[non_exhaustive]
521    NestedAssetClaim {
522        /// Asset occurrence chain from the scene to the nested source.
523        asset_chain: Vec<AssetChainEntry>,
524        /// Canonical path to the nested reference whose claim was repaired.
525        paths: Vec<NestedPath>,
526        /// Original media type stated by the nested data URL.
527        claimed_media_type: String,
528        /// Media type validated from the nested payload bytes.
529        validated_media_type: String,
530        /// Required repair recovery; the nested content remains present.
531        recovery: Recovery,
532    },
533    /// Describes an emitted or fatal PDF raster fallback closure.
534    #[non_exhaustive]
535    PdfRasterFallback {
536        /// Rasterized for a warning; null for a fatal fallback failure.
537        recovery: Option<Recovery>,
538        /// Logical closure bounds, when safely established.
539        logical_bounds: Option<LogicalBounds>,
540        /// Final fallback pixel width, when safely established.
541        pixel_width: Option<u64>,
542        /// Final fallback pixel height, when safely established.
543        pixel_height: Option<u64>,
544        /// Canonical nested paths that caused the closure.
545        causes: Vec<NestedPath>,
546    },
547}
548
549impl DiagnosticDetails {
550    /// The recovery the details carry: the fixed one of a compatibility,
551    /// asset, missing-glyph, metadata or nested-source detail, the optional
552    /// one of a resource limit or PDF raster fallback (null when fatal), and
553    /// none for the detail kinds that describe no recovery.
554    pub(crate) fn recovery(&self) -> Option<Recovery> {
555        match self {
556            Self::Compatibility { recovery, .. }
557            | Self::Asset { recovery, .. }
558            | Self::MissingGlyph { recovery, .. }
559            | Self::Metadata { recovery, .. }
560            | Self::NestedSource { recovery, .. }
561            | Self::NestedResourceLimit { recovery, .. }
562            | Self::NestedAssetClaim { recovery, .. } => Some(*recovery),
563            Self::ResourceLimit { recovery, .. } | Self::PdfRasterFallback { recovery, .. } => {
564                *recovery
565            }
566            Self::ParseLimit { .. }
567            | Self::RequestValue { .. }
568            | Self::MemoryUnavailable { .. }
569            | Self::FontUnavailable { .. }
570            | Self::SvgFontSubsetFailure { .. } => None,
571        }
572    }
573
574    /// The measured value and configured ceiling of a parser or resource limit
575    /// crossing, which a failure's human message appends so that it says what
576    /// its details say.
577    pub(crate) fn limit_crossing(&self) -> Option<LimitCrossing> {
578        let (field, configured, value_kind, measurement) = match self {
579            Self::ParseLimit {
580                field,
581                configured,
582                value_kind,
583                measurement,
584            } => (
585                *field,
586                ResourceValue::Integer(*configured),
587                *value_kind,
588                match *measurement {
589                    Measurement::Attempted { value } => Measurement::Attempted {
590                        value: ResourceValue::Integer(value),
591                    },
592                    Measurement::AtLeast { value } => Measurement::AtLeast {
593                        value: ResourceValue::Integer(value),
594                    },
595                },
596            ),
597            Self::ResourceLimit {
598                field,
599                configured,
600                value_kind,
601                measurement,
602                ..
603            }
604            | Self::NestedResourceLimit {
605                field,
606                configured,
607                value_kind,
608                measurement,
609                ..
610            } => (*field, *configured, *value_kind, *measurement),
611            _ => return None,
612        };
613        Some(LimitCrossing {
614            field,
615            configured,
616            value_kind,
617            measurement,
618        })
619    }
620
621    /// The fallback recipe identity the details name, when the compatibility
622    /// or asset recovery painted a placeholder.
623    pub(crate) fn recipe_id(&self) -> Option<&str> {
624        match self {
625            Self::Compatibility { recipe_id, .. }
626            | Self::Asset { recipe_id, .. }
627            | Self::NestedResourceLimit { recipe_id, .. } => recipe_id.as_deref(),
628            Self::ParseLimit { .. }
629            | Self::RequestValue { .. }
630            | Self::ResourceLimit { .. }
631            | Self::MemoryUnavailable { .. }
632            | Self::MissingGlyph { .. }
633            | Self::FontUnavailable { .. }
634            | Self::SvgFontSubsetFailure { .. }
635            | Self::Metadata { .. }
636            | Self::NestedSource { .. }
637            | Self::NestedAssetClaim { .. }
638            | Self::PdfRasterFallback { .. } => None,
639        }
640    }
641}
642
643/// Stable stage vocabulary for a direct-SVG font subset failure.
644#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
645#[non_exhaustive]
646#[serde(rename_all = "snake_case")]
647pub enum SvgFontSubsetStage {
648    /// WOFF2 source decoding failed.
649    Decode,
650    /// Subset planning failed.
651    Plan,
652    /// Subset sfnt construction failed.
653    Sfnt,
654    /// WOFF2 encoding failed.
655    Encode,
656    /// Generated font validation failed.
657    Validate,
658}
659
660/// Stable recovery applied after diagnosed compatibility loss.
661#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
662#[non_exhaustive]
663#[serde(rename_all = "snake_case")]
664pub enum Recovery {
665    /// Invalid or absent input was deterministically repaired.
666    Repaired,
667    /// Unsupported input was ignored.
668    Ignored,
669    /// Content was replaced by a compatibility placeholder.
670    Placeholder,
671    /// Content was omitted.
672    Omitted,
673    /// Unsafe or incompatible content was removed.
674    Removed,
675    /// Content was substituted deterministically.
676    Substituted,
677    /// Animation was flattened to a static frame.
678    Flattened,
679    /// A permitted isolated closure was rasterized.
680    Rasterized,
681    /// Optional metadata was omitted.
682    MetadataOmitted,
683}
684
685impl Recovery {
686    /// Returns the stable serialized spelling.
687    pub const fn as_str(self) -> &'static str {
688        match self {
689            Self::Repaired => "repaired",
690            Self::Ignored => "ignored",
691            Self::Placeholder => "placeholder",
692            Self::Omitted => "omitted",
693            Self::Removed => "removed",
694            Self::Substituted => "substituted",
695            Self::Flattened => "flattened",
696            Self::Rasterized => "rasterized",
697            Self::MetadataOmitted => "metadata_omitted",
698        }
699    }
700
701    /// The recovery the scene-surface manifest spells `recovery`, if any.
702    pub(crate) fn from_manifest(recovery: &str) -> Option<Self> {
703        match recovery {
704            "repaired" => Some(Self::Repaired),
705            "ignored" => Some(Self::Ignored),
706            "placeholder" => Some(Self::Placeholder),
707            "omitted" => Some(Self::Omitted),
708            "removed" => Some(Self::Removed),
709            "substituted" => Some(Self::Substituted),
710            "flattened" => Some(Self::Flattened),
711            "rasterized" => Some(Self::Rasterized),
712            "metadata_omitted" => Some(Self::MetadataOmitted),
713            _ => None,
714        }
715    }
716}
717
718impl fmt::Display for Recovery {
719    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
720        formatter.write_str(self.as_str())
721    }
722}
723
724/// One limit crossing in human form: `needs 400 pixels, but
725/// max_raster_surface_pixels is 399`.
726pub(crate) struct LimitCrossing {
727    field: &'static str,
728    configured: ResourceValue,
729    value_kind: ValueKind,
730    measurement: ResourceMeasurement,
731}
732
733impl fmt::Display for LimitCrossing {
734    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
735        let (bound, value) = match self.measurement {
736            Measurement::Attempted { value } => ("", value),
737            Measurement::AtLeast { value } => ("at least ", value),
738        };
739        let unit = match self.value_kind {
740            ValueKind::Bytes => " bytes",
741            ValueKind::Pixels => " pixels",
742            ValueKind::WorkUnits => " work units",
743            ValueKind::Depth => " levels",
744            ValueKind::Count | ValueKind::Dimension | ValueKind::Coordinate => "",
745        };
746        write!(
747            formatter,
748            "needs {bound}{}{unit}, but {} is {}",
749            HumanValue(value),
750            self.field,
751            HumanValue(self.configured)
752        )
753    }
754}
755
756struct HumanValue(ResourceValue);
757
758impl fmt::Display for HumanValue {
759    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
760        match self.0 {
761            ResourceValue::Integer(value) => value.fmt(formatter),
762            ResourceValue::Finite(value) => value.get().fmt(formatter),
763        }
764    }
765}
766
767/// Unit used by a stable limit measurement.
768#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
769#[non_exhaustive]
770#[serde(rename_all = "snake_case")]
771pub enum ValueKind {
772    /// A byte count.
773    Bytes,
774    /// A structural-entry count.
775    Count,
776    /// A nesting depth.
777    Depth,
778    /// A pixel count.
779    Pixels,
780    /// A deterministic work-unit count.
781    WorkUnits,
782    /// A dimension.
783    Dimension,
784    /// A coordinate magnitude.
785    Coordinate,
786}
787
788/// Stable attempted or lower-bound measurement.
789#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
790#[non_exhaustive]
791#[serde(tag = "kind", rename_all = "snake_case")]
792pub enum Measurement<T = u64> {
793    /// The exact attempted value is known.
794    Attempted {
795        /// The exact attempted value.
796        value: T,
797    },
798    /// Bounded processing stopped after establishing this lower bound.
799    AtLeast {
800        /// The established lower bound.
801        value: T,
802    },
803}
804
805/// Finite schema-1 number with canonical JSON serialization.
806#[derive(Clone, Copy, Debug, PartialEq)]
807pub struct FiniteValue(f64);
808
809impl FiniteValue {
810    /// Creates a value only when the supplied number is finite.
811    pub(crate) fn new(value: f64) -> Option<Self> {
812        value.is_finite().then_some(Self(value))
813    }
814
815    /// Returns the finite binary64 value.
816    pub const fn get(self) -> f64 {
817        self.0
818    }
819}
820
821impl Serialize for FiniteValue {
822    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
823    where
824        S: serde::Serializer,
825    {
826        let value = self.0;
827        if value == 0.0 {
828            serializer.serialize_u8(0)
829        } else if (0.0..=9_007_199_254_740_992.0).contains(&value) && (value as u64) as f64 == value
830        {
831            serializer.serialize_u64(value as u64)
832        } else if (-9_007_199_254_740_992.0..0.0).contains(&value) && (value as i64) as f64 == value
833        {
834            serializer.serialize_i64(value as i64)
835        } else {
836            serializer.serialize_f64(value)
837        }
838    }
839}
840
841/// Nonnegative integer or finite number used by semantic resource details.
842#[derive(Clone, Copy, Debug, PartialEq)]
843#[non_exhaustive]
844pub enum ResourceValue {
845    /// An integer byte, count, depth, pixel, or work value.
846    Integer(u64),
847    /// A finite dimension or coordinate value.
848    Finite(FiniteValue),
849}
850
851impl Serialize for ResourceValue {
852    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
853    where
854        S: serde::Serializer,
855    {
856        match *self {
857            Self::Integer(value) => serializer.serialize_u64(value),
858            Self::Finite(value) => value.serialize(serializer),
859        }
860    }
861}
862
863/// Stable measurement form for integer, dimension, or coordinate resources.
864pub type ResourceMeasurement = Measurement<ResourceValue>;
865
866/// One entry in a nested asset occurrence chain.
867#[derive(Clone, Debug, PartialEq, Serialize)]
868pub struct AssetChainEntry {
869    pub(crate) asset_id: Option<String>,
870    pub(crate) occurrence: u64,
871}
872
873impl AssetChainEntry {
874    /// Returns the scene file ID, or null for an anonymous nested occurrence.
875    pub fn asset_id(&self) -> Option<&str> {
876        self.asset_id.as_deref()
877    }
878
879    /// Returns the zero-based occurrence ordinal.
880    pub const fn occurrence(&self) -> u64 {
881        self.occurrence
882    }
883}
884
885/// Kind of one canonical nested-source path segment.
886#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
887#[non_exhaustive]
888#[serde(rename_all = "snake_case")]
889pub enum NestedSegmentKind {
890    /// Nested document node.
891    Node,
892    /// Nested node attribute.
893    Attribute,
894    /// CSS rule.
895    CssRule,
896    /// CSS declaration.
897    CssDeclaration,
898    /// Nested resource slot.
899    ResourceSlot,
900    /// Effect input.
901    EffectInput,
902}
903
904/// One segment of a canonical nested-source path.
905#[derive(Clone, Debug, PartialEq, Serialize)]
906pub struct NestedPathSegment {
907    pub(crate) kind: NestedSegmentKind,
908    pub(crate) ordinal: u64,
909    pub(crate) name: Option<String>,
910}
911
912impl NestedPathSegment {
913    /// Returns the nested segment kind.
914    pub const fn kind(&self) -> NestedSegmentKind {
915        self.kind
916    }
917
918    /// Returns the zero-based segment ordinal.
919    pub const fn ordinal(&self) -> u64 {
920        self.ordinal
921    }
922
923    /// Returns the optional source name.
924    pub fn name(&self) -> Option<&str> {
925        self.name.as_deref()
926    }
927}
928
929/// Canonical identity path within one nested source.
930#[derive(Clone, Debug, PartialEq, Serialize)]
931pub struct NestedPath {
932    pub(crate) segments: Vec<NestedPathSegment>,
933}
934
935impl NestedPath {
936    /// Returns the canonical nested path segments.
937    pub fn segments(&self) -> &[NestedPathSegment] {
938        &self.segments
939    }
940}
941
942/// Finite logical bounds of a PDF raster fallback closure.
943#[derive(Clone, Copy, Debug, PartialEq)]
944pub struct LogicalBounds {
945    pub(crate) x: FiniteValue,
946    pub(crate) y: FiniteValue,
947    pub(crate) width: FiniteValue,
948    pub(crate) height: FiniteValue,
949}
950
951impl Serialize for LogicalBounds {
952    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
953    where
954        S: serde::Serializer,
955    {
956        let mut bounds = serializer.serialize_struct("LogicalBounds", 4)?;
957        bounds.serialize_field("x", &self.x)?;
958        bounds.serialize_field("y", &self.y)?;
959        bounds.serialize_field("width", &self.width)?;
960        bounds.serialize_field("height", &self.height)?;
961        bounds.end()
962    }
963}
964
965impl LogicalBounds {
966    /// Returns the logical x coordinate.
967    pub const fn x(&self) -> f64 {
968        self.x.get()
969    }
970
971    /// Returns the logical y coordinate.
972    pub const fn y(&self) -> f64 {
973        self.y.get()
974    }
975
976    /// Returns the finite logical width.
977    pub const fn width(&self) -> f64 {
978        self.width.get()
979    }
980
981    /// Returns the finite logical height.
982    pub const fn height(&self) -> f64 {
983        self.height.get()
984    }
985}
986
987/// Optional artifact metadata component.
988#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
989#[non_exhaustive]
990#[serde(rename_all = "snake_case")]
991pub enum MetadataComponent {
992    /// Renderer provenance.
993    Provenance,
994    /// Pre-metadata compatibility diagnostic summary.
995    DiagnosticSummary,
996    /// Canonicalized original scene document.
997    EmbeddedScene,
998    /// Format-neutral artifact manifest.
999    Manifest,
1000}
1001
1002/// Operation performed on an optional metadata component.
1003#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
1004#[non_exhaustive]
1005#[serde(rename_all = "snake_case")]
1006pub enum MetadataOperation {
1007    /// Canonical serialization.
1008    Serialization,
1009    /// Deterministic compression.
1010    Compression,
1011    /// Format-specific embedding.
1012    Embedding,
1013}
1014
1015/// Stable serializable view of a typed renderer failure.
1016#[derive(Clone, Debug, PartialEq, Serialize)]
1017pub struct ErrorDescriptor {
1018    family: ErrorFamily,
1019    category: Option<RenderErrorCategory>,
1020    code: ErrorCode,
1021    message: String,
1022    format: Option<OutputFormat>,
1023    element_id: Option<String>,
1024    json_pointer: Option<String>,
1025    details: Option<DiagnosticDetails>,
1026}
1027
1028impl ErrorDescriptor {
1029    pub(crate) fn scene_parse(
1030        code: ErrorCode,
1031        message: String,
1032        json_pointer: Option<String>,
1033        details: Option<DiagnosticDetails>,
1034    ) -> Self {
1035        Self {
1036            family: ErrorFamily::SceneParse,
1037            category: None,
1038            code,
1039            message,
1040            format: None,
1041            element_id: None,
1042            json_pointer,
1043            details,
1044        }
1045    }
1046
1047    pub(crate) fn request(code: ErrorCode, message: String, details: DiagnosticDetails) -> Self {
1048        Self {
1049            family: ErrorFamily::Request,
1050            category: None,
1051            code,
1052            message,
1053            format: None,
1054            element_id: None,
1055            json_pointer: None,
1056            details: Some(details),
1057        }
1058    }
1059
1060    pub(crate) fn render(
1061        category: RenderErrorCategory,
1062        code: ErrorCode,
1063        message: String,
1064        format: OutputFormat,
1065        element_id: Option<String>,
1066        json_pointer: String,
1067        details: Option<DiagnosticDetails>,
1068    ) -> Self {
1069        Self {
1070            family: ErrorFamily::Render,
1071            category: Some(category),
1072            code,
1073            message,
1074            format: Some(format),
1075            element_id,
1076            json_pointer: Some(json_pointer),
1077            details,
1078        }
1079    }
1080
1081    pub(crate) fn frame_batch(code: ErrorCode, message: String) -> Self {
1082        Self {
1083            family: ErrorFamily::FrameBatch,
1084            category: None,
1085            code,
1086            message,
1087            format: None,
1088            element_id: None,
1089            json_pointer: None,
1090            details: None,
1091        }
1092    }
1093
1094    /// Returns the typed failure family.
1095    pub const fn family(&self) -> ErrorFamily {
1096        self.family
1097    }
1098
1099    /// Returns the render-failure category, when applicable.
1100    pub const fn category(&self) -> Option<RenderErrorCategory> {
1101        self.category
1102    }
1103
1104    /// Returns the stable machine-readable error code.
1105    pub const fn code(&self) -> ErrorCode {
1106        self.code
1107    }
1108
1109    /// Returns the human-readable error message.
1110    pub fn message(&self) -> &str {
1111        &self.message
1112    }
1113
1114    /// Returns the selected output format, when applicable.
1115    pub const fn format(&self) -> Option<OutputFormat> {
1116        self.format
1117    }
1118
1119    /// Returns the normalized element ID, when applicable.
1120    pub fn element_id(&self) -> Option<&str> {
1121        self.element_id.as_deref()
1122    }
1123
1124    /// Returns the scene-document JSON pointer, when applicable.
1125    pub fn json_pointer(&self) -> Option<&str> {
1126        self.json_pointer.as_deref()
1127    }
1128
1129    /// Returns typed details required by the stable error code.
1130    pub const fn details(&self) -> Option<&DiagnosticDetails> {
1131        self.details.as_ref()
1132    }
1133}
1134
1135#[cfg(test)]
1136mod tests {
1137    use super::*;
1138
1139    #[test]
1140    fn every_detail_kind_serializes_in_its_declared_member_order() {
1141        let finite = |value| FiniteValue::new(value).expect("test value is finite");
1142        let path = NestedPath {
1143            segments: vec![NestedPathSegment {
1144                kind: NestedSegmentKind::Node,
1145                ordinal: 1,
1146                name: None,
1147            }],
1148        };
1149        let cases = [
1150            (
1151                DiagnosticDetails::ParseLimit {
1152                    field: "max_scene_bytes",
1153                    configured: 67_108_864,
1154                    value_kind: ValueKind::Bytes,
1155                    measurement: Measurement::AtLeast { value: 67_108_865 },
1156                },
1157                concat!(
1158                    "{\"kind\":\"parse_limit\",\"field\":\"max_scene_bytes\",",
1159                    "\"configured\":67108864,\"value_kind\":\"bytes\",",
1160                    "\"measurement\":{\"kind\":\"at_least\",\"value\":67108865}}"
1161                ),
1162            ),
1163            (
1164                DiagnosticDetails::RequestValue {
1165                    field: "supplemental_assets",
1166                    asset_id: Some("image-1".to_owned()),
1167                    option: Some("media_type"),
1168                },
1169                concat!(
1170                    "{\"kind\":\"request_value\",\"field\":\"supplemental_assets\",",
1171                    "\"asset_id\":\"image-1\",\"option\":\"media_type\"}"
1172                ),
1173            ),
1174            (
1175                DiagnosticDetails::Compatibility {
1176                    recovery: Recovery::Substituted,
1177                    recipe_id: Some("official.embeddable-offline@sha256:d1d9".to_owned()),
1178                },
1179                concat!(
1180                    "{\"kind\":\"compatibility\",\"recovery\":\"substituted\",",
1181                    "\"recipe_id\":\"official.embeddable-offline@sha256:d1d9\"}"
1182                ),
1183            ),
1184            (
1185                DiagnosticDetails::Asset {
1186                    asset_id: "image-1".to_owned(),
1187                    claimed_media_type: Some("image/jpeg".to_owned()),
1188                    validated_media_type: Some("image/png".to_owned()),
1189                    recovery: Recovery::Repaired,
1190                    recipe_id: None,
1191                },
1192                concat!(
1193                    "{\"kind\":\"asset\",\"asset_id\":\"image-1\",",
1194                    "\"claimed_media_type\":\"image/jpeg\",",
1195                    "\"validated_media_type\":\"image/png\",",
1196                    "\"recovery\":\"repaired\",\"recipe_id\":null}"
1197                ),
1198            ),
1199            (
1200                DiagnosticDetails::ResourceLimit {
1201                    field: "max_coordinate_magnitude",
1202                    configured: ResourceValue::Finite(finite(1e9)),
1203                    value_kind: ValueKind::Coordinate,
1204                    measurement: ResourceMeasurement::Attempted {
1205                        value: ResourceValue::Finite(finite(1_000_000_000.5)),
1206                    },
1207                    recovery: Some(Recovery::Omitted),
1208                },
1209                concat!(
1210                    "{\"kind\":\"resource_limit\",",
1211                    "\"field\":\"max_coordinate_magnitude\",",
1212                    "\"configured\":1000000000,\"value_kind\":\"coordinate\",",
1213                    "\"measurement\":{\"kind\":\"attempted\",",
1214                    "\"value\":1000000000.5},\"recovery\":\"omitted\"}"
1215                ),
1216            ),
1217            (
1218                DiagnosticDetails::MemoryUnavailable {
1219                    subject_kind: "output_buffer".to_owned(),
1220                    subject: "svg".to_owned(),
1221                },
1222                concat!(
1223                    "{\"kind\":\"memory_unavailable\",",
1224                    "\"subject_kind\":\"output_buffer\",\"subject\":\"svg\"}"
1225                ),
1226            ),
1227            (
1228                DiagnosticDetails::MissingGlyph {
1229                    codepoint: 0x10ffff,
1230                    recovery: Recovery::Substituted,
1231                },
1232                concat!(
1233                    "{\"kind\":\"missing_glyph\",\"codepoint\":1114111,",
1234                    "\"recovery\":\"substituted\"}"
1235                ),
1236            ),
1237            (
1238                DiagnosticDetails::SvgFontSubsetFailure {
1239                    stage: SvgFontSubsetStage::Validate,
1240                    resolved_family: "Excalifont".to_owned(),
1241                    asset_identity: "fonts/Excalifont/chunk.woff2@sha256:abc".to_owned(),
1242                },
1243                concat!(
1244                    "{\"kind\":\"svg_font_subset_failure\",\"stage\":\"validate\",",
1245                    "\"resolved_family\":\"Excalifont\",",
1246                    "\"asset_identity\":\"fonts/Excalifont/chunk.woff2@sha256:abc\"}"
1247                ),
1248            ),
1249            (
1250                DiagnosticDetails::FontUnavailable {
1251                    resolved_family: "Xiaolai".to_owned(),
1252                    asset_identity: "fonts/Xiaolai/chunk.woff2@sha256:abc".to_owned(),
1253                },
1254                concat!(
1255                    "{\"kind\":\"font_unavailable\",\"resolved_family\":\"Xiaolai\",",
1256                    "\"asset_identity\":\"fonts/Xiaolai/chunk.woff2@sha256:abc\"}"
1257                ),
1258            ),
1259            (
1260                DiagnosticDetails::Metadata {
1261                    component: MetadataComponent::EmbeddedScene,
1262                    operation: MetadataOperation::Compression,
1263                    recovery: Recovery::MetadataOmitted,
1264                },
1265                concat!(
1266                    "{\"kind\":\"metadata\",\"component\":\"embedded_scene\",",
1267                    "\"operation\":\"compression\",",
1268                    "\"recovery\":\"metadata_omitted\"}"
1269                ),
1270            ),
1271            (
1272                DiagnosticDetails::NestedSource {
1273                    asset_chain: vec![AssetChainEntry {
1274                        asset_id: Some("image-1".to_owned()),
1275                        occurrence: 0,
1276                    }],
1277                    paths: vec![path.clone()],
1278                    recovery: Recovery::Removed,
1279                },
1280                concat!(
1281                    "{\"kind\":\"nested_source\",",
1282                    "\"asset_chain\":[{\"asset_id\":\"image-1\",\"occurrence\":0}],",
1283                    "\"paths\":[{\"segments\":[{\"kind\":\"node\",",
1284                    "\"ordinal\":1,\"name\":null}]}],\"recovery\":\"removed\"}"
1285                ),
1286            ),
1287            (
1288                DiagnosticDetails::NestedResourceLimit {
1289                    field: "max_nested_asset_depth",
1290                    configured: ResourceValue::Integer(8),
1291                    value_kind: ValueKind::Depth,
1292                    measurement: ResourceMeasurement::AtLeast {
1293                        value: ResourceValue::Integer(9),
1294                    },
1295                    asset_chain: vec![AssetChainEntry {
1296                        asset_id: None,
1297                        occurrence: 2,
1298                    }],
1299                    paths: vec![path.clone()],
1300                    recovery: Recovery::Omitted,
1301                    recipe_id: None,
1302                },
1303                concat!(
1304                    "{\"kind\":\"nested_resource_limit\",",
1305                    "\"field\":\"max_nested_asset_depth\",\"configured\":8,",
1306                    "\"value_kind\":\"depth\",",
1307                    "\"measurement\":{\"kind\":\"at_least\",\"value\":9},",
1308                    "\"asset_chain\":[{\"asset_id\":null,\"occurrence\":2}],",
1309                    "\"paths\":[{\"segments\":[{\"kind\":\"node\",",
1310                    "\"ordinal\":1,\"name\":null}]}],\"recovery\":\"omitted\",",
1311                    "\"recipe_id\":null}"
1312                ),
1313            ),
1314            (
1315                DiagnosticDetails::NestedAssetClaim {
1316                    asset_chain: vec![AssetChainEntry {
1317                        asset_id: Some("image-1".to_owned()),
1318                        occurrence: 0,
1319                    }],
1320                    paths: vec![path.clone()],
1321                    claimed_media_type: "image/jpeg".to_owned(),
1322                    validated_media_type: "image/png".to_owned(),
1323                    recovery: Recovery::Repaired,
1324                },
1325                concat!(
1326                    "{\"kind\":\"nested_asset_claim\",",
1327                    "\"asset_chain\":[{\"asset_id\":\"image-1\",\"occurrence\":0}],",
1328                    "\"paths\":[{\"segments\":[{\"kind\":\"node\",",
1329                    "\"ordinal\":1,\"name\":null}]}],",
1330                    "\"claimed_media_type\":\"image/jpeg\",",
1331                    "\"validated_media_type\":\"image/png\",\"recovery\":\"repaired\"}"
1332                ),
1333            ),
1334            (
1335                DiagnosticDetails::PdfRasterFallback {
1336                    recovery: None,
1337                    logical_bounds: Some(LogicalBounds {
1338                        x: finite(-0.0),
1339                        y: finite(1.5),
1340                        width: finite(10.0),
1341                        height: finite(20.0),
1342                    }),
1343                    pixel_width: None,
1344                    pixel_height: None,
1345                    causes: vec![path],
1346                },
1347                concat!(
1348                    "{\"kind\":\"pdf_raster_fallback\",\"recovery\":null,",
1349                    "\"logical_bounds\":{\"x\":0,\"y\":1.5,",
1350                    "\"width\":10,\"height\":20},\"pixel_width\":null,",
1351                    "\"pixel_height\":null,\"causes\":[{\"segments\":[{",
1352                    "\"kind\":\"node\",\"ordinal\":1,\"name\":null}]}]}"
1353                ),
1354            ),
1355        ];
1356
1357        let mut kinds = Vec::new();
1358        for (details, expected) in cases {
1359            // Naming every variant here fails the build when one is added
1360            // without a case above.
1361            let kind = match &details {
1362                DiagnosticDetails::ParseLimit { .. } => "parse_limit",
1363                DiagnosticDetails::RequestValue { .. } => "request_value",
1364                DiagnosticDetails::Compatibility { .. } => "compatibility",
1365                DiagnosticDetails::Asset { .. } => "asset",
1366                DiagnosticDetails::ResourceLimit { .. } => "resource_limit",
1367                DiagnosticDetails::MemoryUnavailable { .. } => "memory_unavailable",
1368                DiagnosticDetails::MissingGlyph { .. } => "missing_glyph",
1369                DiagnosticDetails::FontUnavailable { .. } => "font_unavailable",
1370                DiagnosticDetails::SvgFontSubsetFailure { .. } => "svg_font_subset_failure",
1371                DiagnosticDetails::Metadata { .. } => "metadata",
1372                DiagnosticDetails::NestedSource { .. } => "nested_source",
1373                DiagnosticDetails::NestedResourceLimit { .. } => "nested_resource_limit",
1374                DiagnosticDetails::NestedAssetClaim { .. } => "nested_asset_claim",
1375                DiagnosticDetails::PdfRasterFallback { .. } => "pdf_raster_fallback",
1376            };
1377            kinds.push(kind);
1378            assert_eq!(
1379                serde_json::to_string(&details).expect("serialize schema-1 details"),
1380                expected
1381            );
1382        }
1383        kinds.sort_unstable();
1384        kinds.dedup();
1385        assert_eq!(kinds.len(), 14, "one case for every detail kind");
1386    }
1387
1388    #[test]
1389    fn stable_numeric_values_reject_non_finite_input() {
1390        assert_eq!(FiniteValue::new(f64::NAN), None);
1391        assert_eq!(FiniteValue::new(f64::INFINITY), None);
1392        assert_eq!(FiniteValue::new(f64::NEG_INFINITY), None);
1393        assert_eq!(FiniteValue::new(-0.0).map(FiniteValue::get), Some(-0.0));
1394    }
1395}