xberg 1.1.1

High-performance document intelligence library for Rust. Extract text, metadata, and structured data from PDFs, Office documents, images, and 107 formats and 371 programming languages via tree-sitter code intelligence with async/sync APIs.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
//! ODP (OpenDocument Presentation) extractor.
//!
//! OpenDocument Presentation files share the ODF ZIP + XML container with ODT,
//! but their body is `office:presentation > draw:page` (one page per slide)
//! rather than `office:text`. Slide text lives in
//! `draw:page > draw:frame > draw:text-box`, tables in
//! `draw:page > draw:frame > table:table`, and images in
//! `draw:page > draw:frame > draw:image`.
//!
//! To avoid duplicating parsing logic, this extractor reuses the ODT walker
//! [`build_internal_elements`] for each text box (it already handles
//! `text:p` / `text:h` / `text:list` / nested tables and inline images) and the
//! shared ODF helpers ([`build_style_map`], [`pre_extract_images`],
//! [`pre_extract_formulas`], [`extract_table_cells`]). Only the outer
//! slide → frame traversal and slide markers are ODP-specific.
//!
//! A slide's drawing content can also be a `draw:custom-shape` (and other
//! shape kinds) or a `draw:g` group holding further frames/shapes nested to
//! arbitrary depth — [`process_page_object`] recurses through all of that.
//!
//! Speaker notes (`presentation:notes`, a sibling of the drawing frames inside
//! each `draw:page`) are extracted separately from slide body text: the main
//! traversal walks the page's *direct-child* `draw:frame`/`draw:g` elements
//! only, never `descendants()`, so notes are never folded into slide content;
//! [`extract_odp_notes_text`] handles the `presentation:notes` sibling on its
//! own and the result lands as a distinct raw block.

use crate::Result;
use crate::core::config::ExtractionConfig;
use crate::extraction::office_metadata;
use crate::extractors::odt::{
    MAX_ODT_MEMBER_SIZE, build_internal_elements, build_list_style_map, build_style_map, extract_table_cells,
    pre_extract_formulas, pre_extract_images,
};
use crate::extractors::security::{SecurityBudget, ZipBombValidator};
use crate::plugins::{InternalDocumentExtractor, Plugin};
use crate::types::ExtractedImage;
use crate::types::Metadata;
use crate::types::internal::InternalDocument;
use crate::types::internal_builder::InternalDocumentBuilder;
use ahash::AHashMap;
use async_trait::async_trait;
use bytes::Bytes;
use roxmltree::Document;
use std::borrow::Cow;
use std::io::Cursor;

/// Canonical MIME type for OpenDocument Presentation files.
const ODP_MIME: &str = "application/vnd.oasis.opendocument.presentation";

/// ODF drawing namespace, used to resolve `draw:*` attributes.
const DRAWING_NS: &str = "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0";
/// XLink namespace, used to resolve `xlink:href` on images.
const XLINK_NS: &str = "http://www.w3.org/1999/xlink";

/// Native Rust extractor for OpenDocument Presentation (`.odp`) files.
#[cfg_attr(alef, alef(skip))]
pub struct OdpExtractor;

impl OdpExtractor {
    /// Create a new ODP extractor.
    pub(crate) fn new() -> Self {
        Self
    }
}

impl Default for OdpExtractor {
    fn default() -> Self {
        Self::new()
    }
}

impl Plugin for OdpExtractor {
    fn name(&self) -> &str {
        "odp-extractor"
    }

    fn version(&self) -> String {
        env!("CARGO_PKG_VERSION").to_string()
    }

    fn initialize(&self) -> Result<()> {
        Ok(())
    }

    fn shutdown(&self) -> Result<()> {
        Ok(())
    }

    fn description(&self) -> &str {
        "Native Rust ODP (OpenDocument Presentation) extractor with slide, table, and image support"
    }

    fn author(&self) -> &str {
        "Xberg Team"
    }
}

/// Count the `draw:page` slides an ODP's `content.xml` declares, without doing
/// any of the per-slide element-building work in [`build_internal_document`]'s
/// main loop below. Used by [`enforce_slide_limit`] (#1451) to reject an
/// oversized deck before that per-slide work starts.
fn count_odp_slides(root: roxmltree::Node) -> usize {
    root.children()
        .filter(|n| n.tag_name().name() == "body")
        .flat_map(|body| body.children().filter(|n| n.tag_name().name() == "presentation"))
        .flat_map(|presentation| presentation.children().filter(|n| n.tag_name().name() == "page"))
        .count()
}

