Skip to main content

pptx_edit/structured/
mod.rs

1//! Read-only structured export of PPTX content: slides in deck order, each with its shape tree,
2//! text stories, tables and placeholders for content the export cannot represent, every record
3//! carrying the location it was read from, plus diagnostics for everything omitted.
4//! [`render_pptx_markdown`] renders the same content as Markdown.
5//!
6//! One walker reads every source: a live session ([`DeckSession::export_structured`]) and PPTX
7//! bytes ([`export_pptx_structured`]), which open a private session first.
8//!
9//! Reading order is the current shape tree, depth first: slides in deck order, shapes in their
10//! tree order, a group's descendants at the group's position, and table cells row by row. It is
11//! the authored order, not a visual or accessibility reading order inferred from geometry.
12
13mod markdown;
14mod walk;
15
16use std::fmt;
17
18use serde::de::Error as _;
19use serde::{Deserialize, Deserializer, Serialize};
20
21use crate::batch::DocumentVersion;
22use crate::target::TextRange;
23use crate::{DeckSession, EditResult};
24
25pub use markdown::render_pptx_markdown;
26
27/// The only structured-content schema version this crate reads and writes.
28pub const SCHEMA_VERSION: u8 = 1;
29/// Blocks an export returns when the options name no limit.
30pub const DEFAULT_MAX_BLOCKS: u32 = 10_000;
31/// Serialized bytes an export or rendering returns when the options name no limit.
32pub const DEFAULT_MAX_BYTES: u32 = 8_388_608;
33/// The largest block limit an export accepts.
34pub const MAX_BLOCKS_LIMIT: u32 = 1_000_000;
35/// The largest byte limit an export or rendering accepts.
36pub const MAX_BYTES_LIMIT: u32 = 67_108_864;
37/// The smallest byte limit an export or rendering accepts: room for the envelope and one
38/// truncation diagnostic.
39pub const MIN_BYTES_LIMIT: u32 = 1_024;
40
41/// Whether anchors resolve against a live session version or only against the returned
42/// snapshot.
43#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
44#[serde(rename_all = "camelCase")]
45pub enum AnchorScope {
46    Session,
47    Snapshot,
48}
49
50/// The order records are exported in; see the module documentation.
51#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
52#[serde(rename_all = "camelCase")]
53pub enum ReadingOrder {
54    ShapeTree,
55}
56
57/// A structured export. Ids are deterministic export-tree paths, not document identities.
58#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
59#[serde(rename_all = "camelCase")]
60pub struct PptxStructuredContent {
61    #[serde(deserialize_with = "schema_version")]
62    pub schema_version: u8,
63    pub anchor_scope: AnchorScope,
64    pub reading_order: ReadingOrder,
65    pub included: IncludedContent,
66    pub slides: Vec<ExportSlide>,
67    pub diagnostics: Vec<ExportDiagnostic>,
68    pub truncated: bool,
69}
70
71fn schema_version<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u8, D::Error> {
72    let version = u8::deserialize(deserializer)?;
73    if version != SCHEMA_VERSION {
74        return Err(D::Error::custom(format!(
75            "structured content schema version {version} is not supported; expected {SCHEMA_VERSION}"
76        )));
77    }
78    Ok(version)
79}
80
81/// The optional content an export was asked to include.
82#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
83#[serde(rename_all = "camelCase")]
84pub struct IncludedContent {
85    pub hidden_slides: bool,
86    pub hidden_shapes: bool,
87    pub notes: bool,
88    pub comments: bool,
89    pub formatting: bool,
90}
91
92/// A half-open range of UTF-16 offsets.
93#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
94#[serde(rename_all = "camelCase")]
95pub struct TextSpan {
96    pub start: u32,
97    pub end: u32,
98}
99
100/// Where a record was read from. `range` anchors are [`TextTarget::Range`](crate::TextTarget)
101/// batch targets in the story offsets of [`DeckSession::read_content`]; `notes` and `comment`
102/// ranges index the plain text they carry; `sourcePart` addresses retained XML by element-child
103/// ordinals from the part's root.
104#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
105#[serde(
106    tag = "kind",
107    rename_all = "camelCase",
108    rename_all_fields = "camelCase"
109)]
110pub enum PptxAnchor {
111    Slide {
112        slide_id: String,
113    },
114    Shape {
115        slide_id: String,
116        shape_id: String,
117    },
118    Range(TextRange),
119    Notes {
120        slide_id: String,
121        range: TextSpan,
122    },
123    Comment {
124        slide_id: String,
125        comment_id: String,
126        range: TextSpan,
127    },
128    SourcePart {
129        part: String,
130        part_sha256: String,
131        path: Vec<u32>,
132    },
133}
134
135/// The retained source XML a record was seeded from. `path` holds element-child ordinals from
136/// the part's root; `sld_id` is the owning slide's `p:sldId/@id` and `source_id` a shape's
137/// `cNvPr/@id`.
138#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
139#[serde(rename_all = "camelCase")]
140pub struct SourceProvenance {
141    pub part: String,
142    pub part_sha256: String,
143    pub path: Vec<u32>,
144    pub sld_id: Option<u32>,
145    pub source_id: Option<u32>,
146}
147
148#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
149#[serde(rename_all = "camelCase")]
150pub struct ExportSlide {
151    pub id: String,
152    /// Zero-based position in the current deck, hidden slides counted.
153    pub index: u32,
154    pub anchor: PptxAnchor,
155    pub name: Option<String>,
156    /// `None` when the deck does not record it.
157    pub hidden: Option<bool>,
158    pub provenance: Option<SourceProvenance>,
159    pub shapes: Vec<ExportShape>,
160    pub notes: Option<ExportNotes>,
161    pub comments: Vec<ExportComment>,
162}
163
164#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
165#[serde(rename_all = "camelCase")]
166pub enum ExportShapeKind {
167    Shape,
168    Picture,
169    GraphicFrame,
170    Group,
171    /// A shape-tree element only the source holds, anchored by its source location.
172    Unknown,
173}
174
175/// A shape, group or unrepresented shape-tree element, with its stories, table, placeholder
176/// object and children.
177#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
178#[serde(rename_all = "camelCase")]
179pub struct ExportShape {
180    pub id: String,
181    pub anchor: PptxAnchor,
182    pub kind: ExportShapeKind,
183    pub name: String,
184    /// `cNvPr/@title`, the alternative-text title.
185    pub title: Option<String>,
186    /// `cNvPr/@descr`, the alternative-text description.
187    pub description: Option<String>,
188    /// Hidden itself or through a hidden group.
189    pub hidden: bool,
190    pub placeholder: Option<ExportPlaceholder>,
191    pub provenance: Option<SourceProvenance>,
192    pub stories: Vec<ExportStory>,
193    pub table: Option<ExportTable>,
194    pub object: Option<ExportObject>,
195    pub children: Vec<ExportShape>,
196}
197
198#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
199#[serde(rename_all = "camelCase")]
200pub struct ExportPlaceholder {
201    #[serde(rename = "type")]
202    pub placeholder_type: Option<String>,
203    pub index: Option<u32>,
204}
205
206/// One text story; `anchor` spans all of it.
207#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
208#[serde(rename_all = "camelCase")]
209pub struct ExportStory {
210    pub id: String,
211    pub anchor: PptxAnchor,
212    pub paragraphs: Vec<ExportParagraph>,
213}
214
215#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
216#[serde(rename_all = "camelCase")]
217pub struct ExportParagraph {
218    pub id: String,
219    pub anchor: PptxAnchor,
220    pub paragraph_id: String,
221    pub level: u32,
222    /// The authored alignment, else the inherited one.
223    pub alignment: Option<String>,
224    /// The paragraph's own `a:buChar`, `a:buAutoNum` or `a:buNone`, as the session stores it.
225    pub bullet_json: Option<String>,
226    /// The list marker after inheritance and numbering; `None` for a paragraph that is not a
227    /// list item.
228    pub list: Option<ExportList>,
229    pub runs: Vec<ExportRun>,
230}
231
232#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
233#[serde(
234    tag = "kind",
235    rename_all = "camelCase",
236    rename_all_fields = "camelCase"
237)]
238pub enum ExportList {
239    /// `character` is the authored `a:buChar`; `font` its `a:buFont` typeface, a symbol font
240    /// when the character names a glyph slot.
241    Bullet {
242        character: String,
243        font: Option<String>,
244    },
245    /// `marker` is the formatted number, `None` for a scheme that cannot be formatted.
246    Number {
247        scheme: String,
248        start_at: u32,
249        value: u32,
250        marker: Option<String>,
251    },
252}
253
254/// A run of one kind and formatting. Line breaks are one `\n` unit and fields cover their cached
255/// result; an unsupported inline is zero-width.
256#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
257#[serde(rename_all = "camelCase")]
258pub struct ExportRun {
259    pub anchor: PptxAnchor,
260    /// `None` when the export excludes formatting.
261    pub marks: Option<Vec<ExportMark>>,
262    pub link: Option<ExportLink>,
263    #[serde(flatten)]
264    pub content: ExportRunKind,
265}
266
267#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
268#[serde(
269    tag = "kind",
270    rename_all = "camelCase",
271    rename_all_fields = "camelCase"
272)]
273pub enum ExportRunKind {
274    Text {
275        text: String,
276    },
277    LineBreak,
278    /// A field's cached result; fields are never evaluated.
279    Field {
280        field_type: Option<String>,
281        text: String,
282    },
283    Unsupported {
284        element: String,
285    },
286}
287
288#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
289#[serde(tag = "kind", rename_all = "camelCase")]
290pub enum ExportMark {
291    Bold,
292    Italic,
293    Underline { style: String },
294    Superscript,
295    Subscript,
296    SmallCaps,
297    AllCaps,
298}
299
300/// A click hyperlink: an external URL, or the internal part it jumps to.
301#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
302#[serde(rename_all = "camelCase")]
303pub struct ExportLink {
304    pub href: String,
305    pub external: bool,
306}
307
308/// A table on its grid. Every source cell appears once; `merged` marks a continuation covered
309/// by the cell at `merge_origin`, whose content PowerPoint does not show.
310#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
311#[serde(rename_all = "camelCase")]
312pub struct ExportTable {
313    pub columns: u32,
314    pub rows: Vec<ExportTableRow>,
315}
316
317#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
318#[serde(rename_all = "camelCase")]
319pub struct ExportTableRow {
320    pub cells: Vec<ExportTableCell>,
321}
322
323#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
324#[serde(rename_all = "camelCase")]
325pub struct ExportTableCell {
326    pub id: String,
327    pub anchor: PptxAnchor,
328    pub row: u32,
329    pub column: u32,
330    pub grid_span: u32,
331    pub row_span: u32,
332    pub merged: bool,
333    pub merge_origin: Option<CellPosition>,
334    pub story: Option<ExportStory>,
335}
336
337#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
338#[serde(rename_all = "camelCase")]
339pub struct CellPosition {
340    pub row: u32,
341    pub column: u32,
342}
343
344#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
345#[serde(rename_all = "camelCase")]
346pub enum ExportObjectKind {
347    Picture,
348    Video,
349    Audio,
350    Chart,
351    SmartArt,
352    EmbeddedObject,
353    Unknown,
354}
355
356/// A placeholder for content the export does not represent; its data is never included.
357#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
358#[serde(rename_all = "camelCase")]
359pub struct ExportObject {
360    pub id: String,
361    pub kind: ExportObjectKind,
362    /// The source element's qualified name, such as `p:graphicFrame`.
363    pub element: String,
364    /// `a:graphicData/@uri`.
365    pub uri: Option<String>,
366    pub relationship_ids: Vec<String>,
367    /// The package parts the object references.
368    pub parts: Vec<String>,
369    /// Targets of the object's external relationships, such as a linked video.
370    pub external_targets: Vec<String>,
371}
372
373/// A slide's speaker notes, as plain text.
374#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
375#[serde(rename_all = "camelCase")]
376pub struct ExportNotes {
377    pub id: String,
378    pub anchor: PptxAnchor,
379    pub text: String,
380    pub provenance: Option<SourceProvenance>,
381}
382
383#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
384#[serde(rename_all = "camelCase")]
385pub struct ExportComment {
386    pub id: String,
387    pub anchor: PptxAnchor,
388    pub comment_id: String,
389    pub author: Option<String>,
390    pub date: Option<String>,
391    pub parent_id: Option<String>,
392    pub resolved: bool,
393    pub text: String,
394}
395
396#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
397#[serde(rename_all = "camelCase")]
398pub struct ExportDiagnostic {
399    pub code: ExportDiagnosticCode,
400    pub severity: ExportSeverity,
401    pub anchor: Option<PptxAnchor>,
402    pub message: String,
403}
404
405#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
406#[serde(rename_all = "camelCase")]
407pub enum ExportSeverity {
408    Info,
409    Warning,
410    Error,
411}
412
413#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
414#[serde(rename_all = "kebab-case")]
415pub enum ExportDiagnosticCode {
416    UnsupportedContent,
417    ImageDataOmitted,
418    UnsupportedNumbering,
419    FieldCachedResult,
420    ProvenanceUnavailable,
421    VisibilityUnknown,
422    HiddenContentExcluded,
423    StoriesOmitted,
424    FormattingOmitted,
425    InheritedContentOmitted,
426    NotesStructureOmitted,
427    MergeContinuationContentOmitted,
428    Truncated,
429    MarkdownLossy,
430}
431
432/// What to export. Hidden slides and shapes, notes and comments are excluded unless requested;
433/// formatting is included unless turned off.
434#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
435#[serde(rename_all = "camelCase", deny_unknown_fields)]
436pub struct PptxExportOptions {
437    #[serde(default)]
438    pub include_hidden_slides: Option<bool>,
439    #[serde(default)]
440    pub include_hidden_shapes: Option<bool>,
441    #[serde(default)]
442    pub include_notes: Option<bool>,
443    #[serde(default)]
444    pub include_comments: Option<bool>,
445    #[serde(default)]
446    pub include_formatting: Option<bool>,
447    #[serde(default)]
448    pub max_blocks: Option<u32>,
449    #[serde(default)]
450    pub max_bytes: Option<u32>,
451}
452
453#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
454#[serde(rename_all = "camelCase", deny_unknown_fields)]
455pub struct PptxMarkdownOptions {
456    #[serde(default)]
457    pub max_bytes: Option<u32>,
458}
459
460/// Markdown rendered from structured content. Each `<!-- pptx-export:N -->` marker precedes the
461/// record `anchors` maps it to. Markdown keeps no slide layout, geometry or typography.
462#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
463#[serde(rename_all = "camelCase")]
464pub struct PptxMarkdownContent {
465    pub markdown: String,
466    pub anchors: Vec<MarkdownAnchor>,
467    pub diagnostics: Vec<ExportDiagnostic>,
468    pub truncated: bool,
469}
470
471#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
472#[serde(rename_all = "camelCase")]
473pub struct MarkdownAnchor {
474    pub marker: String,
475    pub anchor: PptxAnchor,
476}
477
478/// Why an export was refused: options out of range (`InvalidOptions`), above a hard maximum
479/// (`LimitExceeded`), or structured content handed to the renderer that contradicts itself
480/// (`InvalidContent`). Unsupported deck content is diagnosed, never refused.
481#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
482#[serde(rename_all = "kebab-case")]
483pub enum ExportFailureCode {
484    InvalidOptions,
485    LimitExceeded,
486    InvalidContent,
487}
488
489/// Why an export was refused, as data. `target` is the anchor the refusal concerns, if any.
490#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
491#[serde(rename_all = "camelCase")]
492pub struct ExportFailure {
493    pub code: ExportFailureCode,
494    pub target: Option<Box<PptxAnchor>>,
495    pub message: String,
496}
497
498impl fmt::Display for ExportFailure {
499    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
500        let code = match self.code {
501            ExportFailureCode::InvalidOptions => "invalid-options",
502            ExportFailureCode::LimitExceeded => "limit-exceeded",
503            ExportFailureCode::InvalidContent => "invalid-content",
504        };
505        write!(f, "{code}: {}", self.message)
506    }
507}
508
509impl std::error::Error for ExportFailure {}
510
511/// A session export refusal; the deck is at `version`.
512#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
513#[serde(rename_all = "camelCase")]
514pub struct ExportRefusal {
515    pub version: DocumentVersion,
516    pub failure: ExportFailure,
517}
518
519/// Content read from a session together with the version it was read at.
520#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
521#[serde(rename_all = "camelCase")]
522pub struct ExportRead<T> {
523    pub version: DocumentVersion,
524    pub content: T,
525}
526
527/// A session export: the content with its version, or a refusal.
528pub type PptxExportResult<T> = Result<ExportRead<T>, ExportRefusal>;
529
530/// Why a bytes export produced nothing.
531#[derive(Clone, Debug, Eq, PartialEq)]
532pub enum ExportError {
533    /// The input is not a readable PPTX package.
534    Parse(String),
535    Refused(ExportFailure),
536}
537
538impl fmt::Display for ExportError {
539    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
540        match self {
541            Self::Parse(message) => f.write_str(message),
542            Self::Refused(failure) => failure.fmt(f),
543        }
544    }
545}
546
547impl std::error::Error for ExportError {}
548
549impl From<ExportFailure> for ExportError {
550    fn from(failure: ExportFailure) -> Self {
551        Self::Refused(failure)
552    }
553}
554
555fn invalid_options(message: impl Into<String>) -> ExportFailure {
556    ExportFailure {
557        code: ExportFailureCode::InvalidOptions,
558        target: None,
559        message: message.into(),
560    }
561}
562
563fn limit_exceeded(message: impl Into<String>) -> ExportFailure {
564    ExportFailure {
565        code: ExportFailureCode::LimitExceeded,
566        target: None,
567        message: message.into(),
568    }
569}
570
571/// Validates a byte limit shared by exports and renderings.
572fn byte_limit(requested: Option<u32>) -> Result<usize, ExportFailure> {
573    let limit = requested.unwrap_or(DEFAULT_MAX_BYTES);
574    if limit < MIN_BYTES_LIMIT {
575        return Err(invalid_options(format!(
576            "maxBytes must be at least {MIN_BYTES_LIMIT}"
577        )));
578    }
579    if limit > MAX_BYTES_LIMIT {
580        return Err(limit_exceeded(format!(
581            "maxBytes may be at most {MAX_BYTES_LIMIT}"
582        )));
583    }
584    Ok(limit as usize)
585}
586
587/// Export options with their defaults applied and validated.
588pub(crate) struct Resolved {
589    pub included: IncludedContent,
590    pub max_blocks: usize,
591    pub max_bytes: usize,
592}
593
594impl Resolved {
595    fn new(options: &PptxExportOptions) -> Result<Self, ExportFailure> {
596        let max_blocks = options.max_blocks.unwrap_or(DEFAULT_MAX_BLOCKS);
597        if max_blocks == 0 {
598            return Err(invalid_options("maxBlocks must be at least 1"));
599        }
600        if max_blocks > MAX_BLOCKS_LIMIT {
601            return Err(limit_exceeded(format!(
602                "maxBlocks may be at most {MAX_BLOCKS_LIMIT}"
603            )));
604        }
605        Ok(Self {
606            included: IncludedContent {
607                hidden_slides: options.include_hidden_slides.unwrap_or(false),
608                hidden_shapes: options.include_hidden_shapes.unwrap_or(false),
609                notes: options.include_notes.unwrap_or(false),
610                comments: options.include_comments.unwrap_or(false),
611                formatting: options.include_formatting.unwrap_or(true),
612            },
613            max_blocks: max_blocks as usize,
614            max_bytes: byte_limit(options.max_bytes)?,
615        })
616    }
617}
618
619impl DeckSession {
620    /// Exports the committed deck with the version it was read at; anchors are scoped to that
621    /// version. Nothing is mutated, minted, published or laid out, and pending input is not
622    /// flushed. Refusals are the inner `Err`.
623    pub fn export_structured(
624        &self,
625        options: &PptxExportOptions,
626    ) -> EditResult<PptxExportResult<PptxStructuredContent>> {
627        let version = self.version();
628        let resolved = match Resolved::new(options) {
629            Ok(resolved) => resolved,
630            Err(failure) => return Ok(Err(ExportRefusal { version, failure })),
631        };
632        let content = walk::export(self, &resolved, AnchorScope::Session)?;
633        Ok(Ok(ExportRead { version, content }))
634    }
635
636    /// [`DeckSession::export_structured`] rendered as Markdown from that one read.
637    pub fn export_markdown(
638        &self,
639        options: &PptxExportOptions,
640    ) -> EditResult<PptxExportResult<PptxMarkdownContent>> {
641        let read = match self.export_structured(options)? {
642            Ok(read) => read,
643            Err(refusal) => return Ok(Err(refusal)),
644        };
645        let markdown_options = PptxMarkdownOptions {
646            max_bytes: options.max_bytes,
647        };
648        Ok(
649            match render_pptx_markdown(&read.content, &markdown_options) {
650                Ok(content) => Ok(ExportRead {
651                    version: read.version,
652                    content,
653                }),
654                Err(failure) => Err(ExportRefusal {
655                    version: read.version,
656                    failure,
657                }),
658            },
659        )
660    }
661}
662
663/// The client id of the private session a bytes export opens; exports never reveal it.
664const SNAPSHOT_CLIENT_ID: u64 = 1;
665
666/// Exports PPTX bytes as a snapshot: anchors address the returned content only.
667pub fn export_pptx_structured(
668    bytes: &[u8],
669    options: &PptxExportOptions,
670) -> Result<PptxStructuredContent, ExportError> {
671    let resolved = Resolved::new(options)?;
672    let session = DeckSession::open(bytes, SNAPSHOT_CLIENT_ID)
673        .map_err(|error| ExportError::Parse(error.to_string()))?;
674    walk::export(&session, &resolved, AnchorScope::Snapshot)
675        .map_err(|error| ExportError::Parse(error.to_string()))
676}
677
678/// JSON of a session export: `{"ok":true,"version","content"}` or
679/// `{"ok":false,"version","failure"}`.
680pub fn export_outcome_json<T: Serialize>(
681    outcome: &PptxExportResult<T>,
682) -> Result<String, serde_json::Error> {
683    tagged(
684        outcome.is_ok(),
685        match outcome {
686            Ok(read) => serde_json::to_value(read)?,
687            Err(refusal) => serde_json::to_value(refusal)?,
688        },
689    )
690}
691
692/// JSON of a bytes export or rendering: `{"ok":true,"content"}` or `{"ok":false,"failure"}`.
693/// `Err` is the message for input that is not a readable PPTX.
694pub fn snapshot_outcome_json<T: Serialize>(
695    outcome: Result<T, ExportError>,
696) -> Result<String, String> {
697    let value = match &outcome {
698        Ok(content) => serde_json::json!({ "content": content }),
699        Err(ExportError::Refused(failure)) => serde_json::json!({ "failure": failure }),
700        Err(ExportError::Parse(message)) => return Err(message.clone()),
701    };
702    tagged(outcome.is_ok(), value).map_err(|error| error.to_string())
703}
704
705fn tagged(ok: bool, mut value: serde_json::Value) -> Result<String, serde_json::Error> {
706    if let serde_json::Value::Object(object) = &mut value {
707        object.insert("ok".to_owned(), serde_json::Value::Bool(ok));
708    }
709    serde_json::to_string(&value)
710}
711
712/// [`export_pptx_structured`] rendered as Markdown.
713pub fn export_pptx_markdown(
714    bytes: &[u8],
715    options: &PptxExportOptions,
716) -> Result<PptxMarkdownContent, ExportError> {
717    let content = export_pptx_structured(bytes, options)?;
718    Ok(render_pptx_markdown(
719        &content,
720        &PptxMarkdownOptions {
721            max_bytes: options.max_bytes,
722        },
723    )?)
724}