1mod 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
27pub const SCHEMA_VERSION: u8 = 1;
29pub const DEFAULT_MAX_BLOCKS: u32 = 10_000;
31pub const DEFAULT_MAX_BYTES: u32 = 8_388_608;
33pub const MAX_BLOCKS_LIMIT: u32 = 1_000_000;
35pub const MAX_BYTES_LIMIT: u32 = 67_108_864;
37pub const MIN_BYTES_LIMIT: u32 = 1_024;
40
41#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
44#[serde(rename_all = "camelCase")]
45pub enum AnchorScope {
46 Session,
47 Snapshot,
48}
49
50#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
52#[serde(rename_all = "camelCase")]
53pub enum ReadingOrder {
54 ShapeTree,
55}
56
57#[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#[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#[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#[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#[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 pub index: u32,
154 pub anchor: PptxAnchor,
155 pub name: Option<String>,
156 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 Unknown,
173}
174
175#[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 pub title: Option<String>,
186 pub description: Option<String>,
188 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#[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 pub alignment: Option<String>,
224 pub bullet_json: Option<String>,
226 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 Bullet {
242 character: String,
243 font: Option<String>,
244 },
245 Number {
247 scheme: String,
248 start_at: u32,
249 value: u32,
250 marker: Option<String>,
251 },
252}
253
254#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
257#[serde(rename_all = "camelCase")]
258pub struct ExportRun {
259 pub anchor: PptxAnchor,
260 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 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#[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#[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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
358#[serde(rename_all = "camelCase")]
359pub struct ExportObject {
360 pub id: String,
361 pub kind: ExportObjectKind,
362 pub element: String,
364 pub uri: Option<String>,
366 pub relationship_ids: Vec<String>,
367 pub parts: Vec<String>,
369 pub external_targets: Vec<String>,
371}
372
373#[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#[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#[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#[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#[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#[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#[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
527pub type PptxExportResult<T> = Result<ExportRead<T>, ExportRefusal>;
529
530#[derive(Clone, Debug, Eq, PartialEq)]
532pub enum ExportError {
533 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
571fn 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
587pub(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 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 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
663const SNAPSHOT_CLIENT_ID: u64 = 1;
665
666pub 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
678pub 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
692pub 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
712pub 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}