/// Reject a presentation whose slide count exceeds `max_pages` before any
/// per-slide work (text/table/image extraction via `process_page_object`)
/// begins (#1451).
///
/// Unlike PPTX, an ODP's slide count is not available before parsing
/// `content.xml`: ODF has no separate manifest of per-slide archive entries, so
/// the count comes from a full XML parse either way. `count_odp_slides` reuses
/// the DOM `Document::parse` already produced and only counts `draw:page`
/// children -- cheap relative to `process_page_object`'s recursive walk of each
/// page's frames, shapes, tables, and images, which is what this guards.
fn enforce_slide_limit(slide_count: usize, max_pages: Option<usize>) -> Result<()> {
    Ok(crate::extractors::security::enforce_page_count(slide_count, max_pages)?)
}

/// Parse an ODP `content.xml` into an [`InternalDocument`], emitting one slide
/// marker per `draw:page` followed by that slide's text, tables, and images.
fn build_internal_document(
    archive: &mut zip::ZipArchive<Cursor<Vec<u8>>>,
    budget: &mut SecurityBudget,
    max_pages: Option<usize>,
) -> crate::error::Result<InternalDocument> {
    let image_data = pre_extract_images(archive)?;
    let formula_data = pre_extract_formulas(archive, budget)?;

    let mut xml_content = String::new();
    match archive.by_name("content.xml") {
        Ok(file) => {
            use std::io::Read;
            file.take(MAX_ODT_MEMBER_SIZE)
                .read_to_string(&mut xml_content)
                .map_err(|e| crate::error::XbergError::parsing(format!("Failed to read content.xml: {}", e)))?;
        }
        Err(_) => {
            // Same defect as ODT's (#112), in the sibling extractor: a ZIP without
            // content.xml is not an ODF document at all, so returning `Ok` with an
            // empty document tells the caller "this is the presentation" for
            // something that isn't one. Fail loudly instead.
            return Err(crate::error::XbergError::parsing(
                "ODP archive is missing content.xml; this is not a valid OpenDocument Presentation file",
            ));
        }
    }

    let doc = Document::parse(&xml_content)
        .map_err(|e| crate::error::XbergError::parsing(format!("Failed to parse content.xml: {}", e)))?;

    let root = doc.root_element();
    enforce_slide_limit(count_odp_slides(root), max_pages)?;

    let style_map = build_style_map(root);
    let list_style_map = build_list_style_map(root);
    let mut builder = InternalDocumentBuilder::new("odp");

    // Presentations carry no tracked changes; feed the shared ODT walker empty
    // collaboration state so its change-tracking arms are inert. ~keep
    let empty_changes = AHashMap::new();
    let mut revisions = Vec::new();

    let mut slide_number: u32 = 0;
    for body in root.children().filter(|n| n.tag_name().name() == "body") {
        for presentation in body.children().filter(|n| n.tag_name().name() == "presentation") {
            for page in presentation.children().filter(|n| n.tag_name().name() == "page") {
                budget.step()?;
                slide_number += 1;

                let slide_name = page
                    .attribute((DRAWING_NS, "name"))
                    .or_else(|| page.attribute("draw:name"));
                builder.push_slide(slide_number, slide_name, None);

                // Direct-child frames/groups only — never descendants — so the
                // sibling `presentation:notes` element is not pulled into
                // slide text; `process_page_object` recurses into `draw:g`
                // (and nested frames within it) on its own (#116). ~keep
                for page_child in page.children() {
                    if matches!(page_child.tag_name().name(), "frame" | "g") {
                        process_page_object(
                            page_child,
                            &mut builder,
                            &style_map,
                            &list_style_map,
                            &image_data,
                            &formula_data,
                            budget,
                            &empty_changes,
                            &mut revisions,
                        )?;
                    }
                }

                // Speaker notes (`presentation:notes`, #95): a sibling of the
                // slide's drawing frames, deliberately not visited by the loop
                // above. Flattened to a raw block (rather than folded into
                // slide body text) so it stays distinguishable from what's
                // actually shown on the slide.
                if let Some(notes_text) = page
                    .children()
                    .find(|n| n.tag_name().name() == "notes")
                    .and_then(extract_odp_notes_text)
                {
                    builder.push_raw_block("odp-speaker-notes", &notes_text, Some(slide_number));
                }
            }
        }
    }

    extract_odp_master_page_text(archive, &mut builder);

    Ok(builder.build())
}

