data_modelling_sdk/export/
pdf.rs

1//! PDF exporter with branding support
2//!
3//! Exports ODCS, ODPS, Knowledge Base articles, and Architecture Decision Records
4//! to PDF format with customizable branding options.
5//!
6//! ## Features
7//!
8//! - Logo support (base64 encoded or URL)
9//! - Customizable header and footer
10//! - Brand color theming
11//! - Page numbering
12//! - Proper GitHub Flavored Markdown rendering
13//!
14//! ## WASM Compatibility
15//!
16//! This module is designed to work in both native and WASM environments
17//! by generating PDF as base64-encoded bytes.
18
19use crate::export::ExportError;
20use crate::models::decision::Decision;
21use crate::models::knowledge::KnowledgeArticle;
22use chrono::Utc;
23use serde::{Deserialize, Serialize};
24
25/// Default logo URL for Open Data Modelling
26const DEFAULT_LOGO_URL: &str = "https://opendatamodelling.com/logo.png";
27
28/// Default copyright footer
29const DEFAULT_COPYRIGHT: &str = "© opendatamodelling.com";
30
31/// Branding configuration for PDF exports
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct BrandingConfig {
34    /// Logo as base64-encoded image data (PNG or JPEG)
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub logo_base64: Option<String>,
37
38    /// Logo URL (alternative to base64)
39    #[serde(default = "default_logo_url")]
40    pub logo_url: Option<String>,
41
42    /// Header text (appears at top of each page)
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub header: Option<String>,
45
46    /// Footer text (appears at bottom of each page)
47    #[serde(default = "default_footer")]
48    pub footer: Option<String>,
49
50    /// Primary brand color in hex format (e.g., "#0066CC")
51    #[serde(default = "default_brand_color")]
52    pub brand_color: String,
53
54    /// Company or organization name
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub company_name: Option<String>,
57
58    /// Include page numbers
59    #[serde(default = "default_true")]
60    pub show_page_numbers: bool,
61
62    /// Include generation timestamp
63    #[serde(default = "default_true")]
64    pub show_timestamp: bool,
65
66    /// Font size for body text (in points)
67    #[serde(default = "default_font_size")]
68    pub font_size: u8,
69
70    /// Page size (A4 or Letter)
71    #[serde(default)]
72    pub page_size: PageSize,
73}
74
75fn default_logo_url() -> Option<String> {
76    Some(DEFAULT_LOGO_URL.to_string())
77}
78
79fn default_footer() -> Option<String> {
80    Some(DEFAULT_COPYRIGHT.to_string())
81}
82
83fn default_brand_color() -> String {
84    "#0066CC".to_string()
85}
86
87fn default_true() -> bool {
88    true
89}
90
91fn default_font_size() -> u8 {
92    11
93}
94
95impl Default for BrandingConfig {
96    fn default() -> Self {
97        Self {
98            logo_base64: None,
99            logo_url: default_logo_url(),
100            header: None,
101            footer: default_footer(),
102            brand_color: default_brand_color(),
103            company_name: None,
104            show_page_numbers: default_true(),
105            show_timestamp: default_true(),
106            font_size: default_font_size(),
107            page_size: PageSize::default(),
108        }
109    }
110}
111
112/// Page size options
113#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
114#[serde(rename_all = "lowercase")]
115pub enum PageSize {
116    /// A4 paper size (210 x 297 mm)
117    #[default]
118    A4,
119    /// US Letter size (8.5 x 11 inches)
120    Letter,
121}
122
123impl PageSize {
124    /// Get page dimensions in millimeters (width, height)
125    pub fn dimensions_mm(&self) -> (f64, f64) {
126        match self {
127            PageSize::A4 => (210.0, 297.0),
128            PageSize::Letter => (215.9, 279.4),
129        }
130    }
131}
132
133/// PDF document content types
134#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(tag = "type", rename_all = "lowercase")]
136#[allow(clippy::large_enum_variant)]
137pub enum PdfContent {
138    /// Architecture Decision Record
139    Decision(Decision),
140    /// Knowledge Base article
141    Knowledge(KnowledgeArticle),
142    /// Raw markdown content
143    Markdown { title: String, content: String },
144}
145
146/// Result of PDF export operation
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct PdfExportResult {
149    /// PDF content as base64-encoded bytes
150    pub pdf_base64: String,
151    /// Filename suggestion
152    pub filename: String,
153    /// Number of pages
154    pub page_count: u32,
155    /// Document title
156    pub title: String,
157}
158
159/// PDF exporter with branding support
160pub struct PdfExporter {
161    branding: BrandingConfig,
162}
163
164impl Default for PdfExporter {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170impl PdfExporter {
171    /// Create a new PDF exporter with default branding
172    pub fn new() -> Self {
173        Self {
174            branding: BrandingConfig::default(),
175        }
176    }
177
178    /// Create a new PDF exporter with custom branding
179    pub fn with_branding(branding: BrandingConfig) -> Self {
180        Self { branding }
181    }
182
183    /// Update branding configuration
184    pub fn set_branding(&mut self, branding: BrandingConfig) {
185        self.branding = branding;
186    }
187
188    /// Get current branding configuration
189    pub fn branding(&self) -> &BrandingConfig {
190        &self.branding
191    }
192
193    /// Export a Decision to PDF
194    pub fn export_decision(&self, decision: &Decision) -> Result<PdfExportResult, ExportError> {
195        let title = format!("{}: {}", decision.formatted_number(), decision.title);
196        let markdown = self.decision_to_markdown(decision);
197        self.generate_pdf(
198            &title,
199            &markdown,
200            &decision.markdown_filename().replace(".md", ".pdf"),
201            "Decision Record",
202        )
203    }
204
205    /// Export a Knowledge article to PDF
206    pub fn export_knowledge(
207        &self,
208        article: &KnowledgeArticle,
209    ) -> Result<PdfExportResult, ExportError> {
210        let title = format!("{}: {}", article.formatted_number(), article.title);
211        let markdown = self.knowledge_to_markdown(article);
212        self.generate_pdf(
213            &title,
214            &markdown,
215            &article.markdown_filename().replace(".md", ".pdf"),
216            "Knowledge Base",
217        )
218    }
219
220    /// Export raw markdown content to PDF
221    pub fn export_markdown(
222        &self,
223        title: &str,
224        content: &str,
225        filename: &str,
226    ) -> Result<PdfExportResult, ExportError> {
227        self.generate_pdf(title, content, filename, "Document")
228    }
229
230    /// Export an ODCS Data Contract (Table) to PDF
231    pub fn export_table(
232        &self,
233        table: &crate::models::Table,
234    ) -> Result<PdfExportResult, ExportError> {
235        let title = table.name.clone();
236        let markdown = self.table_to_markdown(table);
237        let filename = format!("{}.pdf", table.name.to_lowercase().replace(' ', "_"));
238        self.generate_pdf(&title, &markdown, &filename, "Data Contract")
239    }
240
241    /// Export an ODPS Data Product to PDF
242    pub fn export_data_product(
243        &self,
244        product: &crate::models::odps::ODPSDataProduct,
245    ) -> Result<PdfExportResult, ExportError> {
246        let title = product.name.clone().unwrap_or_else(|| product.id.clone());
247        let markdown = self.data_product_to_markdown(product);
248        let filename = format!(
249            "{}.pdf",
250            title.to_lowercase().replace(' ', "_").replace('/', "-")
251        );
252        self.generate_pdf(&title, &markdown, &filename, "Data Product")
253    }
254
255    /// Export a CADS Asset to PDF
256    pub fn export_cads_asset(
257        &self,
258        asset: &crate::models::cads::CADSAsset,
259    ) -> Result<PdfExportResult, ExportError> {
260        let title = asset.name.clone();
261        let markdown = self.cads_asset_to_markdown(asset);
262        let filename = format!(
263            "{}.pdf",
264            title.to_lowercase().replace(' ', "_").replace('/', "-")
265        );
266        self.generate_pdf(&title, &markdown, &filename, "Compute Asset")
267    }
268
269    // ============================================================================
270    // Public Markdown Generation Methods (for WASM bindings)
271    // ============================================================================
272
273    /// Convert an ODCS Table (Data Contract) to Markdown format.
274    ///
275    /// This is a public wrapper for use by WASM bindings.
276    pub fn table_to_markdown_public(&self, table: &crate::models::Table) -> String {
277        self.table_to_markdown(table)
278    }
279
280    /// Convert an ODPS Data Product to Markdown format.
281    ///
282    /// This is a public wrapper for use by WASM bindings.
283    pub fn data_product_to_markdown_public(
284        &self,
285        product: &crate::models::odps::ODPSDataProduct,
286    ) -> String {
287        self.data_product_to_markdown(product)
288    }
289
290    /// Convert a CADS Asset to Markdown format.
291    ///
292    /// This is a public wrapper for use by WASM bindings.
293    pub fn cads_asset_to_markdown_public(&self, asset: &crate::models::cads::CADSAsset) -> String {
294        self.cads_asset_to_markdown(asset)
295    }
296
297    /// Convert Decision to properly formatted GFM markdown for PDF rendering
298    /// Note: Logo and copyright footer are rendered as part of the PDF template,
299    /// not in the markdown content.
300    fn decision_to_markdown(&self, decision: &Decision) -> String {
301        use crate::models::decision::DecisionStatus;
302
303        let mut md = String::new();
304
305        // Main title
306        md.push_str(&format!(
307            "# {}: {}\n\n",
308            decision.formatted_number(),
309            decision.title
310        ));
311
312        // Metadata table
313        let status_text = match decision.status {
314            DecisionStatus::Draft => "Draft",
315            DecisionStatus::Proposed => "Proposed",
316            DecisionStatus::Accepted => "Accepted",
317            DecisionStatus::Deprecated => "Deprecated",
318            DecisionStatus::Superseded => "Superseded",
319            DecisionStatus::Rejected => "Rejected",
320        };
321
322        md.push_str("| Property | Value |\n");
323        md.push_str("|----------|-------|\n");
324        md.push_str(&format!("| **Status** | {} |\n", status_text));
325        md.push_str(&format!("| **Category** | {} |\n", decision.category));
326        md.push_str(&format!(
327            "| **Date** | {} |\n",
328            decision.date.format("%Y-%m-%d")
329        ));
330
331        if !decision.authors.is_empty() {
332            md.push_str(&format!(
333                "| **Authors** | {} |\n",
334                decision.authors.join(", ")
335            ));
336        }
337
338        if let Some(domain) = &decision.domain {
339            md.push_str(&format!("| **Domain** | {} |\n", domain));
340        }
341
342        md.push_str("\n---\n\n");
343
344        // Context section
345        md.push_str("## Context\n\n");
346        md.push_str(&decision.context);
347        md.push_str("\n\n");
348
349        // Decision section
350        md.push_str("## Decision\n\n");
351        md.push_str(&decision.decision);
352        md.push_str("\n\n");
353
354        // Consequences section
355        if let Some(consequences) = &decision.consequences {
356            md.push_str("## Consequences\n\n");
357            md.push_str(consequences);
358            md.push_str("\n\n");
359        }
360
361        // Stakeholders section (if present)
362        if !decision.consulted.is_empty() || !decision.informed.is_empty() {
363            md.push_str("## Stakeholders\n\n");
364            md.push_str("| Role | Participants |\n");
365            md.push_str("|------|-------------|\n");
366
367            if !decision.deciders.is_empty() {
368                md.push_str(&format!(
369                    "| **Deciders** | {} |\n",
370                    decision.deciders.join(", ")
371                ));
372            }
373            if !decision.consulted.is_empty() {
374                md.push_str(&format!(
375                    "| **Consulted** | {} |\n",
376                    decision.consulted.join(", ")
377                ));
378            }
379            if !decision.informed.is_empty() {
380                md.push_str(&format!(
381                    "| **Informed** | {} |\n",
382                    decision.informed.join(", ")
383                ));
384            }
385            md.push('\n');
386        }
387
388        // Decision Drivers (if any)
389        if !decision.drivers.is_empty() {
390            md.push_str("## Decision Drivers\n\n");
391            for driver in &decision.drivers {
392                let priority = match driver.priority {
393                    Some(crate::models::decision::DriverPriority::High) => " *(High Priority)*",
394                    Some(crate::models::decision::DriverPriority::Medium) => " *(Medium Priority)*",
395                    Some(crate::models::decision::DriverPriority::Low) => " *(Low Priority)*",
396                    None => "",
397                };
398                md.push_str(&format!("- {}{}\n", driver.description, priority));
399            }
400            md.push('\n');
401        }
402
403        // Options Considered (if any) - with side-by-side Pros/Cons
404        if !decision.options.is_empty() {
405            md.push_str("## Options Considered\n\n");
406            for (i, option) in decision.options.iter().enumerate() {
407                let selected_marker = if option.selected {
408                    " **(Selected)**"
409                } else {
410                    ""
411                };
412                md.push_str(&format!(
413                    "### Option {}: {}{}\n\n",
414                    i + 1,
415                    option.name,
416                    selected_marker
417                ));
418
419                if let Some(desc) = &option.description {
420                    md.push_str(&format!("{}\n\n", desc));
421                }
422
423                // Render Pros and Cons side by side using a table
424                if !option.pros.is_empty() || !option.cons.is_empty() {
425                    md.push_str("| Pros | Cons |\n");
426                    md.push_str("|------|------|\n");
427
428                    let max_rows = std::cmp::max(option.pros.len(), option.cons.len());
429                    for row in 0..max_rows {
430                        let pro = option
431                            .pros
432                            .get(row)
433                            .map(|s| format!("+ {}", s))
434                            .unwrap_or_default();
435                        let con = option
436                            .cons
437                            .get(row)
438                            .map(|s| format!("- {}", s))
439                            .unwrap_or_default();
440                        md.push_str(&format!("| {} | {} |\n", pro, con));
441                    }
442                    md.push('\n');
443                }
444            }
445        }
446
447        // Linked Assets (if any)
448        if !decision.linked_assets.is_empty() {
449            md.push_str("## Linked Assets\n\n");
450            md.push_str("| Asset | Type |\n");
451            md.push_str("|-------|------|\n");
452            for asset in &decision.linked_assets {
453                md.push_str(&format!(
454                    "| {} | {} |\n",
455                    asset.asset_name, asset.asset_type
456                ));
457            }
458            md.push('\n');
459        }
460
461        // Notes (if present)
462        if let Some(notes) = &decision.notes {
463            md.push_str("## Notes\n\n");
464            md.push_str(notes);
465            md.push_str("\n\n");
466        }
467
468        // Horizontal rule before footer
469        md.push_str("---\n\n");
470
471        // Tags
472        if !decision.tags.is_empty() {
473            let tag_strings: Vec<String> =
474                decision.tags.iter().map(|t| format!("`{}`", t)).collect();
475            md.push_str(&format!("**Tags:** {}\n\n", tag_strings.join(" ")));
476        }
477
478        // Horizontal rule
479        md.push_str("---\n\n");
480
481        // Timestamps
482        md.push_str(&format!(
483            "*Created: {} | Last Updated: {}*\n\n",
484            decision.created_at.format("%Y-%m-%d %H:%M UTC"),
485            decision.updated_at.format("%Y-%m-%d %H:%M UTC")
486        ));
487
488        md
489    }
490
491    /// Convert Knowledge article to properly formatted GFM markdown for PDF rendering
492    /// Note: Logo and copyright footer are rendered as part of the PDF template,
493    /// not in the markdown content.
494    fn knowledge_to_markdown(&self, article: &KnowledgeArticle) -> String {
495        use crate::models::knowledge::{KnowledgeStatus, KnowledgeType};
496
497        let mut md = String::new();
498
499        // Main title
500        md.push_str(&format!(
501            "# {}: {}\n\n",
502            article.formatted_number(),
503            article.title
504        ));
505
506        // Metadata table
507        let status_text = match article.status {
508            KnowledgeStatus::Draft => "Draft",
509            KnowledgeStatus::Review => "Under Review",
510            KnowledgeStatus::Published => "Published",
511            KnowledgeStatus::Archived => "Archived",
512            KnowledgeStatus::Deprecated => "Deprecated",
513        };
514
515        let type_text = match article.article_type {
516            KnowledgeType::Guide => "Guide",
517            KnowledgeType::Standard => "Standard",
518            KnowledgeType::Reference => "Reference",
519            KnowledgeType::HowTo => "How-To",
520            KnowledgeType::Troubleshooting => "Troubleshooting",
521            KnowledgeType::Policy => "Policy",
522            KnowledgeType::Template => "Template",
523            KnowledgeType::Concept => "Concept",
524            KnowledgeType::Runbook => "Runbook",
525            KnowledgeType::Tutorial => "Tutorial",
526            KnowledgeType::Glossary => "Glossary",
527        };
528
529        md.push_str("| Property | Value |\n");
530        md.push_str("|----------|-------|\n");
531        md.push_str(&format!("| **Type** | {} |\n", type_text));
532        md.push_str(&format!("| **Status** | {} |\n", status_text));
533
534        if let Some(domain) = &article.domain {
535            md.push_str(&format!("| **Domain** | {} |\n", domain));
536        }
537
538        if !article.authors.is_empty() {
539            md.push_str(&format!(
540                "| **Authors** | {} |\n",
541                article.authors.join(", ")
542            ));
543        }
544
545        if let Some(skill_level) = &article.skill_level {
546            md.push_str(&format!("| **Skill Level** | {} |\n", skill_level));
547        }
548
549        if !article.audience.is_empty() {
550            md.push_str(&format!(
551                "| **Audience** | {} |\n",
552                article.audience.join(", ")
553            ));
554        }
555
556        md.push_str("\n---\n\n");
557
558        // Summary section
559        md.push_str("## Summary\n\n");
560        md.push_str(&article.summary);
561        md.push_str("\n\n---\n\n");
562
563        // Content section (the main article content - already in markdown)
564        md.push_str(&article.content);
565        md.push_str("\n\n");
566
567        // Related Articles (if any)
568        if !article.related_articles.is_empty() {
569            md.push_str("---\n\n");
570            md.push_str("## Related Articles\n\n");
571            md.push_str("| Article | Relationship |\n");
572            md.push_str("|---------|-------------|\n");
573            for related in &article.related_articles {
574                md.push_str(&format!(
575                    "| {}: {} | {} |\n",
576                    related.article_number, related.title, related.relationship
577                ));
578            }
579            md.push('\n');
580        }
581
582        // Notes (if present)
583        if let Some(notes) = &article.notes {
584            md.push_str("---\n\n");
585            md.push_str("## Notes\n\n");
586            md.push_str(notes);
587            md.push_str("\n\n");
588        }
589
590        // Horizontal rule before footer
591        md.push_str("---\n\n");
592
593        // Tags
594        if !article.tags.is_empty() {
595            let tag_strings: Vec<String> =
596                article.tags.iter().map(|t| format!("`{}`", t)).collect();
597            md.push_str(&format!("**Tags:** {}\n\n", tag_strings.join(" ")));
598        }
599
600        // Horizontal rule
601        md.push_str("---\n\n");
602
603        // Timestamps
604        md.push_str(&format!(
605            "*Created: {} | Last Updated: {}*\n\n",
606            article.created_at.format("%Y-%m-%d %H:%M UTC"),
607            article.updated_at.format("%Y-%m-%d %H:%M UTC")
608        ));
609
610        md
611    }
612
613    /// Convert Table (ODCS Data Contract) to properly formatted GFM markdown for PDF rendering
614    fn table_to_markdown(&self, table: &crate::models::Table) -> String {
615        let mut md = String::new();
616
617        // Main title
618        md.push_str(&format!("# {}\n\n", table.name));
619
620        // Metadata table
621        md.push_str("| Property | Value |\n");
622        md.push_str("|----------|-------|\n");
623
624        if let Some(db_type) = &table.database_type {
625            md.push_str(&format!("| **Database Type** | {:?} |\n", db_type));
626        }
627
628        if let Some(catalog) = &table.catalog_name {
629            md.push_str(&format!("| **Catalog** | {} |\n", catalog));
630        }
631
632        if let Some(schema) = &table.schema_name {
633            md.push_str(&format!("| **Schema** | {} |\n", schema));
634        }
635
636        if let Some(owner) = &table.owner {
637            md.push_str(&format!("| **Owner** | {} |\n", owner));
638        }
639
640        if !table.medallion_layers.is_empty() {
641            let layers: Vec<String> = table
642                .medallion_layers
643                .iter()
644                .map(|l| format!("{:?}", l))
645                .collect();
646            md.push_str(&format!(
647                "| **Medallion Layers** | {} |\n",
648                layers.join(", ")
649            ));
650        }
651
652        if let Some(scd) = &table.scd_pattern {
653            md.push_str(&format!("| **SCD Pattern** | {:?} |\n", scd));
654        }
655
656        if let Some(dv) = &table.data_vault_classification {
657            md.push_str(&format!("| **Data Vault** | {:?} |\n", dv));
658        }
659
660        if let Some(level) = &table.modeling_level {
661            md.push_str(&format!("| **Modeling Level** | {:?} |\n", level));
662        }
663
664        if let Some(infra) = &table.infrastructure_type {
665            md.push_str(&format!("| **Infrastructure** | {:?} |\n", infra));
666        }
667
668        md.push_str(&format!("| **Columns** | {} |\n", table.columns.len()));
669
670        md.push_str("\n---\n\n");
671
672        // Notes section
673        if let Some(notes) = &table.notes {
674            md.push_str("## Description\n\n");
675            md.push_str(notes);
676            md.push_str("\n\n---\n\n");
677        }
678
679        // Columns section
680        md.push_str("## Columns\n\n");
681        md.push_str("| Column | Type | Nullable | PK | Description |\n");
682        md.push_str("|--------|------|----------|----|--------------|\n");
683
684        for col in &table.columns {
685            let nullable = if col.nullable { "Yes" } else { "No" };
686            let pk = if col.primary_key { "Yes" } else { "" };
687            let desc = col
688                .description
689                .chars()
690                .take(50)
691                .collect::<String>()
692                .replace('|', "/");
693            let desc_display = if col.description.len() > 50 {
694                format!("{}...", desc)
695            } else {
696                desc
697            };
698
699            md.push_str(&format!(
700                "| {} | {} | {} | {} | {} |\n",
701                col.name, col.data_type, nullable, pk, desc_display
702            ));
703        }
704        md.push('\n');
705
706        // Column Details (for columns with descriptions, business names, constraints, etc.)
707        let cols_with_details: Vec<_> = table
708            .columns
709            .iter()
710            .filter(|c| {
711                !c.description.is_empty()
712                    || c.business_name.is_some()
713                    || !c.enum_values.is_empty()
714                    || c.physical_type.is_some()
715                    || c.unique
716                    || c.partitioned
717                    || c.classification.is_some()
718                    || c.critical_data_element
719            })
720            .collect();
721
722        if !cols_with_details.is_empty() {
723            md.push_str("## Column Details\n\n");
724            for col in cols_with_details {
725                md.push_str(&format!("### {}\n\n", col.name));
726
727                if let Some(biz_name) = &col.business_name {
728                    md.push_str(&format!("**Business Name:** {}\n\n", biz_name));
729                }
730
731                if !col.description.is_empty() {
732                    md.push_str(&format!("{}\n\n", col.description));
733                }
734
735                // Physical type if different from logical
736                if let Some(phys) = &col.physical_type
737                    && phys != &col.data_type
738                {
739                    md.push_str(&format!("**Physical Type:** {}\n\n", phys));
740                }
741
742                // Constraints
743                let mut constraints = Vec::new();
744                if col.unique {
745                    constraints.push("Unique");
746                }
747                if col.partitioned {
748                    constraints.push("Partitioned");
749                }
750                if col.clustered {
751                    constraints.push("Clustered");
752                }
753                if col.critical_data_element {
754                    constraints.push("Critical Data Element");
755                }
756                if !constraints.is_empty() {
757                    md.push_str(&format!("**Constraints:** {}\n\n", constraints.join(", ")));
758                }
759
760                // Classification
761                if let Some(class) = &col.classification {
762                    md.push_str(&format!("**Classification:** {}\n\n", class));
763                }
764
765                // Enum values
766                if !col.enum_values.is_empty() {
767                    md.push_str("**Allowed Values:**\n");
768                    for val in &col.enum_values {
769                        md.push_str(&format!("- `{}`\n", val));
770                    }
771                    md.push('\n');
772                }
773
774                // Examples
775                if !col.examples.is_empty() {
776                    let examples_str: Vec<String> =
777                        col.examples.iter().map(|v| v.to_string()).collect();
778                    md.push_str(&format!("**Examples:** {}\n\n", examples_str.join(", ")));
779                }
780
781                // Default value
782                if let Some(default) = &col.default_value {
783                    md.push_str(&format!("**Default:** {}\n\n", default));
784                }
785            }
786        }
787
788        // SLA section
789        if let Some(sla) = &table.sla
790            && !sla.is_empty()
791        {
792            md.push_str("---\n\n## Service Level Agreements\n\n");
793            md.push_str("| Property | Value | Unit | Description |\n");
794            md.push_str("|----------|-------|------|-------------|\n");
795            for sla_prop in sla {
796                let desc = sla_prop
797                    .description
798                    .as_deref()
799                    .unwrap_or("")
800                    .replace('|', "/");
801                md.push_str(&format!(
802                    "| {} | {} | {} | {} |\n",
803                    sla_prop.property, sla_prop.value, sla_prop.unit, desc
804                ));
805            }
806            md.push('\n');
807        }
808
809        // Contact Details
810        if let Some(contact) = &table.contact_details {
811            md.push_str("---\n\n## Contact Information\n\n");
812            if let Some(name) = &contact.name {
813                md.push_str(&format!("- **Name:** {}\n", name));
814            }
815            if let Some(email) = &contact.email {
816                md.push_str(&format!("- **Email:** {}\n", email));
817            }
818            if let Some(role) = &contact.role {
819                md.push_str(&format!("- **Role:** {}\n", role));
820            }
821            if let Some(phone) = &contact.phone {
822                md.push_str(&format!("- **Phone:** {}\n", phone));
823            }
824            md.push('\n');
825        }
826
827        // Quality Rules
828        if !table.quality.is_empty() {
829            md.push_str("---\n\n## Quality Rules\n\n");
830            for (i, rule) in table.quality.iter().enumerate() {
831                md.push_str(&format!("**Rule {}:**\n", i + 1));
832                for (key, value) in rule {
833                    md.push_str(&format!("- {}: {}\n", key, value));
834                }
835                md.push('\n');
836            }
837        }
838
839        // ODCS Metadata (legacy format fields preserved from import)
840        if !table.odcl_metadata.is_empty() {
841            md.push_str("---\n\n## ODCS Contract Metadata\n\n");
842            // Sort keys for consistent output
843            let mut keys: Vec<_> = table.odcl_metadata.keys().collect();
844            keys.sort();
845            for key in keys {
846                if let Some(value) = table.odcl_metadata.get(key) {
847                    // Format the value based on its type
848                    let formatted = match value {
849                        serde_json::Value::String(s) => s.clone(),
850                        serde_json::Value::Array(arr) => {
851                            let items: Vec<String> = arr.iter().map(|v| v.to_string()).collect();
852                            items.join(", ")
853                        }
854                        serde_json::Value::Object(_) => {
855                            // For nested objects, show as formatted JSON-like structure
856                            serde_json::to_string_pretty(value)
857                                .unwrap_or_else(|_| value.to_string())
858                        }
859                        _ => value.to_string(),
860                    };
861                    md.push_str(&format!("- **{}:** {}\n", key, formatted));
862                }
863            }
864            md.push('\n');
865        }
866
867        // Tags
868        if !table.tags.is_empty() {
869            md.push_str("---\n\n");
870            let tag_strings: Vec<String> = table.tags.iter().map(|t| format!("`{}`", t)).collect();
871            md.push_str(&format!("**Tags:** {}\n\n", tag_strings.join(" ")));
872        }
873
874        // Timestamps
875        md.push_str("---\n\n");
876        md.push_str(&format!(
877            "*Created: {} | Last Updated: {}*\n\n",
878            table.created_at.format("%Y-%m-%d %H:%M UTC"),
879            table.updated_at.format("%Y-%m-%d %H:%M UTC")
880        ));
881
882        md
883    }
884
885    /// Convert ODPS Data Product to properly formatted GFM markdown for PDF rendering
886    fn data_product_to_markdown(&self, product: &crate::models::odps::ODPSDataProduct) -> String {
887        use crate::models::odps::ODPSStatus;
888
889        let mut md = String::new();
890
891        // Main title
892        let title = product.name.as_deref().unwrap_or(&product.id);
893        md.push_str(&format!("# {}\n\n", title));
894
895        // Metadata table
896        let status_text = match product.status {
897            ODPSStatus::Proposed => "Proposed",
898            ODPSStatus::Draft => "Draft",
899            ODPSStatus::Active => "Active",
900            ODPSStatus::Deprecated => "Deprecated",
901            ODPSStatus::Retired => "Retired",
902        };
903
904        md.push_str("| Property | Value |\n");
905        md.push_str("|----------|-------|\n");
906        md.push_str(&format!("| **ID** | {} |\n", product.id));
907        md.push_str(&format!("| **Status** | {} |\n", status_text));
908        md.push_str(&format!("| **API Version** | {} |\n", product.api_version));
909
910        if let Some(version) = &product.version {
911            md.push_str(&format!("| **Version** | {} |\n", version));
912        }
913
914        if let Some(domain) = &product.domain {
915            md.push_str(&format!("| **Domain** | {} |\n", domain));
916        }
917
918        if let Some(tenant) = &product.tenant {
919            md.push_str(&format!("| **Tenant** | {} |\n", tenant));
920        }
921
922        md.push_str("\n---\n\n");
923
924        // Description section
925        if let Some(desc) = &product.description {
926            md.push_str("## Description\n\n");
927            if let Some(purpose) = &desc.purpose {
928                md.push_str(&format!("**Purpose:** {}\n\n", purpose));
929            }
930            if let Some(usage) = &desc.usage {
931                md.push_str(&format!("**Usage:** {}\n\n", usage));
932            }
933            if let Some(limitations) = &desc.limitations {
934                md.push_str(&format!("**Limitations:** {}\n\n", limitations));
935            }
936            md.push_str("---\n\n");
937        }
938
939        // Input Ports
940        if let Some(input_ports) = &product.input_ports
941            && !input_ports.is_empty()
942        {
943            md.push_str("## Input Ports\n\n");
944            md.push_str("| Name | Version | Contract ID |\n");
945            md.push_str("|------|---------|-------------|\n");
946            for port in input_ports {
947                md.push_str(&format!(
948                    "| {} | {} | {} |\n",
949                    port.name, port.version, port.contract_id
950                ));
951            }
952            md.push('\n');
953        }
954
955        // Output Ports
956        if let Some(output_ports) = &product.output_ports
957            && !output_ports.is_empty()
958        {
959            md.push_str("## Output Ports\n\n");
960            md.push_str("| Name | Version | Type | Contract ID |\n");
961            md.push_str("|------|---------|------|-------------|\n");
962            for port in output_ports {
963                let port_type = port.r#type.as_deref().unwrap_or("-");
964                let contract = port.contract_id.as_deref().unwrap_or("-");
965                md.push_str(&format!(
966                    "| {} | {} | {} | {} |\n",
967                    port.name, port.version, port_type, contract
968                ));
969            }
970            md.push('\n');
971
972            // Output port details
973            for port in output_ports {
974                if port.description.is_some()
975                    || port.sbom.is_some()
976                    || port.input_contracts.is_some()
977                {
978                    md.push_str(&format!("### {}\n\n", port.name));
979                    if let Some(desc) = &port.description {
980                        md.push_str(&format!("{}\n\n", desc));
981                    }
982                    if let Some(sbom) = &port.sbom
983                        && !sbom.is_empty()
984                    {
985                        md.push_str("**SBOM:**\n");
986                        for s in sbom {
987                            let stype = s.r#type.as_deref().unwrap_or("unknown");
988                            md.push_str(&format!("- {} ({})\n", s.url, stype));
989                        }
990                        md.push('\n');
991                    }
992                    if let Some(contracts) = &port.input_contracts
993                        && !contracts.is_empty()
994                    {
995                        md.push_str("**Input Contracts:**\n");
996                        for c in contracts {
997                            md.push_str(&format!("- {} v{}\n", c.id, c.version));
998                        }
999                        md.push('\n');
1000                    }
1001                }
1002            }
1003        }
1004
1005        // Management Ports
1006        if let Some(mgmt_ports) = &product.management_ports
1007            && !mgmt_ports.is_empty()
1008        {
1009            md.push_str("## Management Ports\n\n");
1010            md.push_str("| Name | Type | Content |\n");
1011            md.push_str("|------|------|--------|\n");
1012            for port in mgmt_ports {
1013                let port_type = port.r#type.as_deref().unwrap_or("-");
1014                md.push_str(&format!(
1015                    "| {} | {} | {} |\n",
1016                    port.name, port_type, port.content
1017                ));
1018            }
1019            md.push('\n');
1020        }
1021
1022        // Support Channels
1023        if let Some(support) = &product.support
1024            && !support.is_empty()
1025        {
1026            md.push_str("## Support Channels\n\n");
1027            md.push_str("| Channel | URL | Description |\n");
1028            md.push_str("|---------|-----|-------------|\n");
1029            for s in support {
1030                let desc = s.description.as_deref().unwrap_or("-").replace('|', "/");
1031                md.push_str(&format!("| {} | {} | {} |\n", s.channel, s.url, desc));
1032            }
1033            md.push('\n');
1034        }
1035
1036        // Team
1037        if let Some(team) = &product.team {
1038            md.push_str("## Team\n\n");
1039            if let Some(name) = &team.name {
1040                md.push_str(&format!("**Team Name:** {}\n\n", name));
1041            }
1042            if let Some(desc) = &team.description {
1043                md.push_str(&format!("{}\n\n", desc));
1044            }
1045            if let Some(members) = &team.members
1046                && !members.is_empty()
1047            {
1048                md.push_str("### Team Members\n\n");
1049                md.push_str("| Username | Name | Role |\n");
1050                md.push_str("|----------|------|------|\n");
1051                for member in members {
1052                    let name = member.name.as_deref().unwrap_or("-");
1053                    let role = member.role.as_deref().unwrap_or("-");
1054                    md.push_str(&format!("| {} | {} | {} |\n", member.username, name, role));
1055                }
1056                md.push('\n');
1057            }
1058        }
1059
1060        // Tags
1061        if !product.tags.is_empty() {
1062            md.push_str("---\n\n");
1063            let tag_strings: Vec<String> =
1064                product.tags.iter().map(|t| format!("`{}`", t)).collect();
1065            md.push_str(&format!("**Tags:** {}\n\n", tag_strings.join(" ")));
1066        }
1067
1068        // Timestamps
1069        if product.created_at.is_some() || product.updated_at.is_some() {
1070            md.push_str("---\n\n");
1071            if let Some(created) = &product.created_at {
1072                md.push_str(&format!(
1073                    "*Created: {}",
1074                    created.format("%Y-%m-%d %H:%M UTC")
1075                ));
1076                if let Some(updated) = &product.updated_at {
1077                    md.push_str(&format!(
1078                        " | Last Updated: {}",
1079                        updated.format("%Y-%m-%d %H:%M UTC")
1080                    ));
1081                }
1082                md.push_str("*\n\n");
1083            } else if let Some(updated) = &product.updated_at {
1084                md.push_str(&format!(
1085                    "*Last Updated: {}*\n\n",
1086                    updated.format("%Y-%m-%d %H:%M UTC")
1087                ));
1088            }
1089        }
1090
1091        md
1092    }
1093
1094    /// Convert CADS Asset to properly formatted GFM markdown for PDF rendering
1095    fn cads_asset_to_markdown(&self, asset: &crate::models::cads::CADSAsset) -> String {
1096        use crate::models::cads::{CADSKind, CADSStatus};
1097
1098        let mut md = String::new();
1099
1100        // Main title
1101        md.push_str(&format!("# {}\n\n", asset.name));
1102
1103        // Metadata table
1104        let kind_text = match asset.kind {
1105            CADSKind::AIModel => "AI Model",
1106            CADSKind::MLPipeline => "ML Pipeline",
1107            CADSKind::Application => "Application",
1108            CADSKind::DataPipeline => "Data Pipeline",
1109            CADSKind::ETLProcess => "ETL Process",
1110            CADSKind::ETLPipeline => "ETL Pipeline",
1111            CADSKind::SourceSystem => "Source System",
1112            CADSKind::DestinationSystem => "Destination System",
1113        };
1114
1115        let status_text = match asset.status {
1116            CADSStatus::Draft => "Draft",
1117            CADSStatus::Validated => "Validated",
1118            CADSStatus::Production => "Production",
1119            CADSStatus::Deprecated => "Deprecated",
1120        };
1121
1122        md.push_str("| Property | Value |\n");
1123        md.push_str("|----------|-------|\n");
1124        md.push_str(&format!("| **ID** | {} |\n", asset.id));
1125        md.push_str(&format!("| **Kind** | {} |\n", kind_text));
1126        md.push_str(&format!("| **Version** | {} |\n", asset.version));
1127        md.push_str(&format!("| **Status** | {} |\n", status_text));
1128        md.push_str(&format!("| **API Version** | {} |\n", asset.api_version));
1129
1130        if let Some(domain) = &asset.domain {
1131            md.push_str(&format!("| **Domain** | {} |\n", domain));
1132        }
1133
1134        md.push_str("\n---\n\n");
1135
1136        // Description section
1137        if let Some(desc) = &asset.description {
1138            md.push_str("## Description\n\n");
1139            if let Some(purpose) = &desc.purpose {
1140                md.push_str(&format!("**Purpose:** {}\n\n", purpose));
1141            }
1142            if let Some(usage) = &desc.usage {
1143                md.push_str(&format!("**Usage:** {}\n\n", usage));
1144            }
1145            if let Some(limitations) = &desc.limitations {
1146                md.push_str(&format!("**Limitations:** {}\n\n", limitations));
1147            }
1148            if let Some(links) = &desc.external_links
1149                && !links.is_empty()
1150            {
1151                md.push_str("**External Links:**\n");
1152                for link in links {
1153                    let desc = link.description.as_deref().unwrap_or("");
1154                    md.push_str(&format!("- {} {}\n", link.url, desc));
1155                }
1156                md.push('\n');
1157            }
1158            md.push_str("---\n\n");
1159        }
1160
1161        // Runtime section
1162        if let Some(runtime) = &asset.runtime {
1163            md.push_str("## Runtime\n\n");
1164            if let Some(env) = &runtime.environment {
1165                md.push_str(&format!("**Environment:** {}\n\n", env));
1166            }
1167            if let Some(endpoints) = &runtime.endpoints
1168                && !endpoints.is_empty()
1169            {
1170                md.push_str("**Endpoints:**\n");
1171                for ep in endpoints {
1172                    md.push_str(&format!("- {}\n", ep));
1173                }
1174                md.push('\n');
1175            }
1176            if let Some(container) = &runtime.container
1177                && let Some(image) = &container.image
1178            {
1179                md.push_str(&format!("**Container Image:** {}\n\n", image));
1180            }
1181            if let Some(resources) = &runtime.resources {
1182                md.push_str("**Resources:**\n");
1183                if let Some(cpu) = &resources.cpu {
1184                    md.push_str(&format!("- CPU: {}\n", cpu));
1185                }
1186                if let Some(memory) = &resources.memory {
1187                    md.push_str(&format!("- Memory: {}\n", memory));
1188                }
1189                if let Some(gpu) = &resources.gpu {
1190                    md.push_str(&format!("- GPU: {}\n", gpu));
1191                }
1192                md.push('\n');
1193            }
1194        }
1195
1196        // SLA section
1197        if let Some(sla) = &asset.sla
1198            && let Some(props) = &sla.properties
1199            && !props.is_empty()
1200        {
1201            md.push_str("## Service Level Agreements\n\n");
1202            md.push_str("| Element | Value | Unit | Driver |\n");
1203            md.push_str("|---------|-------|------|--------|\n");
1204            for prop in props {
1205                let driver = prop.driver.as_deref().unwrap_or("-");
1206                md.push_str(&format!(
1207                    "| {} | {} | {} | {} |\n",
1208                    prop.element, prop.value, prop.unit, driver
1209                ));
1210            }
1211            md.push('\n');
1212        }
1213
1214        // Pricing section
1215        if let Some(pricing) = &asset.pricing {
1216            md.push_str("## Pricing\n\n");
1217            if let Some(model) = &pricing.model {
1218                md.push_str(&format!("**Model:** {:?}\n\n", model));
1219            }
1220            if let Some(currency) = &pricing.currency
1221                && let Some(cost) = pricing.unit_cost
1222            {
1223                let unit = pricing.billing_unit.as_deref().unwrap_or("unit");
1224                md.push_str(&format!("**Cost:** {} {} per {}\n\n", cost, currency, unit));
1225            }
1226            if let Some(notes) = &pricing.notes {
1227                md.push_str(&format!("**Notes:** {}\n\n", notes));
1228            }
1229        }
1230
1231        // Team section
1232        if let Some(team) = &asset.team
1233            && !team.is_empty()
1234        {
1235            md.push_str("## Team\n\n");
1236            md.push_str("| Role | Name | Contact |\n");
1237            md.push_str("|------|------|--------|\n");
1238            for member in team {
1239                let contact = member.contact.as_deref().unwrap_or("-");
1240                md.push_str(&format!(
1241                    "| {} | {} | {} |\n",
1242                    member.role, member.name, contact
1243                ));
1244            }
1245            md.push('\n');
1246        }
1247
1248        // Risk section
1249        if let Some(risk) = &asset.risk {
1250            md.push_str("## Risk Management\n\n");
1251            if let Some(classification) = &risk.classification {
1252                md.push_str(&format!("**Classification:** {:?}\n\n", classification));
1253            }
1254            if let Some(areas) = &risk.impact_areas
1255                && !areas.is_empty()
1256            {
1257                let areas_str: Vec<String> = areas.iter().map(|a| format!("{:?}", a)).collect();
1258                md.push_str(&format!("**Impact Areas:** {}\n\n", areas_str.join(", ")));
1259            }
1260            if let Some(intended) = &risk.intended_use {
1261                md.push_str(&format!("**Intended Use:** {}\n\n", intended));
1262            }
1263            if let Some(out_of_scope) = &risk.out_of_scope_use {
1264                md.push_str(&format!("**Out of Scope:** {}\n\n", out_of_scope));
1265            }
1266            if let Some(mitigations) = &risk.mitigations
1267                && !mitigations.is_empty()
1268            {
1269                md.push_str("**Mitigations:**\n");
1270                for m in mitigations {
1271                    md.push_str(&format!("- {} ({:?})\n", m.description, m.status));
1272                }
1273                md.push('\n');
1274            }
1275        }
1276
1277        // Compliance section
1278        if let Some(compliance) = &asset.compliance {
1279            md.push_str("## Compliance\n\n");
1280            if let Some(frameworks) = &compliance.frameworks
1281                && !frameworks.is_empty()
1282            {
1283                md.push_str("### Frameworks\n\n");
1284                md.push_str("| Name | Category | Status |\n");
1285                md.push_str("|------|----------|--------|\n");
1286                for fw in frameworks {
1287                    let cat = fw.category.as_deref().unwrap_or("-");
1288                    md.push_str(&format!("| {} | {} | {:?} |\n", fw.name, cat, fw.status));
1289                }
1290                md.push('\n');
1291            }
1292            if let Some(controls) = &compliance.controls
1293                && !controls.is_empty()
1294            {
1295                md.push_str("### Controls\n\n");
1296                md.push_str("| ID | Description |\n");
1297                md.push_str("|----|-------------|\n");
1298                for ctrl in controls {
1299                    md.push_str(&format!("| {} | {} |\n", ctrl.id, ctrl.description));
1300                }
1301                md.push('\n');
1302            }
1303        }
1304
1305        // Tags
1306        if !asset.tags.is_empty() {
1307            md.push_str("---\n\n");
1308            let tag_strings: Vec<String> = asset.tags.iter().map(|t| format!("`{}`", t)).collect();
1309            md.push_str(&format!("**Tags:** {}\n\n", tag_strings.join(" ")));
1310        }
1311
1312        // Timestamps
1313        if asset.created_at.is_some() || asset.updated_at.is_some() {
1314            md.push_str("---\n\n");
1315            if let Some(created) = &asset.created_at {
1316                md.push_str(&format!(
1317                    "*Created: {}",
1318                    created.format("%Y-%m-%d %H:%M UTC")
1319                ));
1320                if let Some(updated) = &asset.updated_at {
1321                    md.push_str(&format!(
1322                        " | Last Updated: {}",
1323                        updated.format("%Y-%m-%d %H:%M UTC")
1324                    ));
1325                }
1326                md.push_str("*\n\n");
1327            } else if let Some(updated) = &asset.updated_at {
1328                md.push_str(&format!(
1329                    "*Last Updated: {}*\n\n",
1330                    updated.format("%Y-%m-%d %H:%M UTC")
1331                ));
1332            }
1333        }
1334
1335        md
1336    }
1337
1338    /// Generate PDF from markdown content
1339    fn generate_pdf(
1340        &self,
1341        title: &str,
1342        markdown: &str,
1343        filename: &str,
1344        doc_type: &str,
1345    ) -> Result<PdfExportResult, ExportError> {
1346        let pdf_content = self.create_pdf_document(title, markdown, doc_type)?;
1347        let pdf_base64 =
1348            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &pdf_content);
1349
1350        // Estimate page count based on content length
1351        let chars_per_page = 3000;
1352        let page_count = std::cmp::max(1, (markdown.len() / chars_per_page) as u32 + 1);
1353
1354        Ok(PdfExportResult {
1355            pdf_base64,
1356            filename: filename.to_string(),
1357            page_count,
1358            title: title.to_string(),
1359        })
1360    }
1361
1362    /// Create a PDF document with proper GFM rendering and multi-page support
1363    fn create_pdf_document(
1364        &self,
1365        title: &str,
1366        markdown: &str,
1367        doc_type: &str,
1368    ) -> Result<Vec<u8>, ExportError> {
1369        let (width, height) = self.branding.page_size.dimensions_mm();
1370        let width_pt = width * 2.83465;
1371        let height_pt = height * 2.83465;
1372
1373        // Generate all page content streams
1374        let page_streams =
1375            self.render_markdown_to_pdf_pages(title, markdown, width_pt, height_pt, doc_type);
1376        let page_count = page_streams.len();
1377
1378        let mut pdf = Vec::new();
1379
1380        // PDF Header
1381        pdf.extend_from_slice(b"%PDF-1.4\n");
1382        pdf.extend_from_slice(b"%\xE2\xE3\xCF\xD3\n");
1383
1384        let mut xref_positions: Vec<usize> = Vec::new();
1385
1386        // Object 1: Catalog
1387        xref_positions.push(pdf.len());
1388        pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
1389
1390        // Object 2: Pages - will be written later after we know all page refs
1391        let pages_obj_position = xref_positions.len();
1392        xref_positions.push(0); // Placeholder, will update
1393
1394        // For each page, we need: Page object + Content stream object
1395        // Object numbering: 3,4 for page 1; 5,6 for page 2; etc.
1396        // Then fonts start after all pages
1397        let mut page_obj_ids: Vec<usize> = Vec::new();
1398        let font_obj_start = 3 + (page_count * 2); // First font object ID
1399
1400        for (page_idx, content_stream) in page_streams.iter().enumerate() {
1401            let page_obj_id = 3 + (page_idx * 2);
1402            let content_obj_id = page_obj_id + 1;
1403            page_obj_ids.push(page_obj_id);
1404
1405            // Page object
1406            xref_positions.push(pdf.len());
1407            let page_obj = format!(
1408                "{} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {:.2} {:.2}] /Contents {} 0 R /Resources << /Font << /F1 {} 0 R /F2 {} 0 R >> >> >>\nendobj\n",
1409                page_obj_id,
1410                width_pt,
1411                height_pt,
1412                content_obj_id,
1413                font_obj_start,
1414                font_obj_start + 1
1415            );
1416            pdf.extend_from_slice(page_obj.as_bytes());
1417
1418            // Content stream object
1419            xref_positions.push(pdf.len());
1420            let content_obj = format!(
1421                "{} 0 obj\n<< /Length {} >>\nstream\n{}\nendstream\nendobj\n",
1422                content_obj_id,
1423                content_stream.len(),
1424                content_stream
1425            );
1426            pdf.extend_from_slice(content_obj.as_bytes());
1427        }
1428
1429        // Now write the Pages object with correct kids list
1430        let pages_position = pdf.len();
1431        let kids_list: Vec<String> = page_obj_ids
1432            .iter()
1433            .map(|id| format!("{} 0 R", id))
1434            .collect();
1435        let pages_obj = format!(
1436            "2 0 obj\n<< /Type /Pages /Kids [{}] /Count {} >>\nendobj\n",
1437            kids_list.join(" "),
1438            page_count
1439        );
1440        pdf.extend_from_slice(pages_obj.as_bytes());
1441        xref_positions[pages_obj_position] = pages_position;
1442
1443        // Font objects
1444        xref_positions.push(pdf.len());
1445        let font1_obj = format!(
1446            "{} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>\nendobj\n",
1447            font_obj_start
1448        );
1449        pdf.extend_from_slice(font1_obj.as_bytes());
1450
1451        xref_positions.push(pdf.len());
1452        let font2_obj = format!(
1453            "{} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>\nendobj\n",
1454            font_obj_start + 1
1455        );
1456        pdf.extend_from_slice(font2_obj.as_bytes());
1457
1458        // Info dictionary
1459        let info_obj_id = font_obj_start + 2;
1460        xref_positions.push(pdf.len());
1461        let timestamp = if self.branding.show_timestamp {
1462            Utc::now().format("D:%Y%m%d%H%M%S").to_string()
1463        } else {
1464            String::new()
1465        };
1466
1467        let escaped_title = self.escape_pdf_string(title);
1468        let producer = "Open Data Modelling SDK";
1469        let company = self
1470            .branding
1471            .company_name
1472            .as_deref()
1473            .unwrap_or("opendatamodelling.com");
1474
1475        let info_obj = format!(
1476            "{} 0 obj\n<< /Title ({}) /Producer ({}) /Creator ({}) /CreationDate ({}) >>\nendobj\n",
1477            info_obj_id, escaped_title, producer, company, timestamp
1478        );
1479        pdf.extend_from_slice(info_obj.as_bytes());
1480
1481        // Cross-reference table
1482        let xref_start = pdf.len();
1483        pdf.extend_from_slice(b"xref\n");
1484        pdf.extend_from_slice(format!("0 {}\n", xref_positions.len() + 1).as_bytes());
1485        pdf.extend_from_slice(b"0000000000 65535 f \n");
1486        for pos in &xref_positions {
1487            pdf.extend_from_slice(format!("{:010} 00000 n \n", pos).as_bytes());
1488        }
1489
1490        // Trailer
1491        pdf.extend_from_slice(b"trailer\n");
1492        pdf.extend_from_slice(
1493            format!(
1494                "<< /Size {} /Root 1 0 R /Info {} 0 R >>\n",
1495                xref_positions.len() + 1,
1496                info_obj_id
1497            )
1498            .as_bytes(),
1499        );
1500        pdf.extend_from_slice(b"startxref\n");
1501        pdf.extend_from_slice(format!("{}\n", xref_start).as_bytes());
1502        pdf.extend_from_slice(b"%%EOF\n");
1503
1504        Ok(pdf)
1505    }
1506
1507    /// Render markdown to PDF content streams with proper formatting (multi-page)
1508    fn render_markdown_to_pdf_pages(
1509        &self,
1510        title: &str,
1511        markdown: &str,
1512        width: f64,
1513        height: f64,
1514        doc_type: &str,
1515    ) -> Vec<String> {
1516        let mut pages: Vec<String> = Vec::new();
1517        let mut stream = String::new();
1518        let margin = 50.0;
1519        let footer_height = 40.0; // Reserve space for footer
1520        let header_height = 100.0; // Reserve space for header/logo/title/doc type
1521        let body_font_size = self.branding.font_size as f64;
1522        let line_height = body_font_size * 1.4;
1523        let max_width = width - (2.0 * margin);
1524        let mut page_num = 1;
1525
1526        // === HEADER SECTION WITH LOGO ===
1527
1528        // Draw the logo circle with gradient-like effect (blue circle)
1529        let logo_cx = margin + 15.0;
1530        let logo_cy = height - margin - 10.0;
1531        let logo_r = 12.0;
1532
1533        // Draw filled blue circle
1534        stream.push_str("q\n");
1535        stream.push_str("0 0.4 0.8 rg\n"); // RGB for #0066CC
1536        stream.push_str(&format!("{:.2} {:.2} m\n", logo_cx + logo_r, logo_cy));
1537        // Approximate circle with bezier curves
1538        let k = 0.5523; // bezier constant for circle
1539        stream.push_str(&format!(
1540            "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n",
1541            logo_cx + logo_r,
1542            logo_cy + logo_r * k,
1543            logo_cx + logo_r * k,
1544            logo_cy + logo_r,
1545            logo_cx,
1546            logo_cy + logo_r
1547        ));
1548        stream.push_str(&format!(
1549            "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n",
1550            logo_cx - logo_r * k,
1551            logo_cy + logo_r,
1552            logo_cx - logo_r,
1553            logo_cy + logo_r * k,
1554            logo_cx - logo_r,
1555            logo_cy
1556        ));
1557        stream.push_str(&format!(
1558            "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n",
1559            logo_cx - logo_r,
1560            logo_cy - logo_r * k,
1561            logo_cx - logo_r * k,
1562            logo_cy - logo_r,
1563            logo_cx,
1564            logo_cy - logo_r
1565        ));
1566        stream.push_str(&format!(
1567            "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n",
1568            logo_cx + logo_r * k,
1569            logo_cy - logo_r,
1570            logo_cx + logo_r,
1571            logo_cy - logo_r * k,
1572            logo_cx + logo_r,
1573            logo_cy
1574        ));
1575        stream.push_str("f\n"); // Fill the circle
1576        stream.push_str("Q\n");
1577
1578        // Draw white cross inside the circle
1579        stream.push_str("q\n");
1580        stream.push_str("1 1 1 RG\n"); // White stroke
1581        stream.push_str("2 w\n"); // Line width
1582        stream.push_str("1 J\n"); // Round line cap
1583        // Vertical line
1584        stream.push_str(&format!(
1585            "{:.2} {:.2} m\n{:.2} {:.2} l\nS\n",
1586            logo_cx,
1587            logo_cy - logo_r * 0.6,
1588            logo_cx,
1589            logo_cy + logo_r * 0.6
1590        ));
1591        // Horizontal line
1592        stream.push_str(&format!(
1593            "{:.2} {:.2} m\n{:.2} {:.2} l\nS\n",
1594            logo_cx - logo_r * 0.6,
1595            logo_cy,
1596            logo_cx + logo_r * 0.6,
1597            logo_cy
1598        ));
1599        stream.push_str("Q\n");
1600
1601        // Render "Open Data Modelling" text next to logo
1602        stream.push_str("BT\n");
1603        let logo_text_x = margin + 35.0;
1604        let logo_text_y = height - margin - 5.0;
1605        stream.push_str("/F2 11 Tf\n"); // Bold font
1606        stream.push_str(&format!("{:.2} {:.2} Td\n", logo_text_x, logo_text_y));
1607        stream.push_str("(Open Data) Tj\n");
1608        stream.push_str(&format!("0 {:.2} Td\n", -12.0));
1609        stream.push_str("(Modelling) Tj\n");
1610        stream.push_str("ET\n");
1611
1612        // Draw header line below logo
1613        let header_line_y = height - margin - 30.0;
1614        stream.push_str(&format!(
1615            "q\n0.7 G\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
1616            margin,
1617            header_line_y,
1618            width - margin,
1619            header_line_y
1620        ));
1621
1622        // === DOCUMENT TYPE TITLE (e.g., "DECISION RECORD" or "KNOWLEDGE BASE") ===
1623        stream.push_str("BT\n");
1624        let doc_type_y = height - margin - 48.0;
1625        stream.push_str("/F2 12 Tf\n"); // Bold font for document type
1626        stream.push_str("0.3 0.3 0.3 rg\n"); // Dark gray color
1627        stream.push_str(&format!("{:.2} {:.2} Td\n", margin, doc_type_y));
1628        stream.push_str(&format!(
1629            "({}) Tj\n",
1630            self.escape_pdf_string(&doc_type.to_uppercase())
1631        ));
1632        stream.push_str("ET\n");
1633
1634        // === DOCUMENT TITLE ===
1635        stream.push_str("BT\n");
1636        stream.push_str("0 0 0 rg\n"); // Black color for title
1637        let title_y = height - margin - 68.0;
1638        stream.push_str("/F2 16 Tf\n"); // Bold, large font for title
1639        stream.push_str(&format!("{:.2} {:.2} Td\n", margin, title_y));
1640        stream.push_str(&format!("({}) Tj\n", self.escape_pdf_string(title)));
1641        stream.push_str("ET\n");
1642
1643        // Note: Footer is rendered by render_page_header_footer closure for all pages
1644        // This ensures consistent footer across all pages including page numbers
1645
1646        // === BODY CONTENT ===
1647        let content_top = height - margin - header_height;
1648        let content_bottom = margin + footer_height;
1649        let mut y_pos = content_top;
1650        let mut in_table = false;
1651        let mut in_code_block = false;
1652
1653        // Helper closure to render footer on each page
1654        let render_page_header_footer =
1655            |stream: &mut String,
1656             page_num: u32,
1657             width: f64,
1658             height: f64,
1659             margin: f64,
1660             footer_height: f64| {
1661                // Draw logo on continuation pages (smaller, simpler)
1662                if page_num > 1 {
1663                    // Small logo indicator
1664                    stream.push_str("BT\n");
1665                    stream.push_str("/F2 9 Tf\n");
1666                    stream.push_str("0.3 0.3 0.3 rg\n");
1667                    stream.push_str(&format!(
1668                        "1 0 0 1 {:.2} {:.2} Tm\n",
1669                        margin,
1670                        height - margin - 10.0
1671                    ));
1672                    stream.push_str("(Open Data Modelling) Tj\n");
1673                    stream.push_str("ET\n");
1674                }
1675
1676                // Footer line
1677                let footer_line_y = margin + footer_height - 10.0;
1678                stream.push_str(&format!(
1679                    "q\n0.3 G\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
1680                    margin,
1681                    footer_line_y,
1682                    width - margin,
1683                    footer_line_y
1684                ));
1685
1686                let footer_y = margin + 15.0;
1687
1688                // Copyright text on the left (use octal \251 for © symbol)
1689                stream.push_str("BT\n");
1690                stream.push_str("/F1 9 Tf\n");
1691                stream.push_str("0 0 0 rg\n");
1692                stream.push_str(&format!("1 0 0 1 {:.2} {:.2} Tm\n", margin, footer_y));
1693                stream.push_str("(\\251 opendatamodelling.com) Tj\n");
1694                stream.push_str("ET\n");
1695
1696                // Page number on the right
1697                stream.push_str("BT\n");
1698                stream.push_str("/F1 9 Tf\n");
1699                stream.push_str("0 0 0 rg\n");
1700                stream.push_str(&format!(
1701                    "1 0 0 1 {:.2} {:.2} Tm\n",
1702                    width - margin - 40.0,
1703                    footer_y
1704                ));
1705                stream.push_str(&format!("(Page {}) Tj\n", page_num));
1706                stream.push_str("ET\n");
1707            };
1708
1709        for line in markdown.lines() {
1710            // Check if we need a new page
1711            if y_pos < content_bottom + line_height {
1712                // Render footer on current page
1713                render_page_header_footer(
1714                    &mut stream,
1715                    page_num,
1716                    width,
1717                    height,
1718                    margin,
1719                    footer_height,
1720                );
1721
1722                // Save current page and start new one
1723                pages.push(stream);
1724                stream = String::new();
1725                page_num += 1;
1726                y_pos = height - margin - 30.0; // Start content higher on continuation pages
1727            }
1728
1729            let trimmed = line.trim();
1730
1731            // Handle code blocks
1732            if trimmed.starts_with("```") {
1733                in_code_block = !in_code_block;
1734                y_pos -= line_height * 0.5;
1735                continue;
1736            }
1737
1738            if in_code_block {
1739                // Draw dark background for code block line
1740                let code_bg_padding = 3.0;
1741                let code_line_height = line_height * 0.9;
1742                stream.push_str("q\n");
1743                stream.push_str("0.15 0.15 0.15 rg\n"); // Dark gray background (#262626)
1744                stream.push_str(&format!(
1745                    "{:.2} {:.2} {:.2} {:.2} re f\n",
1746                    margin + 15.0,
1747                    y_pos - code_bg_padding,
1748                    max_width - 15.0,
1749                    code_line_height + code_bg_padding
1750                ));
1751                stream.push_str("Q\n");
1752
1753                // Render code with light text using absolute positioning
1754                stream.push_str("BT\n");
1755                let code_font_size = body_font_size - 1.0;
1756                stream.push_str(&format!("/F1 {:.1} Tf\n", code_font_size));
1757                stream.push_str("0.9 0.9 0.9 rg\n"); // Light gray text for code
1758                stream.push_str(&format!("1 0 0 1 {:.2} {:.2} Tm\n", margin + 20.0, y_pos));
1759                stream.push_str(&format!("({}) Tj\n", self.escape_pdf_string(line)));
1760                stream.push_str("ET\n");
1761                y_pos -= code_line_height;
1762                continue;
1763            }
1764
1765            // Skip image references and markdown footer (already rendered)
1766            if trimmed.starts_with("![") {
1767                continue;
1768            }
1769
1770            // Skip the copyright line in content (already in footer)
1771            if trimmed.starts_with("©") || trimmed == DEFAULT_COPYRIGHT {
1772                continue;
1773            }
1774
1775            // Handle horizontal rules
1776            if trimmed == "---" || trimmed == "***" || trimmed == "___" {
1777                y_pos -= line_height * 0.3;
1778                // Draw a line
1779                stream.push_str(&format!(
1780                    "q\n0.7 G\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
1781                    margin,
1782                    y_pos,
1783                    width - margin,
1784                    y_pos
1785                ));
1786                y_pos -= line_height * 0.5;
1787                continue;
1788            }
1789
1790            // Handle table rows
1791            if trimmed.starts_with("|") && trimmed.ends_with("|") {
1792                // Skip separator rows
1793                if trimmed.contains("---") {
1794                    in_table = true;
1795                    continue;
1796                }
1797
1798                let cells: Vec<&str> = trimmed
1799                    .trim_matches('|')
1800                    .split('|')
1801                    .map(|s| s.trim())
1802                    .collect();
1803
1804                let cell_width = max_width / cells.len() as f64;
1805
1806                // Calculate max chars per cell based on cell width and font size
1807                let font_size = if in_table {
1808                    body_font_size - 1.0
1809                } else {
1810                    body_font_size
1811                };
1812                // Approximate character width for Helvetica
1813                // Using a conservative factor to ensure text fits
1814                let char_width_factor = 0.45;
1815                let max_chars_per_line =
1816                    ((cell_width - 10.0) / (font_size * char_width_factor)) as usize;
1817                let max_chars_per_line = max_chars_per_line.max(10); // Minimum 10 chars per line
1818
1819                // Word-wrap each cell and find maximum number of lines needed
1820                let mut wrapped_cells: Vec<(Vec<String>, bool)> = Vec::new();
1821                let mut max_lines = 1usize;
1822
1823                for cell in &cells {
1824                    // Check if cell content is bold
1825                    let (text, is_bold) = if cell.starts_with("**") && cell.ends_with("**") {
1826                        (cell.trim_matches('*'), true)
1827                    } else {
1828                        (*cell, false)
1829                    };
1830
1831                    // Word wrap the text
1832                    let lines = self.word_wrap(text, max_chars_per_line);
1833                    max_lines = max_lines.max(lines.len());
1834                    wrapped_cells.push((lines, is_bold));
1835                }
1836
1837                // Check if we have enough space for this row
1838                let row_height = line_height * max_lines as f64;
1839                if y_pos - row_height < content_bottom {
1840                    // Need a new page
1841                    render_page_header_footer(
1842                        &mut stream,
1843                        page_num,
1844                        width,
1845                        height,
1846                        margin,
1847                        footer_height,
1848                    );
1849                    pages.push(stream);
1850                    stream = String::new();
1851                    page_num += 1;
1852                    y_pos = height - margin - 30.0;
1853                }
1854
1855                // Render each line of each cell
1856                for line_idx in 0..max_lines {
1857                    let mut x_pos = margin;
1858                    let line_y = y_pos - (line_idx as f64 * line_height);
1859
1860                    for (lines, is_bold) in &wrapped_cells {
1861                        let font = if *is_bold || !in_table { "/F2" } else { "/F1" };
1862                        let text = lines.get(line_idx).map(|s| s.as_str()).unwrap_or("");
1863
1864                        if !text.is_empty() {
1865                            stream.push_str("BT\n");
1866                            stream.push_str(&format!("{} {:.1} Tf\n", font, font_size));
1867                            stream.push_str("0 0 0 rg\n");
1868                            stream.push_str(&format!("1 0 0 1 {:.2} {:.2} Tm\n", x_pos, line_y));
1869                            stream.push_str(&format!("({}) Tj\n", self.escape_pdf_string(text)));
1870                            stream.push_str("ET\n");
1871                        }
1872                        x_pos += cell_width;
1873                    }
1874                }
1875
1876                y_pos -= row_height + (line_height * 0.2); // Add small padding between rows
1877                in_table = true;
1878                continue;
1879            } else if in_table && !trimmed.is_empty() {
1880                in_table = false;
1881                y_pos -= line_height * 0.3;
1882            }
1883
1884            // Handle headings - Skip H1 since we render the title in the header
1885            if trimmed.starts_with("# ") && !trimmed.starts_with("## ") {
1886                // Skip the main H1 title as it's already in the header
1887                continue;
1888            }
1889
1890            if trimmed.starts_with("## ") {
1891                let text = trimmed.trim_start_matches("## ");
1892                let h2_size = body_font_size + 3.0;
1893
1894                // Check if we have enough space for heading + at least 4 lines of content
1895                // If not, start a new page to keep section together
1896                let min_section_space = line_height * 5.0;
1897                if y_pos - min_section_space < content_bottom {
1898                    render_page_header_footer(
1899                        &mut stream,
1900                        page_num,
1901                        width,
1902                        height,
1903                        margin,
1904                        footer_height,
1905                    );
1906                    pages.push(stream);
1907                    stream = String::new();
1908                    page_num += 1;
1909                    y_pos = height - margin - 30.0;
1910                }
1911
1912                y_pos -= line_height * 0.3;
1913                stream.push_str("BT\n");
1914                stream.push_str(&format!("/F2 {:.1} Tf\n", h2_size));
1915                stream.push_str("0 0 0 rg\n");
1916                stream.push_str(&format!("1 0 0 1 {:.2} {:.2} Tm\n", margin, y_pos));
1917                stream.push_str(&format!("({}) Tj\n", self.escape_pdf_string(text)));
1918                stream.push_str("ET\n");
1919                y_pos -= line_height * 1.2;
1920                continue;
1921            }
1922
1923            if trimmed.starts_with("### ") {
1924                let text = trimmed.trim_start_matches("### ");
1925
1926                // Check if we have enough space for subheading + at least 3 lines of content
1927                let min_subsection_space = line_height * 4.0;
1928                if y_pos - min_subsection_space < content_bottom {
1929                    render_page_header_footer(
1930                        &mut stream,
1931                        page_num,
1932                        width,
1933                        height,
1934                        margin,
1935                        footer_height,
1936                    );
1937                    pages.push(stream);
1938                    stream = String::new();
1939                    page_num += 1;
1940                    y_pos = height - margin - 30.0;
1941                }
1942                let h3_size = body_font_size + 1.0;
1943                stream.push_str("BT\n");
1944                stream.push_str(&format!("/F2 {:.1} Tf\n", h3_size));
1945                stream.push_str("0 0 0 rg\n");
1946                stream.push_str(&format!("1 0 0 1 {:.2} {:.2} Tm\n", margin, y_pos));
1947                stream.push_str(&format!("({}) Tj\n", self.escape_pdf_string(text)));
1948                stream.push_str("ET\n");
1949                y_pos -= line_height * 1.1;
1950                continue;
1951            }
1952
1953            // Handle list items
1954            if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
1955                let text = trimmed[2..].to_string();
1956                stream.push_str("BT\n");
1957                stream.push_str(&format!("/F1 {:.1} Tf\n", body_font_size));
1958                stream.push_str("0 0 0 rg\n");
1959                stream.push_str(&format!("1 0 0 1 {:.2} {:.2} Tm\n", margin + 10.0, y_pos));
1960                stream.push_str(&format!(
1961                    "(\\267 {}) Tj\n",
1962                    self.escape_pdf_string(&self.strip_markdown_formatting(&text))
1963                ));
1964                stream.push_str("ET\n");
1965                y_pos -= line_height;
1966                continue;
1967            }
1968
1969            // Handle numbered list items
1970            if let Some(rest) = self.parse_numbered_list(trimmed) {
1971                stream.push_str("BT\n");
1972                stream.push_str(&format!("/F1 {:.1} Tf\n", body_font_size));
1973                stream.push_str("0 0 0 rg\n");
1974                stream.push_str(&format!("1 0 0 1 {:.2} {:.2} Tm\n", margin + 10.0, y_pos));
1975                stream.push_str(&format!(
1976                    "({}) Tj\n",
1977                    self.escape_pdf_string(&self.strip_markdown_formatting(rest))
1978                ));
1979                stream.push_str("ET\n");
1980                y_pos -= line_height;
1981                continue;
1982            }
1983
1984            // Handle empty lines
1985            if trimmed.is_empty() {
1986                y_pos -= line_height * 0.5;
1987                continue;
1988            }
1989
1990            // Handle italic text (*text*)
1991            let display_text = self.strip_markdown_formatting(trimmed);
1992
1993            // Check if this is bold text
1994            let (text, font) = if trimmed.starts_with("**") && trimmed.ends_with("**") {
1995                (display_text.as_str(), "/F2")
1996            } else if trimmed.starts_with("*")
1997                && trimmed.ends_with("*")
1998                && !trimmed.starts_with("**")
1999            {
2000                // Italic - we don't have an italic font, so just use regular
2001                (display_text.as_str(), "/F1")
2002            } else {
2003                (display_text.as_str(), "/F1")
2004            };
2005
2006            // Word wrap regular text
2007            let wrapped_lines = self.word_wrap(text, (max_width / (body_font_size * 0.5)) as usize);
2008            for wrapped_line in wrapped_lines {
2009                // Check for page break
2010                if y_pos < content_bottom + line_height {
2011                    render_page_header_footer(
2012                        &mut stream,
2013                        page_num,
2014                        width,
2015                        height,
2016                        margin,
2017                        footer_height,
2018                    );
2019                    pages.push(stream);
2020                    stream = String::new();
2021                    page_num += 1;
2022                    y_pos = height - margin - 30.0;
2023                }
2024                stream.push_str("BT\n");
2025                stream.push_str(&format!("{} {:.1} Tf\n", font, body_font_size));
2026                stream.push_str("0 0 0 rg\n");
2027                stream.push_str(&format!("1 0 0 1 {:.2} {:.2} Tm\n", margin, y_pos));
2028                stream.push_str(&format!("({}) Tj\n", self.escape_pdf_string(&wrapped_line)));
2029                stream.push_str("ET\n");
2030                y_pos -= line_height;
2031            }
2032        }
2033
2034        // Render footer on last page and add it to pages
2035        render_page_header_footer(&mut stream, page_num, width, height, margin, footer_height);
2036        pages.push(stream);
2037
2038        pages
2039    }
2040
2041    /// Strip markdown formatting from text for display
2042    fn strip_markdown_formatting(&self, text: &str) -> String {
2043        let mut result = text.to_string();
2044
2045        // Remove bold markers
2046        while result.contains("**") {
2047            result = result.replacen("**", "", 2);
2048        }
2049
2050        // Remove italic markers (single asterisk)
2051        // Be careful not to remove list markers
2052        let chars: Vec<char> = result.chars().collect();
2053        let mut cleaned = String::new();
2054        let mut i = 0;
2055        while i < chars.len() {
2056            if chars[i] == '*' && i + 1 < chars.len() && chars[i + 1] != '*' && chars[i + 1] != ' '
2057            {
2058                // This might be italic start
2059                if result[i + 1..].contains('*') {
2060                    // Skip the asterisk
2061                    i += 1;
2062                    continue;
2063                }
2064            }
2065            cleaned.push(chars[i]);
2066            i += 1;
2067        }
2068
2069        // Remove backticks (inline code)
2070        result = cleaned.replace('`', "");
2071
2072        // Remove link formatting [text](url) -> text
2073        while let Some(start) = result.find('[') {
2074            if let Some(mid) = result[start..].find("](")
2075                && let Some(end) = result[start + mid..].find(')')
2076            {
2077                let link_text = &result[start + 1..start + mid];
2078                let before = &result[..start];
2079                let after = &result[start + mid + end + 1..];
2080                result = format!("{}{}{}", before, link_text, after);
2081                continue;
2082            }
2083            break;
2084        }
2085
2086        result
2087    }
2088
2089    /// Parse numbered list item, returns the text after the number
2090    fn parse_numbered_list<'a>(&self, text: &'a str) -> Option<&'a str> {
2091        let bytes = text.as_bytes();
2092        let mut i = 0;
2093
2094        // Skip digits
2095        while i < bytes.len() && bytes[i].is_ascii_digit() {
2096            i += 1;
2097        }
2098
2099        // Check for period and space
2100        if i > 0 && i < bytes.len() - 1 && bytes[i] == b'.' && bytes[i + 1] == b' ' {
2101            return Some(&text[i + 2..]);
2102        }
2103
2104        None
2105    }
2106
2107    /// Escape special characters for PDF strings
2108    fn escape_pdf_string(&self, s: &str) -> String {
2109        let mut result = String::new();
2110        for c in s.chars() {
2111            match c {
2112                '\\' => result.push_str("\\\\"),
2113                '(' => result.push_str("\\("),
2114                ')' => result.push_str("\\)"),
2115                '\n' => result.push_str("\\n"),
2116                '\r' => result.push_str("\\r"),
2117                '\t' => result.push_str("\\t"),
2118                // Handle special characters by using octal encoding
2119                '©' => result.push_str("\\251"), // Copyright symbol in WinAnsiEncoding
2120                '®' => result.push_str("\\256"), // Registered trademark
2121                '™' => result.push_str("\\231"), // Trademark
2122                '•' => result.push_str("\\267"), // Bullet
2123                '–' => result.push_str("\\226"), // En dash
2124                '—' => result.push_str("\\227"), // Em dash
2125                _ if c.is_ascii() => result.push(c),
2126                // For non-ASCII characters, try to use closest ASCII equivalent
2127                _ => result.push('?'),
2128            }
2129        }
2130        result
2131    }
2132
2133    /// Word wrap text to fit within max characters per line
2134    fn word_wrap(&self, text: &str, max_chars: usize) -> Vec<String> {
2135        let mut lines = Vec::new();
2136        let mut current_line = String::new();
2137
2138        for word in text.split_whitespace() {
2139            if current_line.is_empty() {
2140                current_line = word.to_string();
2141            } else if current_line.len() + 1 + word.len() <= max_chars {
2142                current_line.push(' ');
2143                current_line.push_str(word);
2144            } else {
2145                lines.push(current_line);
2146                current_line = word.to_string();
2147            }
2148        }
2149
2150        if !current_line.is_empty() {
2151            lines.push(current_line);
2152        }
2153
2154        if lines.is_empty() {
2155            lines.push(String::new());
2156        }
2157
2158        lines
2159    }
2160}
2161
2162#[cfg(test)]
2163mod tests {
2164    use super::*;
2165    use crate::models::decision::Decision;
2166    use crate::models::knowledge::KnowledgeArticle;
2167
2168    #[test]
2169    fn test_branding_config_default() {
2170        let config = BrandingConfig::default();
2171        assert_eq!(config.brand_color, "#0066CC");
2172        assert!(config.show_page_numbers);
2173        assert!(config.show_timestamp);
2174        assert_eq!(config.font_size, 11);
2175        assert_eq!(config.page_size, PageSize::A4);
2176        assert_eq!(config.logo_url, Some(DEFAULT_LOGO_URL.to_string()));
2177        assert_eq!(config.footer, Some(DEFAULT_COPYRIGHT.to_string()));
2178    }
2179
2180    #[test]
2181    fn test_page_size_dimensions() {
2182        let a4 = PageSize::A4;
2183        let (w, h) = a4.dimensions_mm();
2184        assert_eq!(w, 210.0);
2185        assert_eq!(h, 297.0);
2186
2187        let letter = PageSize::Letter;
2188        let (w, h) = letter.dimensions_mm();
2189        assert!((w - 215.9).abs() < 0.1);
2190        assert!((h - 279.4).abs() < 0.1);
2191    }
2192
2193    #[test]
2194    fn test_pdf_exporter_with_branding() {
2195        let branding = BrandingConfig {
2196            header: Some("Company Header".to_string()),
2197            footer: Some("Confidential".to_string()),
2198            company_name: Some("Test Corp".to_string()),
2199            brand_color: "#FF0000".to_string(),
2200            ..Default::default()
2201        };
2202
2203        let exporter = PdfExporter::with_branding(branding.clone());
2204        assert_eq!(
2205            exporter.branding().header,
2206            Some("Company Header".to_string())
2207        );
2208        assert_eq!(exporter.branding().brand_color, "#FF0000");
2209    }
2210
2211    #[test]
2212    fn test_export_decision_to_pdf() {
2213        let decision = Decision::new(
2214            1,
2215            "Use Rust for SDK",
2216            "We need to choose a language for the SDK implementation.",
2217            "Use Rust for type safety and performance.",
2218        );
2219
2220        let exporter = PdfExporter::new();
2221        let result = exporter.export_decision(&decision);
2222        assert!(result.is_ok());
2223
2224        let pdf_result = result.unwrap();
2225        assert!(!pdf_result.pdf_base64.is_empty());
2226        assert!(pdf_result.filename.ends_with(".pdf"));
2227        assert!(pdf_result.page_count >= 1);
2228        assert!(pdf_result.title.contains("ADR-"));
2229    }
2230
2231    #[test]
2232    fn test_export_knowledge_to_pdf() {
2233        let article = KnowledgeArticle::new(
2234            1,
2235            "Getting Started Guide",
2236            "A guide to getting started with the SDK.",
2237            "This guide covers the basics...",
2238            "author@example.com",
2239        );
2240
2241        let exporter = PdfExporter::new();
2242        let result = exporter.export_knowledge(&article);
2243        assert!(result.is_ok());
2244
2245        let pdf_result = result.unwrap();
2246        assert!(!pdf_result.pdf_base64.is_empty());
2247        assert!(pdf_result.filename.ends_with(".pdf"));
2248        assert!(pdf_result.title.contains("KB-"));
2249    }
2250
2251    #[test]
2252    fn test_export_table_to_pdf() {
2253        use crate::models::{Column, Table};
2254
2255        let mut table = Table::new(
2256            "users".to_string(),
2257            vec![
2258                Column::new("id".to_string(), "BIGINT".to_string()),
2259                Column::new("name".to_string(), "VARCHAR(255)".to_string()),
2260                Column::new("email".to_string(), "VARCHAR(255)".to_string()),
2261            ],
2262        );
2263        table.schema_name = Some("public".to_string());
2264        table.owner = Some("Data Engineering".to_string());
2265        table.notes = Some("Core user table for the application".to_string());
2266
2267        let exporter = PdfExporter::new();
2268        let result = exporter.export_table(&table);
2269        assert!(result.is_ok());
2270
2271        let pdf_result = result.unwrap();
2272        assert!(!pdf_result.pdf_base64.is_empty());
2273        assert!(pdf_result.filename.ends_with(".pdf"));
2274        assert_eq!(pdf_result.title, "users");
2275    }
2276
2277    #[test]
2278    fn test_export_data_product_to_pdf() {
2279        use crate::models::odps::{ODPSDataProduct, ODPSDescription, ODPSOutputPort, ODPSStatus};
2280
2281        let product = ODPSDataProduct {
2282            api_version: "v1.0.0".to_string(),
2283            kind: "DataProduct".to_string(),
2284            id: "dp-customer-360".to_string(),
2285            name: Some("Customer 360".to_string()),
2286            version: Some("1.0.0".to_string()),
2287            status: ODPSStatus::Active,
2288            domain: Some("Customer".to_string()),
2289            tenant: None,
2290            authoritative_definitions: None,
2291            description: Some(ODPSDescription {
2292                purpose: Some("Unified customer view across all touchpoints".to_string()),
2293                limitations: Some("Does not include real-time data".to_string()),
2294                usage: Some("Use for analytics and reporting".to_string()),
2295                authoritative_definitions: None,
2296                custom_properties: None,
2297            }),
2298            custom_properties: None,
2299            tags: vec![],
2300            input_ports: None,
2301            output_ports: Some(vec![ODPSOutputPort {
2302                name: "customer-data".to_string(),
2303                version: "1.0.0".to_string(),
2304                description: Some("Customer master data".to_string()),
2305                r#type: Some("table".to_string()),
2306                contract_id: Some("contract-123".to_string()),
2307                sbom: None,
2308                input_contracts: None,
2309                tags: vec![],
2310                custom_properties: None,
2311                authoritative_definitions: None,
2312            }]),
2313            management_ports: None,
2314            support: None,
2315            team: None,
2316            product_created_ts: None,
2317            created_at: None,
2318            updated_at: None,
2319        };
2320
2321        let exporter = PdfExporter::new();
2322        let result = exporter.export_data_product(&product);
2323        assert!(result.is_ok());
2324
2325        let pdf_result = result.unwrap();
2326        assert!(!pdf_result.pdf_base64.is_empty());
2327        assert!(pdf_result.filename.ends_with(".pdf"));
2328        assert_eq!(pdf_result.title, "Customer 360");
2329    }
2330
2331    #[test]
2332    fn test_export_cads_asset_to_pdf() {
2333        use crate::models::cads::{
2334            CADSAsset, CADSDescription, CADSKind, CADSStatus, CADSTeamMember,
2335        };
2336
2337        let asset = CADSAsset {
2338            api_version: "v1.0".to_string(),
2339            kind: CADSKind::AIModel,
2340            id: "model-sentiment-v1".to_string(),
2341            name: "Sentiment Analysis Model".to_string(),
2342            version: "1.0.0".to_string(),
2343            status: CADSStatus::Production,
2344            domain: Some("NLP".to_string()),
2345            tags: vec![],
2346            description: Some(CADSDescription {
2347                purpose: Some("Analyze sentiment in customer feedback".to_string()),
2348                usage: Some("Call the /predict endpoint with text input".to_string()),
2349                limitations: Some("English language only".to_string()),
2350                external_links: None,
2351            }),
2352            runtime: None,
2353            sla: None,
2354            pricing: None,
2355            team: Some(vec![CADSTeamMember {
2356                role: "Owner".to_string(),
2357                name: "ML Team".to_string(),
2358                contact: Some("ml-team@example.com".to_string()),
2359            }]),
2360            risk: None,
2361            compliance: None,
2362            validation_profiles: None,
2363            bpmn_models: None,
2364            dmn_models: None,
2365            openapi_specs: None,
2366            custom_properties: None,
2367            created_at: None,
2368            updated_at: None,
2369        };
2370
2371        let exporter = PdfExporter::new();
2372        let result = exporter.export_cads_asset(&asset);
2373        assert!(result.is_ok());
2374
2375        let pdf_result = result.unwrap();
2376        assert!(!pdf_result.pdf_base64.is_empty());
2377        assert!(pdf_result.filename.ends_with(".pdf"));
2378        assert_eq!(pdf_result.title, "Sentiment Analysis Model");
2379    }
2380
2381    #[test]
2382    fn test_export_markdown_to_pdf() {
2383        let exporter = PdfExporter::new();
2384        let result = exporter.export_markdown(
2385            "Test Document",
2386            "# Test\n\nThis is a test document.\n\n## Section\n\n- Item 1\n- Item 2",
2387            "test.pdf",
2388        );
2389        assert!(result.is_ok());
2390
2391        let pdf_result = result.unwrap();
2392        assert!(!pdf_result.pdf_base64.is_empty());
2393        assert_eq!(pdf_result.filename, "test.pdf");
2394    }
2395
2396    #[test]
2397    fn test_escape_pdf_string() {
2398        let exporter = PdfExporter::new();
2399        assert_eq!(exporter.escape_pdf_string("Hello"), "Hello");
2400        assert_eq!(exporter.escape_pdf_string("(test)"), "\\(test\\)");
2401        assert_eq!(exporter.escape_pdf_string("back\\slash"), "back\\\\slash");
2402    }
2403
2404    #[test]
2405    fn test_word_wrap() {
2406        let exporter = PdfExporter::new();
2407
2408        let wrapped = exporter.word_wrap("Hello world this is a test", 10);
2409        assert!(wrapped.len() > 1);
2410
2411        let wrapped = exporter.word_wrap("Short", 100);
2412        assert_eq!(wrapped.len(), 1);
2413        assert_eq!(wrapped[0], "Short");
2414    }
2415
2416    #[test]
2417    fn test_pdf_result_serialization() {
2418        let result = PdfExportResult {
2419            pdf_base64: "dGVzdA==".to_string(),
2420            filename: "test.pdf".to_string(),
2421            page_count: 1,
2422            title: "Test".to_string(),
2423        };
2424
2425        let json = serde_json::to_string(&result).unwrap();
2426        assert!(json.contains("pdf_base64"));
2427        assert!(json.contains("filename"));
2428    }
2429
2430    #[test]
2431    fn test_strip_markdown_formatting() {
2432        let exporter = PdfExporter::new();
2433        assert_eq!(exporter.strip_markdown_formatting("**bold**"), "bold");
2434        assert_eq!(exporter.strip_markdown_formatting("`code`"), "code");
2435        assert_eq!(
2436            exporter.strip_markdown_formatting("[link](http://example.com)"),
2437            "link"
2438        );
2439    }
2440
2441    /// Generate sample PDFs for visual inspection (writes to /tmp)
2442    /// Run with: cargo test generate_sample_pdfs_for_inspection -- --ignored --nocapture
2443    #[test]
2444    #[ignore]
2445    fn generate_sample_pdfs_for_inspection() {
2446        use crate::models::decision::DecisionOption;
2447        use base64::Engine;
2448
2449        // Create a Decision with rich content including options with pros/cons
2450        let mut decision = Decision::new(
2451            2501100001,
2452            "Use Rust for SDK Implementation",
2453            "We need to choose a programming language for the SDK implementation.\n\nKey requirements:\n- Type safety\n- Performance\n- Cross-platform compilation\n- WASM support\n\nThe decision will impact the entire development team and future maintenance of the codebase. We need to carefully consider all options before making a final choice.",
2454            "We will use Rust as the primary programming language.\n\nRust provides:\n1. Strong type safety through its ownership system\n2. Excellent performance comparable to C/C++\n3. Cross-platform compilation via LLVM\n4. First-class WASM support\n\nThis decision was made after careful evaluation of all alternatives and considering the long-term maintainability of the project.",
2455        );
2456
2457        // Add options with pros and cons
2458        decision.options = vec![
2459            DecisionOption::with_details(
2460                "Rust",
2461                "A systems programming language focused on safety and performance.",
2462                vec![
2463                    "Memory safety without garbage collection".to_string(),
2464                    "Excellent performance".to_string(),
2465                    "Strong type system".to_string(),
2466                    "First-class WASM support".to_string(),
2467                    "Growing ecosystem".to_string(),
2468                ],
2469                vec![
2470                    "Steeper learning curve".to_string(),
2471                    "Longer compilation times".to_string(),
2472                    "Smaller talent pool".to_string(),
2473                ],
2474                true, // selected
2475            ),
2476            DecisionOption::with_details(
2477                "TypeScript",
2478                "A typed superset of JavaScript.",
2479                vec![
2480                    "Large developer community".to_string(),
2481                    "Easy to learn".to_string(),
2482                    "Good tooling".to_string(),
2483                ],
2484                vec![
2485                    "Runtime type checking only".to_string(),
2486                    "Performance limitations".to_string(),
2487                    "Node.js dependency".to_string(),
2488                ],
2489                false,
2490            ),
2491            DecisionOption::with_details(
2492                "Go",
2493                "A statically typed language designed at Google.",
2494                vec![
2495                    "Simple syntax".to_string(),
2496                    "Fast compilation".to_string(),
2497                    "Good concurrency support".to_string(),
2498                ],
2499                vec![
2500                    "Limited generics".to_string(),
2501                    "No WASM support".to_string(),
2502                    "Verbose error handling".to_string(),
2503                ],
2504                false,
2505            ),
2506        ];
2507
2508        decision.consequences =
2509            Some("This decision will have significant impact on the project.".to_string());
2510
2511        // Debug: print the generated markdown to see what's being rendered
2512        let exporter = PdfExporter::new();
2513        let md = exporter.decision_to_markdown(&decision);
2514        println!("Generated markdown length: {} chars", md.len());
2515        println!(
2516            "Contains 'Options Considered': {}",
2517            md.contains("Options Considered")
2518        );
2519        println!("Contains 'Pros': {}", md.contains("Pros"));
2520
2521        // Create a Knowledge Article with code blocks
2522        let article = KnowledgeArticle::new(
2523            2501100001,
2524            "Getting Started with the SDK",
2525            "A comprehensive guide to getting started with the Open Data Modelling SDK.",
2526            r#"## Installation
2527
2528Install the SDK using cargo:
2529
2530```bash
2531cargo add data-modelling-sdk
2532```
2533
2534## Basic Usage
2535
2536Here's a simple example:
2537
2538```rust
2539use data_modelling_sdk::models::decision::Decision;
2540
2541fn main() {
2542    let decision = Decision::new(
2543        1,
2544        "Use microservices",
2545        "Context here",
2546        "Decision here",
2547    );
2548    println!("Created: {}", decision.title);
2549}
2550```
2551
2552## Configuration
2553
2554Configure using YAML:
2555
2556```yaml
2557sdk:
2558  log_level: info
2559  storage_path: ./data
2560```
2561
2562For more information, see the documentation."#,
2563            "docs@opendatamodelling.com",
2564        );
2565
2566        let exporter = PdfExporter::new();
2567
2568        // Export Decision
2569        let result = exporter.export_decision(&decision).unwrap();
2570        let pdf_bytes = base64::engine::general_purpose::STANDARD
2571            .decode(&result.pdf_base64)
2572            .unwrap();
2573        std::fs::write("/tmp/sample_decision.pdf", &pdf_bytes).unwrap();
2574        println!("Wrote /tmp/sample_decision.pdf ({} bytes)", pdf_bytes.len());
2575
2576        // Export Knowledge Article
2577        let result = exporter.export_knowledge(&article).unwrap();
2578        let pdf_bytes = base64::engine::general_purpose::STANDARD
2579            .decode(&result.pdf_base64)
2580            .unwrap();
2581        std::fs::write("/tmp/sample_knowledge.pdf", &pdf_bytes).unwrap();
2582        println!(
2583            "Wrote /tmp/sample_knowledge.pdf ({} bytes)",
2584            pdf_bytes.len()
2585        );
2586
2587        // Export Data Contract (Table)
2588        use crate::models::{Column, Table};
2589
2590        let mut table = Table::new(
2591            "customer_orders".to_string(),
2592            vec![
2593                {
2594                    let mut col = Column::new("order_id".to_string(), "BIGINT".to_string());
2595                    col.primary_key = true;
2596                    col.description = "Unique identifier for each order".to_string();
2597                    col
2598                },
2599                {
2600                    let mut col = Column::new("customer_id".to_string(), "BIGINT".to_string());
2601                    col.description = "Foreign key reference to customers table".to_string();
2602                    col
2603                },
2604                {
2605                    let mut col = Column::new("order_date".to_string(), "TIMESTAMP".to_string());
2606                    col.description = "Date and time when the order was placed".to_string();
2607                    col.nullable = false;
2608                    col
2609                },
2610                {
2611                    let mut col = Column::new("status".to_string(), "VARCHAR(50)".to_string());
2612                    col.description = "Current status of the order".to_string();
2613                    col.enum_values = vec![
2614                        "pending".to_string(),
2615                        "processing".to_string(),
2616                        "shipped".to_string(),
2617                        "delivered".to_string(),
2618                        "cancelled".to_string(),
2619                    ];
2620                    col.business_name = Some("Order Status".to_string());
2621                    col
2622                },
2623                {
2624                    let mut col =
2625                        Column::new("total_amount".to_string(), "DECIMAL(10,2)".to_string());
2626                    col.description = "Total order amount in USD".to_string();
2627                    col
2628                },
2629            ],
2630        );
2631        table.schema_name = Some("sales".to_string());
2632        table.catalog_name = Some("production".to_string());
2633        table.owner = Some("Data Engineering Team".to_string());
2634        table.notes = Some("Contains all customer orders including historical data. This table is partitioned by order_date for query performance. Updated daily via ETL pipeline.".to_string());
2635
2636        // Add ODCS metadata to test full export
2637        table
2638            .odcl_metadata
2639            .insert("apiVersion".to_string(), serde_json::json!("v3.0.2"));
2640        table
2641            .odcl_metadata
2642            .insert("kind".to_string(), serde_json::json!("DataContract"));
2643        table
2644            .odcl_metadata
2645            .insert("status".to_string(), serde_json::json!("active"));
2646        table
2647            .odcl_metadata
2648            .insert("version".to_string(), serde_json::json!("1.2.0"));
2649        table
2650            .odcl_metadata
2651            .insert("domain".to_string(), serde_json::json!("Sales"));
2652        table.odcl_metadata.insert(
2653            "dataProduct".to_string(),
2654            serde_json::json!("Customer Orders Analytics"),
2655        );
2656
2657        // Add SLA information
2658        use crate::models::table::SlaProperty;
2659        table.sla = Some(vec![
2660            SlaProperty {
2661                property: "availability".to_string(),
2662                value: serde_json::json!("99.9"),
2663                unit: "%".to_string(),
2664                element: None,
2665                driver: Some("operational".to_string()),
2666                description: Some("Guaranteed uptime for data access".to_string()),
2667                scheduler: None,
2668                schedule: None,
2669            },
2670            SlaProperty {
2671                property: "freshness".to_string(),
2672                value: serde_json::json!(24),
2673                unit: "hours".to_string(),
2674                element: None,
2675                driver: Some("analytics".to_string()),
2676                description: Some("Maximum data staleness".to_string()),
2677                scheduler: None,
2678                schedule: None,
2679            },
2680        ]);
2681
2682        // Add contact details
2683        use crate::models::table::ContactDetails;
2684        table.contact_details = Some(ContactDetails {
2685            name: Some("John Smith".to_string()),
2686            email: Some("john.smith@example.com".to_string()),
2687            role: Some("Data Steward".to_string()),
2688            phone: Some("+1-555-0123".to_string()),
2689            other: None,
2690        });
2691
2692        let result = exporter.export_table(&table).unwrap();
2693        let pdf_bytes = base64::engine::general_purpose::STANDARD
2694            .decode(&result.pdf_base64)
2695            .unwrap();
2696        std::fs::write("/tmp/sample_table.pdf", &pdf_bytes).unwrap();
2697        println!("Wrote /tmp/sample_table.pdf ({} bytes)", pdf_bytes.len());
2698
2699        // Export Data Product (ODPS)
2700        use crate::models::odps::{
2701            ODPSDataProduct, ODPSDescription, ODPSInputPort, ODPSOutputPort, ODPSStatus,
2702            ODPSSupport, ODPSTeam, ODPSTeamMember,
2703        };
2704
2705        let product = ODPSDataProduct {
2706            api_version: "v1.0.0".to_string(),
2707            kind: "DataProduct".to_string(),
2708            id: "dp-customer-360-view".to_string(),
2709            name: Some("Customer 360 View".to_string()),
2710            version: Some("2.1.0".to_string()),
2711            status: ODPSStatus::Active,
2712            domain: Some("Customer Intelligence".to_string()),
2713            tenant: Some("ACME Corp".to_string()),
2714            authoritative_definitions: None,
2715            description: Some(ODPSDescription {
2716                purpose: Some("Provides a unified 360-degree view of customers by aggregating data from multiple sources including CRM, transactions, support tickets, and marketing interactions.".to_string()),
2717                limitations: Some("Data is refreshed daily at 2 AM UTC. Real-time updates are not supported. Historical data is retained for 7 years.".to_string()),
2718                usage: Some("Use this data product for customer analytics, segmentation, personalization, and churn prediction models.".to_string()),
2719                authoritative_definitions: None,
2720                custom_properties: None,
2721            }),
2722            custom_properties: None,
2723            tags: vec![],
2724            input_ports: Some(vec![
2725                ODPSInputPort {
2726                    name: "crm-contacts".to_string(),
2727                    version: "1.0.0".to_string(),
2728                    contract_id: "contract-crm-001".to_string(),
2729                    tags: vec![],
2730                    custom_properties: None,
2731                    authoritative_definitions: None,
2732                },
2733                ODPSInputPort {
2734                    name: "transaction-history".to_string(),
2735                    version: "2.0.0".to_string(),
2736                    contract_id: "contract-txn-002".to_string(),
2737                    tags: vec![],
2738                    custom_properties: None,
2739                    authoritative_definitions: None,
2740                },
2741            ]),
2742            output_ports: Some(vec![
2743                ODPSOutputPort {
2744                    name: "customer-profile".to_string(),
2745                    version: "2.1.0".to_string(),
2746                    description: Some("Unified customer profile with demographics, preferences, and behavioral scores".to_string()),
2747                    r#type: Some("table".to_string()),
2748                    contract_id: Some("contract-profile-001".to_string()),
2749                    sbom: None,
2750                    input_contracts: None,
2751                    tags: vec![],
2752                    custom_properties: None,
2753                    authoritative_definitions: None,
2754                },
2755                ODPSOutputPort {
2756                    name: "customer-segments".to_string(),
2757                    version: "1.5.0".to_string(),
2758                    description: Some("Customer segmentation based on RFM analysis and behavioral clustering".to_string()),
2759                    r#type: Some("table".to_string()),
2760                    contract_id: Some("contract-segments-001".to_string()),
2761                    sbom: None,
2762                    input_contracts: None,
2763                    tags: vec![],
2764                    custom_properties: None,
2765                    authoritative_definitions: None,
2766                },
2767            ]),
2768            management_ports: None,
2769            support: Some(vec![ODPSSupport {
2770                channel: "Slack".to_string(),
2771                url: "https://acme.slack.com/channels/customer-data".to_string(),
2772                description: Some("Primary support channel for data product questions".to_string()),
2773                tool: Some("Slack".to_string()),
2774                scope: None,
2775                invitation_url: None,
2776                tags: vec![],
2777                custom_properties: None,
2778                authoritative_definitions: None,
2779            }]),
2780            team: Some(ODPSTeam {
2781                name: Some("Customer Data Team".to_string()),
2782                description: Some("Responsible for customer data products and analytics".to_string()),
2783                members: Some(vec![
2784                    ODPSTeamMember {
2785                        username: "john.doe@acme.com".to_string(),
2786                        name: Some("John Doe".to_string()),
2787                        role: Some("Product Owner".to_string()),
2788                        description: None,
2789                        date_in: None,
2790                        date_out: None,
2791                        replaced_by_username: None,
2792                        tags: vec![],
2793                        custom_properties: None,
2794                        authoritative_definitions: None,
2795                    },
2796                    ODPSTeamMember {
2797                        username: "jane.smith@acme.com".to_string(),
2798                        name: Some("Jane Smith".to_string()),
2799                        role: Some("Data Engineer".to_string()),
2800                        description: None,
2801                        date_in: None,
2802                        date_out: None,
2803                        replaced_by_username: None,
2804                        tags: vec![],
2805                        custom_properties: None,
2806                        authoritative_definitions: None,
2807                    },
2808                ]),
2809                tags: vec![],
2810                custom_properties: None,
2811                authoritative_definitions: None,
2812            }),
2813            product_created_ts: None,
2814            created_at: Some(chrono::Utc::now()),
2815            updated_at: Some(chrono::Utc::now()),
2816        };
2817
2818        let result = exporter.export_data_product(&product).unwrap();
2819        let pdf_bytes = base64::engine::general_purpose::STANDARD
2820            .decode(&result.pdf_base64)
2821            .unwrap();
2822        std::fs::write("/tmp/sample_data_product.pdf", &pdf_bytes).unwrap();
2823        println!(
2824            "Wrote /tmp/sample_data_product.pdf ({} bytes)",
2825            pdf_bytes.len()
2826        );
2827
2828        // Export CADS Asset
2829        use crate::models::cads::{
2830            CADSAsset, CADSDescription, CADSImpactArea, CADSKind, CADSRisk, CADSRiskClassification,
2831            CADSRuntime, CADSRuntimeResources, CADSStatus, CADSTeamMember,
2832        };
2833
2834        let asset = CADSAsset {
2835            api_version: "v1.0".to_string(),
2836            kind: CADSKind::AIModel,
2837            id: "urn:cads:ai-model:sentiment-analysis:v2".to_string(),
2838            name: "Customer Sentiment Analysis Model".to_string(),
2839            version: "2.3.1".to_string(),
2840            status: CADSStatus::Production,
2841            domain: Some("Natural Language Processing".to_string()),
2842            tags: vec![],
2843            description: Some(CADSDescription {
2844                purpose: Some("Analyzes customer feedback, reviews, and support tickets to determine sentiment polarity (positive, negative, neutral) and emotion categories.".to_string()),
2845                usage: Some("Send text via REST API to /v2/predict endpoint. Supports batch processing up to 100 items per request.".to_string()),
2846                limitations: Some("English language only. Maximum 5000 characters per text input. Not suitable for sarcasm detection.".to_string()),
2847                external_links: None,
2848            }),
2849            runtime: Some(CADSRuntime {
2850                environment: Some("Kubernetes".to_string()),
2851                endpoints: Some(vec![
2852                    "https://api.example.com/ml/sentiment/v2".to_string(),
2853                ]),
2854                container: None,
2855                resources: Some(CADSRuntimeResources {
2856                    cpu: Some("4 cores".to_string()),
2857                    memory: Some("16 GB".to_string()),
2858                    gpu: Some("1x NVIDIA T4".to_string()),
2859                }),
2860            }),
2861            sla: None,
2862            pricing: None,
2863            team: Some(vec![
2864                CADSTeamMember {
2865                    role: "Model Owner".to_string(),
2866                    name: "Dr. Sarah Chen".to_string(),
2867                    contact: Some("sarah.chen@example.com".to_string()),
2868                },
2869                CADSTeamMember {
2870                    role: "ML Engineer".to_string(),
2871                    name: "Alex Kumar".to_string(),
2872                    contact: Some("alex.kumar@example.com".to_string()),
2873                },
2874            ]),
2875            risk: Some(CADSRisk {
2876                classification: Some(CADSRiskClassification::Medium),
2877                impact_areas: Some(vec![CADSImpactArea::Fairness, CADSImpactArea::Privacy]),
2878                intended_use: Some("Analyzing customer sentiment for product improvement and support prioritization".to_string()),
2879                out_of_scope_use: Some("Medical diagnosis, legal decisions, credit scoring".to_string()),
2880                assessment: None,
2881                mitigations: None,
2882            }),
2883            compliance: None,
2884            validation_profiles: None,
2885            bpmn_models: None,
2886            dmn_models: None,
2887            openapi_specs: None,
2888            custom_properties: None,
2889            created_at: Some(chrono::Utc::now()),
2890            updated_at: Some(chrono::Utc::now()),
2891        };
2892
2893        let result = exporter.export_cads_asset(&asset).unwrap();
2894        let pdf_bytes = base64::engine::general_purpose::STANDARD
2895            .decode(&result.pdf_base64)
2896            .unwrap();
2897        std::fs::write("/tmp/sample_cads_asset.pdf", &pdf_bytes).unwrap();
2898        println!(
2899            "Wrote /tmp/sample_cads_asset.pdf ({} bytes)",
2900            pdf_bytes.len()
2901        );
2902    }
2903}