data_modelling_core/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                '…' => result.push_str("\\205"), // Ellipsis
2126                _ if c.is_ascii() => result.push(c),
2127                // For non-ASCII characters, try to use closest ASCII equivalent
2128                _ => result.push('?'),
2129            }
2130        }
2131        result
2132    }
2133
2134    /// Word wrap text to fit within max characters per line.
2135    /// Long words (URLs, field names) that exceed max_chars are broken with a
2136    /// continuation marker (→) to indicate the text continues on the next line.
2137    fn word_wrap(&self, text: &str, max_chars: usize) -> Vec<String> {
2138        let mut lines = Vec::new();
2139        let mut current_line = String::new();
2140        // Ensure we have at least some space for the continuation marker
2141        let effective_max = max_chars.max(5);
2142
2143        for word in text.split_whitespace() {
2144            // If the word itself is too long, break it with continuation markers
2145            if word.len() > effective_max {
2146                // First, flush any current content
2147                if !current_line.is_empty() {
2148                    lines.push(current_line);
2149                    current_line = String::new();
2150                }
2151                // Break the long word into chunks
2152                let broken = self.break_long_word(word, effective_max);
2153                for (i, chunk) in broken.iter().enumerate() {
2154                    if i < broken.len() - 1 {
2155                        // Not the last chunk, push it as its own line
2156                        lines.push(chunk.clone());
2157                    } else {
2158                        // Last chunk becomes start of current line
2159                        current_line = chunk.clone();
2160                    }
2161                }
2162            } else if current_line.is_empty() {
2163                current_line = word.to_string();
2164            } else if current_line.len() + 1 + word.len() <= effective_max {
2165                current_line.push(' ');
2166                current_line.push_str(word);
2167            } else {
2168                lines.push(current_line);
2169                current_line = word.to_string();
2170            }
2171        }
2172
2173        if !current_line.is_empty() {
2174            lines.push(current_line);
2175        }
2176
2177        if lines.is_empty() {
2178            lines.push(String::new());
2179        }
2180
2181        lines
2182    }
2183
2184    /// Break a long word into chunks that fit within max_chars.
2185    /// Each chunk except the last ends with a hyphen (-) to indicate continuation.
2186    fn break_long_word(&self, word: &str, max_chars: usize) -> Vec<String> {
2187        let mut chunks = Vec::new();
2188        let chars: Vec<char> = word.chars().collect();
2189        // Use hyphen as continuation marker (ASCII-safe for PDF)
2190        let continuation_marker = "-";
2191        // Reserve 1 char for the continuation marker on non-final chunks
2192        let chunk_size = (max_chars - 1).max(1);
2193
2194        let mut start = 0;
2195        while start < chars.len() {
2196            let remaining = chars.len() - start;
2197            if remaining <= max_chars {
2198                // Final chunk - no continuation marker needed
2199                chunks.push(chars[start..].iter().collect());
2200                break;
2201            } else {
2202                // Not final - add continuation marker
2203                let end = start + chunk_size;
2204                let mut chunk: String = chars[start..end].iter().collect();
2205                chunk.push_str(continuation_marker);
2206                chunks.push(chunk);
2207                start = end;
2208            }
2209        }
2210
2211        if chunks.is_empty() {
2212            chunks.push(word.to_string());
2213        }
2214
2215        chunks
2216    }
2217}
2218
2219#[cfg(test)]
2220mod tests {
2221    use super::*;
2222    use crate::models::decision::Decision;
2223    use crate::models::knowledge::KnowledgeArticle;
2224
2225    #[test]
2226    fn test_branding_config_default() {
2227        let config = BrandingConfig::default();
2228        assert_eq!(config.brand_color, "#0066CC");
2229        assert!(config.show_page_numbers);
2230        assert!(config.show_timestamp);
2231        assert_eq!(config.font_size, 11);
2232        assert_eq!(config.page_size, PageSize::A4);
2233        assert_eq!(config.logo_url, Some(DEFAULT_LOGO_URL.to_string()));
2234        assert_eq!(config.footer, Some(DEFAULT_COPYRIGHT.to_string()));
2235    }
2236
2237    #[test]
2238    fn test_page_size_dimensions() {
2239        let a4 = PageSize::A4;
2240        let (w, h) = a4.dimensions_mm();
2241        assert_eq!(w, 210.0);
2242        assert_eq!(h, 297.0);
2243
2244        let letter = PageSize::Letter;
2245        let (w, h) = letter.dimensions_mm();
2246        assert!((w - 215.9).abs() < 0.1);
2247        assert!((h - 279.4).abs() < 0.1);
2248    }
2249
2250    #[test]
2251    fn test_pdf_exporter_with_branding() {
2252        let branding = BrandingConfig {
2253            header: Some("Company Header".to_string()),
2254            footer: Some("Confidential".to_string()),
2255            company_name: Some("Test Corp".to_string()),
2256            brand_color: "#FF0000".to_string(),
2257            ..Default::default()
2258        };
2259
2260        let exporter = PdfExporter::with_branding(branding.clone());
2261        assert_eq!(
2262            exporter.branding().header,
2263            Some("Company Header".to_string())
2264        );
2265        assert_eq!(exporter.branding().brand_color, "#FF0000");
2266    }
2267
2268    #[test]
2269    fn test_export_decision_to_pdf() {
2270        let decision = Decision::new(
2271            1,
2272            "Use Rust for SDK",
2273            "We need to choose a language for the SDK implementation.",
2274            "Use Rust for type safety and performance.",
2275        );
2276
2277        let exporter = PdfExporter::new();
2278        let result = exporter.export_decision(&decision);
2279        assert!(result.is_ok());
2280
2281        let pdf_result = result.unwrap();
2282        assert!(!pdf_result.pdf_base64.is_empty());
2283        assert!(pdf_result.filename.ends_with(".pdf"));
2284        assert!(pdf_result.page_count >= 1);
2285        assert!(pdf_result.title.contains("ADR-"));
2286    }
2287
2288    #[test]
2289    fn test_export_knowledge_to_pdf() {
2290        let article = KnowledgeArticle::new(
2291            1,
2292            "Getting Started Guide",
2293            "A guide to getting started with the SDK.",
2294            "This guide covers the basics...",
2295            "author@example.com",
2296        );
2297
2298        let exporter = PdfExporter::new();
2299        let result = exporter.export_knowledge(&article);
2300        assert!(result.is_ok());
2301
2302        let pdf_result = result.unwrap();
2303        assert!(!pdf_result.pdf_base64.is_empty());
2304        assert!(pdf_result.filename.ends_with(".pdf"));
2305        assert!(pdf_result.title.contains("KB-"));
2306    }
2307
2308    #[test]
2309    fn test_export_table_to_pdf() {
2310        use crate::models::{Column, Table};
2311
2312        let mut table = Table::new(
2313            "users".to_string(),
2314            vec![
2315                Column::new("id".to_string(), "BIGINT".to_string()),
2316                Column::new("name".to_string(), "VARCHAR(255)".to_string()),
2317                Column::new("email".to_string(), "VARCHAR(255)".to_string()),
2318            ],
2319        );
2320        table.schema_name = Some("public".to_string());
2321        table.owner = Some("Data Engineering".to_string());
2322        table.notes = Some("Core user table for the application".to_string());
2323
2324        let exporter = PdfExporter::new();
2325        let result = exporter.export_table(&table);
2326        assert!(result.is_ok());
2327
2328        let pdf_result = result.unwrap();
2329        assert!(!pdf_result.pdf_base64.is_empty());
2330        assert!(pdf_result.filename.ends_with(".pdf"));
2331        assert_eq!(pdf_result.title, "users");
2332    }
2333
2334    #[test]
2335    fn test_export_data_product_to_pdf() {
2336        use crate::models::odps::{ODPSDataProduct, ODPSDescription, ODPSOutputPort, ODPSStatus};
2337
2338        let product = ODPSDataProduct {
2339            api_version: "v1.0.0".to_string(),
2340            kind: "DataProduct".to_string(),
2341            id: "dp-customer-360".to_string(),
2342            name: Some("Customer 360".to_string()),
2343            version: Some("1.0.0".to_string()),
2344            status: ODPSStatus::Active,
2345            domain: Some("Customer".to_string()),
2346            tenant: None,
2347            authoritative_definitions: None,
2348            description: Some(ODPSDescription {
2349                purpose: Some("Unified customer view across all touchpoints".to_string()),
2350                limitations: Some("Does not include real-time data".to_string()),
2351                usage: Some("Use for analytics and reporting".to_string()),
2352                authoritative_definitions: None,
2353                custom_properties: None,
2354            }),
2355            custom_properties: None,
2356            tags: vec![],
2357            input_ports: None,
2358            output_ports: Some(vec![ODPSOutputPort {
2359                name: "customer-data".to_string(),
2360                version: "1.0.0".to_string(),
2361                description: Some("Customer master data".to_string()),
2362                r#type: Some("table".to_string()),
2363                contract_id: Some("contract-123".to_string()),
2364                sbom: None,
2365                input_contracts: None,
2366                tags: vec![],
2367                custom_properties: None,
2368                authoritative_definitions: None,
2369            }]),
2370            management_ports: None,
2371            support: None,
2372            team: None,
2373            product_created_ts: None,
2374            created_at: None,
2375            updated_at: None,
2376        };
2377
2378        let exporter = PdfExporter::new();
2379        let result = exporter.export_data_product(&product);
2380        assert!(result.is_ok());
2381
2382        let pdf_result = result.unwrap();
2383        assert!(!pdf_result.pdf_base64.is_empty());
2384        assert!(pdf_result.filename.ends_with(".pdf"));
2385        assert_eq!(pdf_result.title, "Customer 360");
2386    }
2387
2388    #[test]
2389    fn test_export_cads_asset_to_pdf() {
2390        use crate::models::cads::{
2391            CADSAsset, CADSDescription, CADSKind, CADSStatus, CADSTeamMember,
2392        };
2393
2394        let asset = CADSAsset {
2395            api_version: "v1.0".to_string(),
2396            kind: CADSKind::AIModel,
2397            id: "model-sentiment-v1".to_string(),
2398            name: "Sentiment Analysis Model".to_string(),
2399            version: "1.0.0".to_string(),
2400            status: CADSStatus::Production,
2401            domain: Some("NLP".to_string()),
2402            tags: vec![],
2403            description: Some(CADSDescription {
2404                purpose: Some("Analyze sentiment in customer feedback".to_string()),
2405                usage: Some("Call the /predict endpoint with text input".to_string()),
2406                limitations: Some("English language only".to_string()),
2407                external_links: None,
2408            }),
2409            runtime: None,
2410            sla: None,
2411            pricing: None,
2412            team: Some(vec![CADSTeamMember {
2413                role: "Owner".to_string(),
2414                name: "ML Team".to_string(),
2415                contact: Some("ml-team@example.com".to_string()),
2416            }]),
2417            risk: None,
2418            compliance: None,
2419            validation_profiles: None,
2420            bpmn_models: None,
2421            dmn_models: None,
2422            openapi_specs: None,
2423            custom_properties: None,
2424            created_at: None,
2425            updated_at: None,
2426        };
2427
2428        let exporter = PdfExporter::new();
2429        let result = exporter.export_cads_asset(&asset);
2430        assert!(result.is_ok());
2431
2432        let pdf_result = result.unwrap();
2433        assert!(!pdf_result.pdf_base64.is_empty());
2434        assert!(pdf_result.filename.ends_with(".pdf"));
2435        assert_eq!(pdf_result.title, "Sentiment Analysis Model");
2436    }
2437
2438    #[test]
2439    fn test_export_markdown_to_pdf() {
2440        let exporter = PdfExporter::new();
2441        let result = exporter.export_markdown(
2442            "Test Document",
2443            "# Test\n\nThis is a test document.\n\n## Section\n\n- Item 1\n- Item 2",
2444            "test.pdf",
2445        );
2446        assert!(result.is_ok());
2447
2448        let pdf_result = result.unwrap();
2449        assert!(!pdf_result.pdf_base64.is_empty());
2450        assert_eq!(pdf_result.filename, "test.pdf");
2451    }
2452
2453    #[test]
2454    fn test_escape_pdf_string() {
2455        let exporter = PdfExporter::new();
2456        assert_eq!(exporter.escape_pdf_string("Hello"), "Hello");
2457        assert_eq!(exporter.escape_pdf_string("(test)"), "\\(test\\)");
2458        assert_eq!(exporter.escape_pdf_string("back\\slash"), "back\\\\slash");
2459    }
2460
2461    #[test]
2462    fn test_word_wrap() {
2463        let exporter = PdfExporter::new();
2464
2465        let wrapped = exporter.word_wrap("Hello world this is a test", 10);
2466        assert!(wrapped.len() > 1);
2467
2468        let wrapped = exporter.word_wrap("Short", 100);
2469        assert_eq!(wrapped.len(), 1);
2470        assert_eq!(wrapped[0], "Short");
2471    }
2472
2473    #[test]
2474    fn test_word_wrap_long_url() {
2475        let exporter = PdfExporter::new();
2476
2477        // Test that a long URL without spaces gets broken with continuation markers
2478        let long_url = "https://example.com/very/long/path/to/some/resource/file.json";
2479        let wrapped = exporter.word_wrap(long_url, 20);
2480
2481        // Should be broken into multiple lines
2482        assert!(
2483            wrapped.len() > 1,
2484            "Long URL should be wrapped into multiple lines"
2485        );
2486
2487        // All lines except the last should end with hyphen
2488        for (i, line) in wrapped.iter().enumerate() {
2489            if i < wrapped.len() - 1 {
2490                assert!(
2491                    line.ends_with('-'),
2492                    "Non-final line should end with hyphen: {}",
2493                    line
2494                );
2495            }
2496        }
2497
2498        // When joined (removing hyphens), should reconstruct the original
2499        let reconstructed: String = wrapped
2500            .iter()
2501            .map(|s| s.trim_end_matches('-'))
2502            .collect::<Vec<_>>()
2503            .join("");
2504        assert_eq!(reconstructed, long_url);
2505    }
2506
2507    #[test]
2508    fn test_word_wrap_mixed_content() {
2509        let exporter = PdfExporter::new();
2510
2511        // Test mixed content with short words and a long URL
2512        let text = "See https://example.com/very/long/path/to/resource for details";
2513        let wrapped = exporter.word_wrap(text, 25);
2514
2515        // Should have multiple lines
2516        assert!(wrapped.len() > 1);
2517
2518        // The URL should be broken across lines
2519        let all_text = wrapped.join(" ");
2520        assert!(all_text.contains("https://"));
2521    }
2522
2523    #[test]
2524    fn test_break_long_word() {
2525        let exporter = PdfExporter::new();
2526
2527        // Test breaking a long word
2528        let long_word = "abcdefghijklmnopqrstuvwxyz";
2529        let broken = exporter.break_long_word(long_word, 10);
2530
2531        // Should be broken into multiple chunks
2532        assert!(broken.len() > 1);
2533
2534        // All chunks except the last should end with hyphen
2535        for (i, chunk) in broken.iter().enumerate() {
2536            if i < broken.len() - 1 {
2537                assert!(
2538                    chunk.ends_with('-'),
2539                    "Chunk should end with hyphen: {}",
2540                    chunk
2541                );
2542                assert!(chunk.len() <= 10, "Chunk should fit within max_chars");
2543            }
2544        }
2545
2546        // Reconstruct and verify
2547        let reconstructed: String = broken
2548            .iter()
2549            .map(|s| s.trim_end_matches('-'))
2550            .collect::<Vec<_>>()
2551            .join("");
2552        assert_eq!(reconstructed, long_word);
2553    }
2554
2555    #[test]
2556    fn test_pdf_result_serialization() {
2557        let result = PdfExportResult {
2558            pdf_base64: "dGVzdA==".to_string(),
2559            filename: "test.pdf".to_string(),
2560            page_count: 1,
2561            title: "Test".to_string(),
2562        };
2563
2564        let json = serde_json::to_string(&result).unwrap();
2565        assert!(json.contains("pdf_base64"));
2566        assert!(json.contains("filename"));
2567    }
2568
2569    #[test]
2570    fn test_strip_markdown_formatting() {
2571        let exporter = PdfExporter::new();
2572        assert_eq!(exporter.strip_markdown_formatting("**bold**"), "bold");
2573        assert_eq!(exporter.strip_markdown_formatting("`code`"), "code");
2574        assert_eq!(
2575            exporter.strip_markdown_formatting("[link](http://example.com)"),
2576            "link"
2577        );
2578    }
2579
2580    /// Generate sample PDFs for visual inspection (writes to /tmp)
2581    /// Run with: cargo test generate_sample_pdfs_for_inspection -- --ignored --nocapture
2582    #[test]
2583    #[ignore]
2584    fn generate_sample_pdfs_for_inspection() {
2585        use crate::models::decision::DecisionOption;
2586        use base64::Engine;
2587
2588        // Create a Decision with rich content including options with pros/cons
2589        let mut decision = Decision::new(
2590            2501100001,
2591            "Use Rust for SDK Implementation",
2592            "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.",
2593            "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.",
2594        );
2595
2596        // Add options with pros and cons
2597        decision.options = vec![
2598            DecisionOption::with_details(
2599                "Rust",
2600                "A systems programming language focused on safety and performance.",
2601                vec![
2602                    "Memory safety without garbage collection".to_string(),
2603                    "Excellent performance".to_string(),
2604                    "Strong type system".to_string(),
2605                    "First-class WASM support".to_string(),
2606                    "Growing ecosystem".to_string(),
2607                ],
2608                vec![
2609                    "Steeper learning curve".to_string(),
2610                    "Longer compilation times".to_string(),
2611                    "Smaller talent pool".to_string(),
2612                ],
2613                true, // selected
2614            ),
2615            DecisionOption::with_details(
2616                "TypeScript",
2617                "A typed superset of JavaScript.",
2618                vec![
2619                    "Large developer community".to_string(),
2620                    "Easy to learn".to_string(),
2621                    "Good tooling".to_string(),
2622                ],
2623                vec![
2624                    "Runtime type checking only".to_string(),
2625                    "Performance limitations".to_string(),
2626                    "Node.js dependency".to_string(),
2627                ],
2628                false,
2629            ),
2630            DecisionOption::with_details(
2631                "Go",
2632                "A statically typed language designed at Google.",
2633                vec![
2634                    "Simple syntax".to_string(),
2635                    "Fast compilation".to_string(),
2636                    "Good concurrency support".to_string(),
2637                ],
2638                vec![
2639                    "Limited generics".to_string(),
2640                    "No WASM support".to_string(),
2641                    "Verbose error handling".to_string(),
2642                ],
2643                false,
2644            ),
2645        ];
2646
2647        decision.consequences =
2648            Some("This decision will have significant impact on the project.".to_string());
2649
2650        // Debug: print the generated markdown to see what's being rendered
2651        let exporter = PdfExporter::new();
2652        let md = exporter.decision_to_markdown(&decision);
2653        println!("Generated markdown length: {} chars", md.len());
2654        println!(
2655            "Contains 'Options Considered': {}",
2656            md.contains("Options Considered")
2657        );
2658        println!("Contains 'Pros': {}", md.contains("Pros"));
2659
2660        // Create a Knowledge Article with code blocks
2661        let article = KnowledgeArticle::new(
2662            2501100001,
2663            "Getting Started with the SDK",
2664            "A comprehensive guide to getting started with the Open Data Modelling SDK.",
2665            r#"## Installation
2666
2667Install the SDK using cargo:
2668
2669```bash
2670cargo add data-modelling-sdk
2671```
2672
2673## Basic Usage
2674
2675Here's a simple example:
2676
2677```rust
2678use data_modelling_core::models::decision::Decision;
2679
2680fn main() {
2681    let decision = Decision::new(
2682        1,
2683        "Use microservices",
2684        "Context here",
2685        "Decision here",
2686    );
2687    println!("Created: {}", decision.title);
2688}
2689```
2690
2691## Configuration
2692
2693Configure using YAML:
2694
2695```yaml
2696sdk:
2697  log_level: info
2698  storage_path: ./data
2699```
2700
2701For more information, see the documentation."#,
2702            "docs@opendatamodelling.com",
2703        );
2704
2705        let exporter = PdfExporter::new();
2706
2707        // Export Decision
2708        let result = exporter.export_decision(&decision).unwrap();
2709        let pdf_bytes = base64::engine::general_purpose::STANDARD
2710            .decode(&result.pdf_base64)
2711            .unwrap();
2712        std::fs::write("/tmp/sample_decision.pdf", &pdf_bytes).unwrap();
2713        println!("Wrote /tmp/sample_decision.pdf ({} bytes)", pdf_bytes.len());
2714
2715        // Export Knowledge Article
2716        let result = exporter.export_knowledge(&article).unwrap();
2717        let pdf_bytes = base64::engine::general_purpose::STANDARD
2718            .decode(&result.pdf_base64)
2719            .unwrap();
2720        std::fs::write("/tmp/sample_knowledge.pdf", &pdf_bytes).unwrap();
2721        println!(
2722            "Wrote /tmp/sample_knowledge.pdf ({} bytes)",
2723            pdf_bytes.len()
2724        );
2725
2726        // Export Data Contract (Table)
2727        use crate::models::{Column, Table};
2728
2729        let mut table = Table::new(
2730            "customer_orders".to_string(),
2731            vec![
2732                {
2733                    let mut col = Column::new("order_id".to_string(), "BIGINT".to_string());
2734                    col.primary_key = true;
2735                    col.description = "Unique identifier for each order".to_string();
2736                    col
2737                },
2738                {
2739                    let mut col = Column::new("customer_id".to_string(), "BIGINT".to_string());
2740                    col.description = "Foreign key reference to customers table".to_string();
2741                    col
2742                },
2743                {
2744                    let mut col = Column::new("order_date".to_string(), "TIMESTAMP".to_string());
2745                    col.description = "Date and time when the order was placed".to_string();
2746                    col.nullable = false;
2747                    col
2748                },
2749                {
2750                    let mut col = Column::new("status".to_string(), "VARCHAR(50)".to_string());
2751                    col.description = "Current status of the order".to_string();
2752                    col.enum_values = vec![
2753                        "pending".to_string(),
2754                        "processing".to_string(),
2755                        "shipped".to_string(),
2756                        "delivered".to_string(),
2757                        "cancelled".to_string(),
2758                    ];
2759                    col.business_name = Some("Order Status".to_string());
2760                    col
2761                },
2762                {
2763                    let mut col =
2764                        Column::new("total_amount".to_string(), "DECIMAL(10,2)".to_string());
2765                    col.description = "Total order amount in USD".to_string();
2766                    col
2767                },
2768            ],
2769        );
2770        table.schema_name = Some("sales".to_string());
2771        table.catalog_name = Some("production".to_string());
2772        table.owner = Some("Data Engineering Team".to_string());
2773        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());
2774
2775        // Add ODCS metadata to test full export
2776        table
2777            .odcl_metadata
2778            .insert("apiVersion".to_string(), serde_json::json!("v3.0.2"));
2779        table
2780            .odcl_metadata
2781            .insert("kind".to_string(), serde_json::json!("DataContract"));
2782        table
2783            .odcl_metadata
2784            .insert("status".to_string(), serde_json::json!("active"));
2785        table
2786            .odcl_metadata
2787            .insert("version".to_string(), serde_json::json!("1.2.0"));
2788        table
2789            .odcl_metadata
2790            .insert("domain".to_string(), serde_json::json!("Sales"));
2791        table.odcl_metadata.insert(
2792            "dataProduct".to_string(),
2793            serde_json::json!("Customer Orders Analytics"),
2794        );
2795
2796        // Add SLA information
2797        use crate::models::table::SlaProperty;
2798        table.sla = Some(vec![
2799            SlaProperty {
2800                property: "availability".to_string(),
2801                value: serde_json::json!("99.9"),
2802                unit: "%".to_string(),
2803                element: None,
2804                driver: Some("operational".to_string()),
2805                description: Some("Guaranteed uptime for data access".to_string()),
2806                scheduler: None,
2807                schedule: None,
2808            },
2809            SlaProperty {
2810                property: "freshness".to_string(),
2811                value: serde_json::json!(24),
2812                unit: "hours".to_string(),
2813                element: None,
2814                driver: Some("analytics".to_string()),
2815                description: Some("Maximum data staleness".to_string()),
2816                scheduler: None,
2817                schedule: None,
2818            },
2819        ]);
2820
2821        // Add contact details
2822        use crate::models::table::ContactDetails;
2823        table.contact_details = Some(ContactDetails {
2824            name: Some("John Smith".to_string()),
2825            email: Some("john.smith@example.com".to_string()),
2826            role: Some("Data Steward".to_string()),
2827            phone: Some("+1-555-0123".to_string()),
2828            other: None,
2829        });
2830
2831        let result = exporter.export_table(&table).unwrap();
2832        let pdf_bytes = base64::engine::general_purpose::STANDARD
2833            .decode(&result.pdf_base64)
2834            .unwrap();
2835        std::fs::write("/tmp/sample_table.pdf", &pdf_bytes).unwrap();
2836        println!("Wrote /tmp/sample_table.pdf ({} bytes)", pdf_bytes.len());
2837
2838        // Export Data Product (ODPS)
2839        use crate::models::odps::{
2840            ODPSDataProduct, ODPSDescription, ODPSInputPort, ODPSOutputPort, ODPSStatus,
2841            ODPSSupport, ODPSTeam, ODPSTeamMember,
2842        };
2843
2844        let product = ODPSDataProduct {
2845            api_version: "v1.0.0".to_string(),
2846            kind: "DataProduct".to_string(),
2847            id: "dp-customer-360-view".to_string(),
2848            name: Some("Customer 360 View".to_string()),
2849            version: Some("2.1.0".to_string()),
2850            status: ODPSStatus::Active,
2851            domain: Some("Customer Intelligence".to_string()),
2852            tenant: Some("ACME Corp".to_string()),
2853            authoritative_definitions: None,
2854            description: Some(ODPSDescription {
2855                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()),
2856                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()),
2857                usage: Some("Use this data product for customer analytics, segmentation, personalization, and churn prediction models.".to_string()),
2858                authoritative_definitions: None,
2859                custom_properties: None,
2860            }),
2861            custom_properties: None,
2862            tags: vec![],
2863            input_ports: Some(vec![
2864                ODPSInputPort {
2865                    name: "crm-contacts".to_string(),
2866                    version: "1.0.0".to_string(),
2867                    contract_id: "contract-crm-001".to_string(),
2868                    tags: vec![],
2869                    custom_properties: None,
2870                    authoritative_definitions: None,
2871                },
2872                ODPSInputPort {
2873                    name: "transaction-history".to_string(),
2874                    version: "2.0.0".to_string(),
2875                    contract_id: "contract-txn-002".to_string(),
2876                    tags: vec![],
2877                    custom_properties: None,
2878                    authoritative_definitions: None,
2879                },
2880            ]),
2881            output_ports: Some(vec![
2882                ODPSOutputPort {
2883                    name: "customer-profile".to_string(),
2884                    version: "2.1.0".to_string(),
2885                    description: Some("Unified customer profile with demographics, preferences, and behavioral scores".to_string()),
2886                    r#type: Some("table".to_string()),
2887                    contract_id: Some("contract-profile-001".to_string()),
2888                    sbom: None,
2889                    input_contracts: None,
2890                    tags: vec![],
2891                    custom_properties: None,
2892                    authoritative_definitions: None,
2893                },
2894                ODPSOutputPort {
2895                    name: "customer-segments".to_string(),
2896                    version: "1.5.0".to_string(),
2897                    description: Some("Customer segmentation based on RFM analysis and behavioral clustering".to_string()),
2898                    r#type: Some("table".to_string()),
2899                    contract_id: Some("contract-segments-001".to_string()),
2900                    sbom: None,
2901                    input_contracts: None,
2902                    tags: vec![],
2903                    custom_properties: None,
2904                    authoritative_definitions: None,
2905                },
2906            ]),
2907            management_ports: None,
2908            support: Some(vec![ODPSSupport {
2909                channel: "Slack".to_string(),
2910                url: "https://acme.slack.com/channels/customer-data".to_string(),
2911                description: Some("Primary support channel for data product questions".to_string()),
2912                tool: Some("Slack".to_string()),
2913                scope: None,
2914                invitation_url: None,
2915                tags: vec![],
2916                custom_properties: None,
2917                authoritative_definitions: None,
2918            }]),
2919            team: Some(ODPSTeam {
2920                name: Some("Customer Data Team".to_string()),
2921                description: Some("Responsible for customer data products and analytics".to_string()),
2922                members: Some(vec![
2923                    ODPSTeamMember {
2924                        username: "john.doe@acme.com".to_string(),
2925                        name: Some("John Doe".to_string()),
2926                        role: Some("Product Owner".to_string()),
2927                        description: None,
2928                        date_in: None,
2929                        date_out: None,
2930                        replaced_by_username: None,
2931                        tags: vec![],
2932                        custom_properties: None,
2933                        authoritative_definitions: None,
2934                    },
2935                    ODPSTeamMember {
2936                        username: "jane.smith@acme.com".to_string(),
2937                        name: Some("Jane Smith".to_string()),
2938                        role: Some("Data Engineer".to_string()),
2939                        description: None,
2940                        date_in: None,
2941                        date_out: None,
2942                        replaced_by_username: None,
2943                        tags: vec![],
2944                        custom_properties: None,
2945                        authoritative_definitions: None,
2946                    },
2947                ]),
2948                tags: vec![],
2949                custom_properties: None,
2950                authoritative_definitions: None,
2951            }),
2952            product_created_ts: None,
2953            created_at: Some(chrono::Utc::now()),
2954            updated_at: Some(chrono::Utc::now()),
2955        };
2956
2957        let result = exporter.export_data_product(&product).unwrap();
2958        let pdf_bytes = base64::engine::general_purpose::STANDARD
2959            .decode(&result.pdf_base64)
2960            .unwrap();
2961        std::fs::write("/tmp/sample_data_product.pdf", &pdf_bytes).unwrap();
2962        println!(
2963            "Wrote /tmp/sample_data_product.pdf ({} bytes)",
2964            pdf_bytes.len()
2965        );
2966
2967        // Export CADS Asset
2968        use crate::models::cads::{
2969            CADSAsset, CADSDescription, CADSImpactArea, CADSKind, CADSRisk, CADSRiskClassification,
2970            CADSRuntime, CADSRuntimeResources, CADSStatus, CADSTeamMember,
2971        };
2972
2973        let asset = CADSAsset {
2974            api_version: "v1.0".to_string(),
2975            kind: CADSKind::AIModel,
2976            id: "urn:cads:ai-model:sentiment-analysis:v2".to_string(),
2977            name: "Customer Sentiment Analysis Model".to_string(),
2978            version: "2.3.1".to_string(),
2979            status: CADSStatus::Production,
2980            domain: Some("Natural Language Processing".to_string()),
2981            tags: vec![],
2982            description: Some(CADSDescription {
2983                purpose: Some("Analyzes customer feedback, reviews, and support tickets to determine sentiment polarity (positive, negative, neutral) and emotion categories.".to_string()),
2984                usage: Some("Send text via REST API to /v2/predict endpoint. Supports batch processing up to 100 items per request.".to_string()),
2985                limitations: Some("English language only. Maximum 5000 characters per text input. Not suitable for sarcasm detection.".to_string()),
2986                external_links: None,
2987            }),
2988            runtime: Some(CADSRuntime {
2989                environment: Some("Kubernetes".to_string()),
2990                endpoints: Some(vec![
2991                    "https://api.example.com/ml/sentiment/v2".to_string(),
2992                ]),
2993                container: None,
2994                resources: Some(CADSRuntimeResources {
2995                    cpu: Some("4 cores".to_string()),
2996                    memory: Some("16 GB".to_string()),
2997                    gpu: Some("1x NVIDIA T4".to_string()),
2998                }),
2999            }),
3000            sla: None,
3001            pricing: None,
3002            team: Some(vec![
3003                CADSTeamMember {
3004                    role: "Model Owner".to_string(),
3005                    name: "Dr. Sarah Chen".to_string(),
3006                    contact: Some("sarah.chen@example.com".to_string()),
3007                },
3008                CADSTeamMember {
3009                    role: "ML Engineer".to_string(),
3010                    name: "Alex Kumar".to_string(),
3011                    contact: Some("alex.kumar@example.com".to_string()),
3012                },
3013            ]),
3014            risk: Some(CADSRisk {
3015                classification: Some(CADSRiskClassification::Medium),
3016                impact_areas: Some(vec![CADSImpactArea::Fairness, CADSImpactArea::Privacy]),
3017                intended_use: Some("Analyzing customer sentiment for product improvement and support prioritization".to_string()),
3018                out_of_scope_use: Some("Medical diagnosis, legal decisions, credit scoring".to_string()),
3019                assessment: None,
3020                mitigations: None,
3021            }),
3022            compliance: None,
3023            validation_profiles: None,
3024            bpmn_models: None,
3025            dmn_models: None,
3026            openapi_specs: None,
3027            custom_properties: None,
3028            created_at: Some(chrono::Utc::now()),
3029            updated_at: Some(chrono::Utc::now()),
3030        };
3031
3032        let result = exporter.export_cads_asset(&asset).unwrap();
3033        let pdf_bytes = base64::engine::general_purpose::STANDARD
3034            .decode(&result.pdf_base64)
3035            .unwrap();
3036        std::fs::write("/tmp/sample_cads_asset.pdf", &pdf_bytes).unwrap();
3037        println!(
3038            "Wrote /tmp/sample_cads_asset.pdf ({} bytes)",
3039            pdf_bytes.len()
3040        );
3041    }
3042}