/// Process one drawing object reached from a slide page: a `draw:frame`
/// (unwrapped to its content), a `draw:g` group (recursed into, arbitrarily
/// nested, #116), or a bare shape/table/image/object.
///
/// Shapes other than `draw:frame > draw:text-box` (custom shapes, rectangles,
/// ellipses, lines, connectors, ...) can carry their own `text:p` content
/// directly and were previously invisible to this walker entirely (#116).
#[allow(clippy::too_many_arguments)]
fn process_page_object(
    node: roxmltree::Node,
    builder: &mut InternalDocumentBuilder,
    style_map: &AHashMap<String, crate::extractors::odt::OdtStyleProps>,
    list_style_map: &AHashMap<String, bool>,
    image_data: &AHashMap<String, (Vec<u8>, String)>,
    formula_data: &AHashMap<String, String>,
    budget: &mut SecurityBudget,
    empty_changes: &AHashMap<String, crate::extractors::odt::OdtChangeRegion>,
    revisions: &mut Vec<crate::types::revisions::DocumentRevision>,
) -> crate::error::Result<()> {
    match node.tag_name().name() {
        "frame" | "g" => {
            for child in node.children() {
                process_page_object(
                    child,
                    builder,
                    style_map,
                    list_style_map,
                    image_data,
                    formula_data,
                    budget,
                    empty_changes,
                    revisions,
                )?;
            }
        }
        "text-box" | "custom-shape" | "rect" | "ellipse" | "circle" | "line" | "polygon" | "polyline" | "path"
        | "connector" | "regular-polygon" | "measure" => {
            build_internal_elements(
                node,
                builder,
                style_map,
                list_style_map,
                image_data,
                formula_data,
                budget,
                empty_changes,
                revisions,
            )?;
        }
        "table" => {
            let cells = extract_table_cells(node);
            if !cells.is_empty() {
                let cell_count: usize = cells.iter().map(|row| row.len()).sum();
                budget.add_cells(cell_count)?;
                builder.push_table_from_cells(&cells, None, None);
            }
        }
        "image" => {
            push_frame_image(node, image_data, builder);
        }
        "object" | "object-ole" => {
            // An embedded object (#116): a MathML formula resolves through
            // the same lookup ODT uses; anything else (e.g. an embedded
            // spreadsheet) cannot be turned into text by this extractor, so
            // its loss must be surfaced rather than silently dropped, per
            // the extractor-warning convention in `core::diagnostics`.
            let href = node
                .attribute((XLINK_NS, "href"))
                .or_else(|| node.attribute("xlink:href"));
            let resolved = href.and_then(|h| formula_data.get(h.trim_start_matches("./")));
            if let Some(formula_text) = resolved {
                builder.push_formula(formula_text, None, None);
            } else {
                let message = match href {
                    Some(h) => format!("Embedded object '{h}' is not a formula and could not be extracted as text"),
                    None => "An embedded object could not be extracted as text".to_string(),
                };
                builder.add_warning(crate::core::diagnostics::warning("odp", message));
            }
        }
        _ => {}
    }
    Ok(())
}

/// Flatten a `presentation:notes` element's slide-like body into plain text
/// for a raw block (#95). Notes share the same `draw:page`-style shape
/// (`draw:frame > draw:text-box > text:p`) as the slide itself.
fn extract_odp_notes_text(notes: roxmltree::Node) -> Option<String> {
    let mut paragraphs = Vec::new();
    for frame in notes.descendants().filter(|n| n.tag_name().name() == "frame") {
        for text_box in frame.children().filter(|n| n.tag_name().name() == "text-box") {
            for p in text_box.children().filter(|n| matches!(n.tag_name().name(), "p" | "h")) {
                if let Some(text) = crate::extractors::odt::extract_node_text(p) {
                    let trimmed = text.trim();
                    if !trimmed.is_empty() {
                        paragraphs.push(trimmed.to_string());
                    }
                }
            }
        }
    }
    if paragraphs.is_empty() {
        None
    } else {
        Some(paragraphs.join("\n"))
    }
}

/// Extract any static text from `styles.xml`'s `style:master-page` elements
/// (#116) — slide masters commonly carry footer/placeholder text applied to
/// every slide that uses them, which was previously never visited by this
/// extractor.
fn extract_odp_master_page_text(archive: &mut zip::ZipArchive<Cursor<Vec<u8>>>, builder: &mut InternalDocumentBuilder) {
    use std::io::Read;

    let mut styles_xml = String::new();
    let Ok(file) = archive.by_name("styles.xml") else {
        return;
    };
    if file.take(MAX_ODT_MEMBER_SIZE).read_to_string(&mut styles_xml).is_err() {
        return;
    }
    let Ok(doc) = Document::parse(&styles_xml) else {
        return;
    };

    for master_page in doc
        .root_element()
        .descendants()
        .filter(|n| n.tag_name().name() == "master-page")
    {
        let mut paragraphs = Vec::new();
        for p in master_page
            .descendants()
            .filter(|n| matches!(n.tag_name().name(), "p" | "h"))
        {
            if let Some(text) = crate::extractors::odt::extract_node_text(p) {
                let trimmed = text.trim();
                if !trimmed.is_empty() {
                    paragraphs.push(trimmed.to_string());
                }
            }
        }
        if !paragraphs.is_empty() {
            builder.push_raw_block("odp-master-page", &paragraphs.join("\n"), None);
        }
    }
}

/// Resolve a `draw:image`'s referenced bytes from the pre-extracted image map
/// and push it onto the builder. Unresolvable references are skipped silently
/// (the ODT walker handles inline images; only page-level frame images land
/// here).
fn push_frame_image(
    image_node: roxmltree::Node,
    image_data: &AHashMap<String, (Vec<u8>, String)>,
    builder: &mut InternalDocumentBuilder,
) {
    let href = image_node
        .attribute((XLINK_NS, "href"))
        .or_else(|| image_node.attribute("xlink:href"));
    let Some(href) = href else { return };
    let Some((data, format)) = image_data.get(href).cloned() else {
        return;
    };

    let (image_kind, kind_confidence) =
        crate::extraction::image_kind::classify(&data, &format, None, None, None, None, false);

    let image = ExtractedImage {
        data: Bytes::from(data),
        format: Cow::Owned(format),
        image_kind: Some(image_kind),
        kind_confidence: Some(kind_confidence),
        ..Default::default()
    };
    let idx = builder.push_image(None, image, None, None);
    let mut attrs = AHashMap::with_capacity(1);
    attrs.insert("src".to_string(), href.to_string());
    builder.set_attributes(idx, attrs);
}

#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
impl InternalDocumentExtractor for OdpExtractor {
    #[cfg_attr(
        feature = "otel",
        tracing::instrument(
            skip(self, content, config),
            fields(
                extractor.name = self.name(),
                content.size_bytes = content.len(),
            )
        )
    )]
    async fn extract_content(
        &self,
        content: &[u8],
        mime_type: &str,
        config: &ExtractionConfig,
    ) -> Result<InternalDocument> {
        tracing::debug!(format = "odp", size_bytes = content.len(), "extraction starting");
        let content_owned = content.to_vec();
        let limits = config.security_limits.clone().unwrap_or_default();

        let cursor = Cursor::new(content_owned.clone());
        let mut archive = zip::ZipArchive::new(cursor)
            .map_err(|e| crate::error::XbergError::parsing(format!("Failed to open ZIP archive: {}", e)))?;
        ZipBombValidator::new(limits.clone()).validate(&mut archive)?;

        let mut budget = SecurityBudget::from_config(config);
        let max_pages = config.security_limits.as_ref().and_then(|limits| limits.max_pages);
        let mut doc = build_internal_document(&mut archive, &mut budget, max_pages)?;
        doc.mime_type = mime_type.to_string();

        let mut metadata_map = AHashMap::new();

        let meta_cursor = Cursor::new(content_owned);
        let mut meta_archive = zip::ZipArchive::new(meta_cursor).map_err(|e| {
            crate::error::XbergError::parsing(format!("Failed to open ZIP archive for metadata: {}", e))
        })?;
        // Second, independent `ZipArchive::new` over the same bytes: needs its own
        // validation call, the same gap DOCX has at `extractors/docx.rs:913`. ~keep
        ZipBombValidator::new(limits).validate(&mut meta_archive)?;

        // ODP `meta.xml` uses the same ODF metadata schema as ODT. ~keep
        if let Ok(props) = office_metadata::extract_odt_properties(&mut meta_archive) {
            if let Some(title) = props.title {
                metadata_map.insert(Cow::Borrowed("title"), serde_json::Value::String(title));
            }
            if let Some(creator) = props.creator {
                metadata_map.insert(
                    Cow::Borrowed("authors"),
                    serde_json::Value::Array(vec![serde_json::Value::String(creator.clone())]),
                );
                metadata_map.insert(Cow::Borrowed("created_by"), serde_json::Value::String(creator));
            }
            if let Some(initial_creator) = props.initial_creator {
                metadata_map.insert(
                    Cow::Borrowed("initial_creator"),
                    serde_json::Value::String(initial_creator),
                );
            }
            if let Some(subject) = props.subject {
                metadata_map.insert(Cow::Borrowed("subject"), serde_json::Value::String(subject));
            }
            if let Some(keywords) = props.keywords {
                metadata_map.insert(Cow::Borrowed("keywords"), serde_json::Value::String(keywords));
            }
            if let Some(description) = props.description {
                metadata_map.insert(Cow::Borrowed("description"), serde_json::Value::String(description));
            }
            if let Some(creation_date) = props.creation_date {
                metadata_map.insert(Cow::Borrowed("created_at"), serde_json::Value::String(creation_date));
            }
            if let Some(date) = props.date {
                metadata_map.insert(Cow::Borrowed("modified_at"), serde_json::Value::String(date));
            }
            if let Some(language) = props.language {
                metadata_map.insert(Cow::Borrowed("language"), serde_json::Value::String(language));
            }
            if let Some(generator) = props.generator {
                metadata_map.insert(Cow::Borrowed("generator"), serde_json::Value::String(generator));
            }
            if let Some(editing_duration) = props.editing_duration {
                metadata_map.insert(
                    Cow::Borrowed("editing_duration"),
                    serde_json::Value::String(editing_duration),
                );
            }
            if let Some(editing_cycles) = props.editing_cycles {
                metadata_map.insert(
                    Cow::Borrowed("editing_cycles"),
                    serde_json::Value::String(editing_cycles),
                );
            }
            if let Some(page_count) = props.page_count {
                metadata_map.insert(
                    Cow::Borrowed("page_count"),
                    serde_json::Value::Number(page_count.into()),
                );
            }
            if let Some(word_count) = props.word_count {
                metadata_map.insert(
                    Cow::Borrowed("word_count"),
                    serde_json::Value::Number(word_count.into()),
                );
            }
            if let Some(character_count) = props.character_count {
                metadata_map.insert(
                    Cow::Borrowed("character_count"),
                    serde_json::Value::Number(character_count.into()),
                );
            }
            if let Some(paragraph_count) = props.paragraph_count {
                metadata_map.insert(
                    Cow::Borrowed("paragraph_count"),
                    serde_json::Value::Number(paragraph_count.into()),
                );
            }
            if let Some(table_count) = props.table_count {
                metadata_map.insert(
                    Cow::Borrowed("table_count"),
                    serde_json::Value::Number(table_count.into()),
                );
            }
            if let Some(image_count) = props.image_count {
                metadata_map.insert(
                    Cow::Borrowed("image_count"),
                    serde_json::Value::Number(image_count.into()),
                );
            }
        }

        let title = metadata_map
            .remove(&Cow::Borrowed("title"))
            .and_then(|v| v.as_str().map(|s| s.to_string()));
        let subject = metadata_map
            .remove(&Cow::Borrowed("subject"))
            .and_then(|v| v.as_str().map(|s| s.to_string()));
        let authors = metadata_map.remove(&Cow::Borrowed("authors")).and_then(|v| {
            v.as_array()
                .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
        });
        let created_by = metadata_map
            .remove(&Cow::Borrowed("created_by"))
            .and_then(|v| v.as_str().map(|s| s.to_string()));
        let created_at = metadata_map
            .remove(&Cow::Borrowed("created_at"))
            .and_then(|v| v.as_str().map(|s| s.to_string()));
        let modified_at = metadata_map
            .remove(&Cow::Borrowed("modified_at"))
            .and_then(|v| v.as_str().map(|s| s.to_string()));
        let language = metadata_map
            .remove(&Cow::Borrowed("language"))
            .and_then(|v| v.as_str().map(|s| s.to_string()));
        let keywords = metadata_map.remove(&Cow::Borrowed("keywords")).and_then(|v| {
            v.as_str().map(|s| {
                s.split(',')
                    .map(|k| k.trim().to_string())
                    .filter(|k| !k.is_empty())
                    .collect()
            })
        });

        doc.metadata = Metadata {
            title,
            subject,
            authors,
            keywords,
            language,
            created_at,
            modified_at,
            created_by,
            additional: metadata_map,
            ..Default::default()
        };

        if let Some(ref filter) = config.content_filter {
            use crate::types::document_structure::ContentLayer;
            doc.elements.retain(|elem| match elem.layer {
                ContentLayer::Header => filter.include_headers,
                ContentLayer::Footer => filter.include_footers,
                _ => true,
            });
        }

        tracing::debug!(
            element_count = doc.elements.len(),
            format = "odp",
            "extraction complete"
        );
        Ok(doc)
    }

    fn supported_mime_types(&self) -> &[&str] {
        &[ODP_MIME]
    }

    fn priority(&self) -> i32 {
        60
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::internal::ElementKind;
    use std::io::Write;

    /// Wrap a presentation body fragment into a valid in-memory `.odp` ZIP for
    /// deterministic, network-free extraction tests. `body_inner` is placed
    /// inside `<office:presentation>`.
    fn odp_bytes(body_inner: &str) -> Vec<u8> {
        let content_xml = format!(
            r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document-content
    xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
    xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
    xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
    xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0">
  <office:body>
    <office:presentation>{body_inner}</office:presentation>
  </office:body>
</office:document-content>"#
        );

        let mut buf = Vec::new();
        {
            let mut zip = zip::ZipWriter::new(Cursor::new(&mut buf));
            let stored = zip::write::FileOptions::<()>::default().compression_method(zip::CompressionMethod::Stored);
            // The `mimetype` entry must be first and stored uncompressed per the ODF spec. ~keep
            zip.start_file("mimetype", stored).unwrap();
            zip.write_all(ODP_MIME.as_bytes()).unwrap();

            let deflated =
                zip::write::FileOptions::<()>::default().compression_method(zip::CompressionMethod::Deflated);
            zip.start_file("content.xml", deflated).unwrap();
            zip.write_all(content_xml.as_bytes()).unwrap();
            zip.finish().unwrap();
        }
        buf
    }

    /// A minimal single-slide deck with one text box.
    fn minimal_odp() -> Vec<u8> {
        odp_bytes(
            r#"<draw:page draw:name="Intro"><draw:frame><draw:text-box>
                 <text:p>Hello Slide</text:p>
               </draw:text-box></draw:frame></draw:page>"#,
        )
    }

    #[tokio::test]
    async fn test_odp_extracts_slide_text() {
        let bytes = minimal_odp();
        let extractor = OdpExtractor::new();
        let doc = extractor
            .extract_content(&bytes, ODP_MIME, &ExtractionConfig::default())
            .await
            .expect("ODP extraction should succeed");

        let has_text = doc.elements.iter().any(|e| e.text.contains("Hello Slide"));
        assert!(has_text, "extracted content should contain the slide's text");

        let slide_count = doc
            .elements
            .iter()
            .filter(|e| matches!(e.kind, ElementKind::Slide { .. }))
            .count();
        assert_eq!(slide_count, 1, "one draw:page should yield one slide marker");
    }

    #[tokio::test]
    async fn test_odp_extracts_table_cells() {
        // A slide whose frame holds a table (draw:frame > table:table), the
        // real-world nesting — a sibling of text boxes, not inside one. ~keep
        let bytes = odp_bytes(
            r#"<draw:page draw:name="Data"><draw:frame><table:table>
                 <table:table-row>
                   <table:table-cell><text:p>A1</text:p></table:table-cell>
                   <table:table-cell><text:p>B1</text:p></table:table-cell>
                 </table:table-row>
               </table:table></draw:frame></draw:page>"#,
        );
        let doc = OdpExtractor::new()
            .extract_content(&bytes, ODP_MIME, &ExtractionConfig::default())
            .await
            .expect("ODP extraction should succeed");

        assert!(
            doc.elements.iter().any(|e| matches!(e.kind, ElementKind::Table { .. })),
            "the table frame should produce a Table element"
        );
        assert!(
            doc.tables.iter().any(|t| t.cells.iter().flatten().any(|c| c == "A1"))
                && doc.tables.iter().any(|t| t.cells.iter().flatten().any(|c| c == "B1")),
            "table cell text A1/B1 should be extracted"
        );
    }

    /// Confirms that ODP inherits the ODT formula (MathML→LaTeX) pipeline for
    /// free, since it reuses `pre_extract_formulas`/`build_internal_elements`.
    #[tokio::test]
    async fn test_odp_extracts_embedded_formula_as_latex() {
        let content_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document-content
    xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
    xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
    xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
    xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
    xmlns:xlink="http://www.w3.org/1999/xlink">
  <office:body>
    <office:presentation><draw:page draw:name="Formula"><draw:frame><draw:text-box>
      <text:p><draw:frame><draw:object xlink:href="./Object 1"/></draw:frame></text:p>
    </draw:text-box></draw:frame></draw:page></office:presentation>
  </office:body>
</office:document-content>"#;
        let formula_content_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<math xmlns="http://www.w3.org/1998/Math/MathML">
  <msup><mi>x</mi><mn>2</mn></msup>
</math>"#;

        let mut buf = Vec::new();
        {
            let mut zip = zip::ZipWriter::new(Cursor::new(&mut buf));
            let stored = zip::write::FileOptions::<()>::default().compression_method(zip::CompressionMethod::Stored);
            zip.start_file("mimetype", stored).unwrap();
            zip.write_all(ODP_MIME.as_bytes()).unwrap();

            let deflated =
                zip::write::FileOptions::<()>::default().compression_method(zip::CompressionMethod::Deflated);
            zip.start_file("content.xml", deflated).unwrap();
            zip.write_all(content_xml.as_bytes()).unwrap();
            zip.start_file("Object 1/content.xml", deflated).unwrap();
            zip.write_all(formula_content_xml.as_bytes()).unwrap();
            zip.finish().unwrap();
        }

        let doc = OdpExtractor::new()
            .extract_content(&buf, ODP_MIME, &ExtractionConfig::default())
            .await
            .expect("ODP extraction should succeed");

        let formula = doc
            .elements
            .iter()
            .find(|e| matches!(e.kind, ElementKind::Formula))
            .expect("expected a Formula element");
        assert_eq!(formula.text, "x^{2}");
    }

    #[tokio::test]
    async fn test_odp_extractor_supports_odp_mime() {
        let extractor = OdpExtractor::new();
        assert!(extractor.supported_mime_types().contains(&ODP_MIME));
    }

    #[tokio::test]
    async fn test_odp_extractor_plugin_interface() {
        let extractor = OdpExtractor::new();
        assert_eq!(extractor.name(), "odp-extractor");
        assert_eq!(extractor.priority(), 60);
        extractor.initialize().expect("initialize should succeed");
        extractor.shutdown().expect("shutdown should succeed");
    }

    #[tokio::test]
    async fn test_odp_extractor_default() {
        let a = OdpExtractor::new();
        let b = OdpExtractor;
        assert_eq!(a.name(), b.name());
    }

    /// Fixture-backed smoke test over the real `.odp` files in
    /// `test_documents/odp/`. Skips cleanly when the fixtures are absent (the
    /// directory is a git submodule that may not be checked out).
    #[tokio::test]
    async fn test_odp_real_fixtures() {
        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test_documents/odp");
        let Ok(entries) = std::fs::read_dir(&dir) else {
            return;
        };

        let extractor = OdpExtractor::new();
        let mut files = 0usize;
        let mut any_text = false;
        let mut any_slide = false;
        let mut any_table = false;

        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("odp") {
                continue;
            }
            files += 1;
            let bytes = std::fs::read(&path).expect("failed to read fixture");
            let doc = extractor
                .extract_content(&bytes, ODP_MIME, &ExtractionConfig::default())
                .await
                .unwrap_or_else(|e| panic!("extraction failed for {}: {e}", path.display()));

            if doc.elements.iter().any(|e| !e.text.trim().is_empty()) {
                any_text = true;
            }
            if doc.elements.iter().any(|e| matches!(e.kind, ElementKind::Slide { .. })) {
                any_slide = true;
            }
            if doc.elements.iter().any(|e| matches!(e.kind, ElementKind::Table { .. })) {
                any_table = true;
            }
        }

        if files == 0 {
            return;
        }
        assert!(any_text, "at least one .odp fixture should yield non-empty text");
        assert!(any_slide, "at least one .odp fixture should yield a slide marker");
        assert!(
            any_table,
            "at least one .odp fixture (with_table.odp) should yield a table"
        );
    }

    /// A three-slide deck, for exercising `max_pages` independent of the
    /// single-slide `minimal_odp` fixture.
    fn three_slide_odp() -> Vec<u8> {
        odp_bytes(concat!(
            r#"<draw:page draw:name="One"><draw:frame><draw:text-box><text:p>Slide 1</text:p></draw:text-box></draw:frame></draw:page>"#,
            r#"<draw:page draw:name="Two"><draw:frame><draw:text-box><text:p>Slide 2</text:p></draw:text-box></draw:frame></draw:page>"#,
            r#"<draw:page draw:name="Three"><draw:frame><draw:text-box><text:p>Slide 3</text:p></draw:text-box></draw:frame></draw:page>"#,
        ))
    }

    /// #1451: `max_pages` must reject a presentation once its slide count is
    /// known, before any per-slide work (`process_page_object`) begins. Against
    /// unfixed code `build_internal_document` takes no `max_pages` parameter and
    /// nothing calls `enforce_slide_limit`, so this fails to compile; once wired
    /// up but not enforced, `extract_content` would return `Ok` with 3 slides
    /// instead of the expected `SecurityError::TooManyPages`.
    #[tokio::test]
    async fn test_odp_extract_content_rejects_presentation_exceeding_max_pages() {
        let bytes = three_slide_odp();
        let config = ExtractionConfig {
            security_limits: Some(crate::extractors::security::SecurityLimits {
                max_pages: Some(2),
                ..Default::default()
            }),
            ..Default::default()
        };

        let result = OdpExtractor::new().extract_content(&bytes, ODP_MIME, &config).await;
        let error = result.expect_err("a presentation with more slides than max_pages must be rejected");
        let message = error.to_string();
        assert!(
            message.contains("too many pages") || message.contains("max_pages"),
            "error must name the limit that was hit: {message}"
        );
    }

    /// A presentation exactly at the configured `max_pages` ceiling must extract
    /// in full -- the off-by-one boundary case #1451 asked to get right.
    #[tokio::test]
    async fn test_odp_extract_content_succeeds_when_slide_count_is_at_max_pages() {
        let bytes = three_slide_odp();
        let config = ExtractionConfig {
            security_limits: Some(crate::extractors::security::SecurityLimits {
                max_pages: Some(3),
                ..Default::default()
            }),
            ..Default::default()
        };

        let doc = OdpExtractor::new()
            .extract_content(&bytes, ODP_MIME, &config)
            .await
            .expect("a presentation exactly at max_pages must extract fully, not be rejected");
        assert!(
            doc.elements.iter().any(|e| e.text.contains("Slide 3")),
            "extraction at the boundary must still produce every slide's content"
        );
    }

    /// The default `SecurityLimits` (`max_pages: None`) must never reject a
    /// multi-slide presentation: a real ceiling here is opt-in.
    #[tokio::test]
    async fn test_odp_extract_content_succeeds_with_default_max_pages() {
        let bytes = three_slide_odp();
        let result = OdpExtractor::new()
            .extract_content(&bytes, ODP_MIME, &ExtractionConfig::default())
            .await;
        assert!(
            result.is_ok(),
            "default security limits must not reject a normal multi-slide presentation: {:?}",
            result.err()
        );
    }
}