Skip to main content

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                    || c.logical_type_options.is_some()
720                    || !c.transform_source_objects.is_empty()
721                    || c.transform_logic.is_some()
722                    || c.transform_description.is_some()
723                    || !c.relationships.is_empty()
724                    || c.foreign_key.is_some()
725                    || !c.authoritative_definitions.is_empty()
726                    || !c.quality.is_empty()
727                    || !c.tags.is_empty()
728                    || c.secondary_key
729                    || c.composite_key.is_some()
730                    || c.primary_key_position.is_some()
731                    || c.partition_key_position.is_some()
732                    || !c.constraints.is_empty()
733                    || c.encrypted_name.is_some()
734                    || c.physical_name.is_some()
735            })
736            .collect();
737
738        if !cols_with_details.is_empty() {
739            md.push_str("## Column Details\n\n");
740            for col in cols_with_details {
741                md.push_str(&format!("### {}\n\n", col.name));
742
743                if let Some(biz_name) = &col.business_name {
744                    md.push_str(&format!("**Business Name:** {}\n\n", biz_name));
745                }
746
747                if !col.description.is_empty() {
748                    md.push_str(&format!("{}\n\n", col.description));
749                }
750
751                // Physical type if different from logical
752                if let Some(phys) = &col.physical_type
753                    && phys != &col.data_type
754                {
755                    md.push_str(&format!("**Physical Type:** {}\n\n", phys));
756                }
757
758                // Physical name if different from column name
759                if let Some(phys_name) = &col.physical_name
760                    && phys_name != &col.name
761                {
762                    md.push_str(&format!("**Physical Name:** {}\n\n", phys_name));
763                }
764
765                // Key information
766                if col.primary_key
767                    && let Some(pos) = col.primary_key_position
768                {
769                    md.push_str(&format!("**Primary Key Position:** {}\n\n", pos));
770                }
771
772                if col.secondary_key {
773                    md.push_str("**Secondary Key:** Yes\n\n");
774                }
775
776                if let Some(composite) = &col.composite_key {
777                    md.push_str(&format!("**Composite Key:** {}\n\n", composite));
778                }
779
780                // Partitioning
781                if col.partitioned
782                    && let Some(pos) = col.partition_key_position
783                {
784                    md.push_str(&format!("**Partition Key Position:** {}\n\n", pos));
785                }
786
787                // Constraints
788                let mut constraint_flags = Vec::new();
789                if col.unique {
790                    constraint_flags.push("Unique");
791                }
792                if col.partitioned && col.partition_key_position.is_none() {
793                    constraint_flags.push("Partitioned");
794                }
795                if col.clustered {
796                    constraint_flags.push("Clustered");
797                }
798                if col.critical_data_element {
799                    constraint_flags.push("Critical Data Element");
800                }
801                if !constraint_flags.is_empty() {
802                    md.push_str(&format!(
803                        "**Constraints:** {}\n\n",
804                        constraint_flags.join(", ")
805                    ));
806                }
807
808                // Additional constraints (CHECK, etc.)
809                if !col.constraints.is_empty() {
810                    md.push_str("**Additional Constraints:**\n");
811                    for constraint in &col.constraints {
812                        md.push_str(&format!("- {}\n", constraint));
813                    }
814                    md.push('\n');
815                }
816
817                // Classification
818                if let Some(class) = &col.classification {
819                    md.push_str(&format!("**Classification:** {}\n\n", class));
820                }
821
822                // Encrypted name
823                if let Some(enc_name) = &col.encrypted_name {
824                    md.push_str(&format!("**Encrypted Name:** {}\n\n", enc_name));
825                }
826
827                // Logical Type Options
828                if let Some(opts) = &col.logical_type_options
829                    && !opts.is_empty()
830                {
831                    md.push_str("**Type Options:**\n");
832                    if let Some(min_len) = opts.min_length {
833                        md.push_str(&format!("- Min Length: {}\n", min_len));
834                    }
835                    if let Some(max_len) = opts.max_length {
836                        md.push_str(&format!("- Max Length: {}\n", max_len));
837                    }
838                    if let Some(pattern) = &opts.pattern {
839                        md.push_str(&format!("- Pattern: `{}`\n", pattern));
840                    }
841                    if let Some(format) = &opts.format {
842                        md.push_str(&format!("- Format: {}\n", format));
843                    }
844                    if let Some(min) = &opts.minimum {
845                        md.push_str(&format!("- Minimum: {}\n", min));
846                    }
847                    if let Some(max) = &opts.maximum {
848                        md.push_str(&format!("- Maximum: {}\n", max));
849                    }
850                    if let Some(exc_min) = &opts.exclusive_minimum {
851                        md.push_str(&format!("- Exclusive Minimum: {}\n", exc_min));
852                    }
853                    if let Some(exc_max) = &opts.exclusive_maximum {
854                        md.push_str(&format!("- Exclusive Maximum: {}\n", exc_max));
855                    }
856                    if let Some(prec) = opts.precision {
857                        md.push_str(&format!("- Precision: {}\n", prec));
858                    }
859                    if let Some(scale) = opts.scale {
860                        md.push_str(&format!("- Scale: {}\n", scale));
861                    }
862                    md.push('\n');
863                }
864
865                // Enum values
866                if !col.enum_values.is_empty() {
867                    md.push_str("**Allowed Values:**\n");
868                    for val in &col.enum_values {
869                        md.push_str(&format!("- `{}`\n", val));
870                    }
871                    md.push('\n');
872                }
873
874                // Examples
875                if !col.examples.is_empty() {
876                    let examples_str: Vec<String> =
877                        col.examples.iter().map(|v| v.to_string()).collect();
878                    md.push_str(&format!("**Examples:** {}\n\n", examples_str.join(", ")));
879                }
880
881                // Default value
882                if let Some(default) = &col.default_value {
883                    md.push_str(&format!("**Default:** {}\n\n", default));
884                }
885
886                // Transformation Metadata
887                if !col.transform_source_objects.is_empty()
888                    || col.transform_logic.is_some()
889                    || col.transform_description.is_some()
890                {
891                    md.push_str("**Transformation:**\n");
892                    if !col.transform_source_objects.is_empty() {
893                        md.push_str(&format!(
894                            "- Source Objects: {}\n",
895                            col.transform_source_objects.join(", ")
896                        ));
897                    }
898                    if let Some(logic) = &col.transform_logic {
899                        md.push_str(&format!("- Logic: `{}`\n", logic));
900                    }
901                    if let Some(desc) = &col.transform_description {
902                        md.push_str(&format!("- Description: {}\n", desc));
903                    }
904                    md.push('\n');
905                }
906
907                // Relationships
908                if !col.relationships.is_empty() {
909                    md.push_str("**Relationships:**\n");
910                    for rel in &col.relationships {
911                        md.push_str(&format!("- {} → {}\n", rel.relationship_type, rel.to));
912                    }
913                    md.push('\n');
914                }
915
916                // Foreign Key (legacy)
917                if let Some(fk) = &col.foreign_key {
918                    md.push_str(&format!(
919                        "**Foreign Key:** {}.{}\n\n",
920                        fk.table_id, fk.column_name
921                    ));
922                }
923
924                // Authoritative Definitions
925                if !col.authoritative_definitions.is_empty() {
926                    md.push_str("**Authoritative Definitions:**\n");
927                    for def in &col.authoritative_definitions {
928                        md.push_str(&format!("- {} - {}\n", def.definition_type, def.url));
929                    }
930                    md.push('\n');
931                }
932
933                // Column-level Quality Rules
934                if !col.quality.is_empty() {
935                    md.push_str("**Quality Rules:**\n");
936                    for rule in &col.quality {
937                        let rule_parts: Vec<String> =
938                            rule.iter().map(|(k, v)| format!("{}: {}", k, v)).collect();
939                        md.push_str(&format!("- {}\n", rule_parts.join(", ")));
940                    }
941                    md.push('\n');
942                }
943
944                // Column-level Tags
945                if !col.tags.is_empty() {
946                    let tag_strings: Vec<String> =
947                        col.tags.iter().map(|t| format!("`{}`", t)).collect();
948                    md.push_str(&format!("**Tags:** {}\n\n", tag_strings.join(" ")));
949                }
950            }
951        }
952
953        // SLA section
954        if let Some(sla) = &table.sla
955            && !sla.is_empty()
956        {
957            md.push_str("---\n\n## Service Level Agreements\n\n");
958            md.push_str("| Property | Value | Unit | Description |\n");
959            md.push_str("|----------|-------|------|-------------|\n");
960            for sla_prop in sla {
961                let desc = sla_prop
962                    .description
963                    .as_deref()
964                    .unwrap_or("")
965                    .replace('|', "/");
966                md.push_str(&format!(
967                    "| {} | {} | {} | {} |\n",
968                    sla_prop.property, sla_prop.value, sla_prop.unit, desc
969                ));
970            }
971            md.push('\n');
972        }
973
974        // Contact Details
975        if let Some(contact) = &table.contact_details {
976            md.push_str("---\n\n## Contact Information\n\n");
977            if let Some(name) = &contact.name {
978                md.push_str(&format!("- **Name:** {}\n", name));
979            }
980            if let Some(email) = &contact.email {
981                md.push_str(&format!("- **Email:** {}\n", email));
982            }
983            if let Some(role) = &contact.role {
984                md.push_str(&format!("- **Role:** {}\n", role));
985            }
986            if let Some(phone) = &contact.phone {
987                md.push_str(&format!("- **Phone:** {}\n", phone));
988            }
989            md.push('\n');
990        }
991
992        // Quality Rules
993        if !table.quality.is_empty() {
994            md.push_str("---\n\n## Quality Rules\n\n");
995            for (i, rule) in table.quality.iter().enumerate() {
996                md.push_str(&format!("**Rule {}:**\n", i + 1));
997                for (key, value) in rule {
998                    md.push_str(&format!("- {}: {}\n", key, value));
999                }
1000                md.push('\n');
1001            }
1002        }
1003
1004        // ODCS Metadata (legacy format fields preserved from import)
1005        if !table.odcl_metadata.is_empty() {
1006            md.push_str("---\n\n## ODCS Contract Metadata\n\n");
1007            // Sort keys for consistent output
1008            let mut keys: Vec<_> = table.odcl_metadata.keys().collect();
1009            keys.sort();
1010            for key in keys {
1011                if let Some(value) = table.odcl_metadata.get(key) {
1012                    // Format the value based on its type
1013                    let formatted = match value {
1014                        serde_json::Value::String(s) => s.clone(),
1015                        serde_json::Value::Array(arr) => {
1016                            let items: Vec<String> = arr.iter().map(|v| v.to_string()).collect();
1017                            items.join(", ")
1018                        }
1019                        serde_json::Value::Object(_) => {
1020                            // For nested objects, show as formatted JSON-like structure
1021                            serde_json::to_string_pretty(value)
1022                                .unwrap_or_else(|_| value.to_string())
1023                        }
1024                        _ => value.to_string(),
1025                    };
1026                    md.push_str(&format!("- **{}:** {}\n", key, formatted));
1027                }
1028            }
1029            md.push('\n');
1030        }
1031
1032        // Tags
1033        if !table.tags.is_empty() {
1034            md.push_str("---\n\n");
1035            let tag_strings: Vec<String> = table.tags.iter().map(|t| format!("`{}`", t)).collect();
1036            md.push_str(&format!("**Tags:** {}\n\n", tag_strings.join(" ")));
1037        }
1038
1039        // Timestamps
1040        md.push_str("---\n\n");
1041        md.push_str(&format!(
1042            "*Created: {} | Last Updated: {}*\n\n",
1043            table.created_at.format("%Y-%m-%d %H:%M UTC"),
1044            table.updated_at.format("%Y-%m-%d %H:%M UTC")
1045        ));
1046
1047        md
1048    }
1049
1050    /// Convert ODPS Data Product to properly formatted GFM markdown for PDF rendering
1051    fn data_product_to_markdown(&self, product: &crate::models::odps::ODPSDataProduct) -> String {
1052        use crate::models::odps::ODPSStatus;
1053
1054        let mut md = String::new();
1055
1056        // Main title
1057        let title = product.name.as_deref().unwrap_or(&product.id);
1058        md.push_str(&format!("# {}\n\n", title));
1059
1060        // Metadata table
1061        let status_text = match product.status {
1062            ODPSStatus::Proposed => "Proposed",
1063            ODPSStatus::Draft => "Draft",
1064            ODPSStatus::Active => "Active",
1065            ODPSStatus::Deprecated => "Deprecated",
1066            ODPSStatus::Retired => "Retired",
1067        };
1068
1069        md.push_str("| Property | Value |\n");
1070        md.push_str("|----------|-------|\n");
1071        md.push_str(&format!("| **ID** | {} |\n", product.id));
1072        md.push_str(&format!("| **Status** | {} |\n", status_text));
1073        md.push_str(&format!("| **API Version** | {} |\n", product.api_version));
1074
1075        if let Some(version) = &product.version {
1076            md.push_str(&format!("| **Version** | {} |\n", version));
1077        }
1078
1079        if let Some(domain) = &product.domain {
1080            md.push_str(&format!("| **Domain** | {} |\n", domain));
1081        }
1082
1083        if let Some(tenant) = &product.tenant {
1084            md.push_str(&format!("| **Tenant** | {} |\n", tenant));
1085        }
1086
1087        md.push_str("\n---\n\n");
1088
1089        // Description section
1090        if let Some(desc) = &product.description {
1091            md.push_str("## Description\n\n");
1092            if let Some(purpose) = &desc.purpose {
1093                md.push_str(&format!("**Purpose:** {}\n\n", purpose));
1094            }
1095            if let Some(usage) = &desc.usage {
1096                md.push_str(&format!("**Usage:** {}\n\n", usage));
1097            }
1098            if let Some(limitations) = &desc.limitations {
1099                md.push_str(&format!("**Limitations:** {}\n\n", limitations));
1100            }
1101            md.push_str("---\n\n");
1102        }
1103
1104        // Input Ports
1105        if let Some(input_ports) = &product.input_ports
1106            && !input_ports.is_empty()
1107        {
1108            md.push_str("## Input Ports\n\n");
1109            md.push_str("| Name | Version | Contract ID |\n");
1110            md.push_str("|------|---------|-------------|\n");
1111            for port in input_ports {
1112                md.push_str(&format!(
1113                    "| {} | {} | {} |\n",
1114                    port.name, port.version, port.contract_id
1115                ));
1116            }
1117            md.push('\n');
1118        }
1119
1120        // Output Ports
1121        if let Some(output_ports) = &product.output_ports
1122            && !output_ports.is_empty()
1123        {
1124            md.push_str("## Output Ports\n\n");
1125            md.push_str("| Name | Version | Type | Contract ID |\n");
1126            md.push_str("|------|---------|------|-------------|\n");
1127            for port in output_ports {
1128                let port_type = port.r#type.as_deref().unwrap_or("-");
1129                let contract = port.contract_id.as_deref().unwrap_or("-");
1130                md.push_str(&format!(
1131                    "| {} | {} | {} | {} |\n",
1132                    port.name, port.version, port_type, contract
1133                ));
1134            }
1135            md.push('\n');
1136
1137            // Output port details
1138            for port in output_ports {
1139                if port.description.is_some()
1140                    || port.sbom.is_some()
1141                    || port.input_contracts.is_some()
1142                {
1143                    md.push_str(&format!("### {}\n\n", port.name));
1144                    if let Some(desc) = &port.description {
1145                        md.push_str(&format!("{}\n\n", desc));
1146                    }
1147                    if let Some(sbom) = &port.sbom
1148                        && !sbom.is_empty()
1149                    {
1150                        md.push_str("**SBOM:**\n");
1151                        for s in sbom {
1152                            let stype = s.r#type.as_deref().unwrap_or("unknown");
1153                            md.push_str(&format!("- {} ({})\n", s.url, stype));
1154                        }
1155                        md.push('\n');
1156                    }
1157                    if let Some(contracts) = &port.input_contracts
1158                        && !contracts.is_empty()
1159                    {
1160                        md.push_str("**Input Contracts:**\n");
1161                        for c in contracts {
1162                            md.push_str(&format!("- {} v{}\n", c.id, c.version));
1163                        }
1164                        md.push('\n');
1165                    }
1166                }
1167            }
1168        }
1169
1170        // Management Ports
1171        if let Some(mgmt_ports) = &product.management_ports
1172            && !mgmt_ports.is_empty()
1173        {
1174            md.push_str("## Management Ports\n\n");
1175            md.push_str("| Name | Type | Content |\n");
1176            md.push_str("|------|------|--------|\n");
1177            for port in mgmt_ports {
1178                let port_type = port.r#type.as_deref().unwrap_or("-");
1179                md.push_str(&format!(
1180                    "| {} | {} | {} |\n",
1181                    port.name, port_type, port.content
1182                ));
1183            }
1184            md.push('\n');
1185        }
1186
1187        // Support Channels
1188        if let Some(support) = &product.support
1189            && !support.is_empty()
1190        {
1191            md.push_str("## Support Channels\n\n");
1192            md.push_str("| Channel | URL | Description |\n");
1193            md.push_str("|---------|-----|-------------|\n");
1194            for s in support {
1195                let desc = s.description.as_deref().unwrap_or("-").replace('|', "/");
1196                md.push_str(&format!("| {} | {} | {} |\n", s.channel, s.url, desc));
1197            }
1198            md.push('\n');
1199        }
1200
1201        // Team
1202        if let Some(team) = &product.team {
1203            md.push_str("## Team\n\n");
1204            if let Some(name) = &team.name {
1205                md.push_str(&format!("**Team Name:** {}\n\n", name));
1206            }
1207            if let Some(desc) = &team.description {
1208                md.push_str(&format!("{}\n\n", desc));
1209            }
1210            if let Some(members) = &team.members
1211                && !members.is_empty()
1212            {
1213                md.push_str("### Team Members\n\n");
1214                md.push_str("| Username | Name | Role |\n");
1215                md.push_str("|----------|------|------|\n");
1216                for member in members {
1217                    let name = member.name.as_deref().unwrap_or("-");
1218                    let role = member.role.as_deref().unwrap_or("-");
1219                    md.push_str(&format!("| {} | {} | {} |\n", member.username, name, role));
1220                }
1221                md.push('\n');
1222            }
1223        }
1224
1225        // Tags
1226        if !product.tags.is_empty() {
1227            md.push_str("---\n\n");
1228            let tag_strings: Vec<String> =
1229                product.tags.iter().map(|t| format!("`{}`", t)).collect();
1230            md.push_str(&format!("**Tags:** {}\n\n", tag_strings.join(" ")));
1231        }
1232
1233        // Timestamps
1234        if product.created_at.is_some() || product.updated_at.is_some() {
1235            md.push_str("---\n\n");
1236            if let Some(created) = &product.created_at {
1237                md.push_str(&format!(
1238                    "*Created: {}",
1239                    created.format("%Y-%m-%d %H:%M UTC")
1240                ));
1241                if let Some(updated) = &product.updated_at {
1242                    md.push_str(&format!(
1243                        " | Last Updated: {}",
1244                        updated.format("%Y-%m-%d %H:%M UTC")
1245                    ));
1246                }
1247                md.push_str("*\n\n");
1248            } else if let Some(updated) = &product.updated_at {
1249                md.push_str(&format!(
1250                    "*Last Updated: {}*\n\n",
1251                    updated.format("%Y-%m-%d %H:%M UTC")
1252                ));
1253            }
1254        }
1255
1256        md
1257    }
1258
1259    /// Convert CADS Asset to properly formatted GFM markdown for PDF rendering
1260    fn cads_asset_to_markdown(&self, asset: &crate::models::cads::CADSAsset) -> String {
1261        use crate::models::cads::{CADSKind, CADSStatus};
1262
1263        let mut md = String::new();
1264
1265        // Main title
1266        md.push_str(&format!("# {}\n\n", asset.name));
1267
1268        // Metadata table
1269        let kind_text = match asset.kind {
1270            CADSKind::AIModel => "AI Model",
1271            CADSKind::MLPipeline => "ML Pipeline",
1272            CADSKind::Application => "Application",
1273            CADSKind::DataPipeline => "Data Pipeline",
1274            CADSKind::ETLProcess => "ETL Process",
1275            CADSKind::ETLPipeline => "ETL Pipeline",
1276            CADSKind::SourceSystem => "Source System",
1277            CADSKind::DestinationSystem => "Destination System",
1278        };
1279
1280        let status_text = match asset.status {
1281            CADSStatus::Draft => "Draft",
1282            CADSStatus::Validated => "Validated",
1283            CADSStatus::Production => "Production",
1284            CADSStatus::Deprecated => "Deprecated",
1285        };
1286
1287        md.push_str("| Property | Value |\n");
1288        md.push_str("|----------|-------|\n");
1289        md.push_str(&format!("| **ID** | {} |\n", asset.id));
1290        md.push_str(&format!("| **Kind** | {} |\n", kind_text));
1291        md.push_str(&format!("| **Version** | {} |\n", asset.version));
1292        md.push_str(&format!("| **Status** | {} |\n", status_text));
1293        md.push_str(&format!("| **API Version** | {} |\n", asset.api_version));
1294
1295        if let Some(domain) = &asset.domain {
1296            md.push_str(&format!("| **Domain** | {} |\n", domain));
1297        }
1298
1299        md.push_str("\n---\n\n");
1300
1301        // Description section
1302        if let Some(desc) = &asset.description {
1303            md.push_str("## Description\n\n");
1304            if let Some(purpose) = &desc.purpose {
1305                md.push_str(&format!("**Purpose:** {}\n\n", purpose));
1306            }
1307            if let Some(usage) = &desc.usage {
1308                md.push_str(&format!("**Usage:** {}\n\n", usage));
1309            }
1310            if let Some(limitations) = &desc.limitations {
1311                md.push_str(&format!("**Limitations:** {}\n\n", limitations));
1312            }
1313            if let Some(links) = &desc.external_links
1314                && !links.is_empty()
1315            {
1316                md.push_str("**External Links:**\n");
1317                for link in links {
1318                    let desc = link.description.as_deref().unwrap_or("");
1319                    md.push_str(&format!("- {} {}\n", link.url, desc));
1320                }
1321                md.push('\n');
1322            }
1323            md.push_str("---\n\n");
1324        }
1325
1326        // Runtime section
1327        if let Some(runtime) = &asset.runtime {
1328            md.push_str("## Runtime\n\n");
1329            if let Some(env) = &runtime.environment {
1330                md.push_str(&format!("**Environment:** {}\n\n", env));
1331            }
1332            if let Some(endpoints) = &runtime.endpoints
1333                && !endpoints.is_empty()
1334            {
1335                md.push_str("**Endpoints:**\n");
1336                for ep in endpoints {
1337                    md.push_str(&format!("- {}\n", ep));
1338                }
1339                md.push('\n');
1340            }
1341            if let Some(container) = &runtime.container
1342                && let Some(image) = &container.image
1343            {
1344                md.push_str(&format!("**Container Image:** {}\n\n", image));
1345            }
1346            if let Some(resources) = &runtime.resources {
1347                md.push_str("**Resources:**\n");
1348                if let Some(cpu) = &resources.cpu {
1349                    md.push_str(&format!("- CPU: {}\n", cpu));
1350                }
1351                if let Some(memory) = &resources.memory {
1352                    md.push_str(&format!("- Memory: {}\n", memory));
1353                }
1354                if let Some(gpu) = &resources.gpu {
1355                    md.push_str(&format!("- GPU: {}\n", gpu));
1356                }
1357                md.push('\n');
1358            }
1359        }
1360
1361        // SLA section
1362        if let Some(sla) = &asset.sla
1363            && let Some(props) = &sla.properties
1364            && !props.is_empty()
1365        {
1366            md.push_str("## Service Level Agreements\n\n");
1367            md.push_str("| Element | Value | Unit | Driver |\n");
1368            md.push_str("|---------|-------|------|--------|\n");
1369            for prop in props {
1370                let driver = prop.driver.as_deref().unwrap_or("-");
1371                md.push_str(&format!(
1372                    "| {} | {} | {} | {} |\n",
1373                    prop.element, prop.value, prop.unit, driver
1374                ));
1375            }
1376            md.push('\n');
1377        }
1378
1379        // Pricing section
1380        if let Some(pricing) = &asset.pricing {
1381            md.push_str("## Pricing\n\n");
1382            if let Some(model) = &pricing.model {
1383                md.push_str(&format!("**Model:** {:?}\n\n", model));
1384            }
1385            if let Some(currency) = &pricing.currency
1386                && let Some(cost) = pricing.unit_cost
1387            {
1388                let unit = pricing.billing_unit.as_deref().unwrap_or("unit");
1389                md.push_str(&format!("**Cost:** {} {} per {}\n\n", cost, currency, unit));
1390            }
1391            if let Some(notes) = &pricing.notes {
1392                md.push_str(&format!("**Notes:** {}\n\n", notes));
1393            }
1394        }
1395
1396        // Team section
1397        if let Some(team) = &asset.team
1398            && !team.is_empty()
1399        {
1400            md.push_str("## Team\n\n");
1401            md.push_str("| Role | Name | Contact |\n");
1402            md.push_str("|------|------|--------|\n");
1403            for member in team {
1404                let contact = member.contact.as_deref().unwrap_or("-");
1405                md.push_str(&format!(
1406                    "| {} | {} | {} |\n",
1407                    member.role, member.name, contact
1408                ));
1409            }
1410            md.push('\n');
1411        }
1412
1413        // Risk section
1414        if let Some(risk) = &asset.risk {
1415            md.push_str("## Risk Management\n\n");
1416            if let Some(classification) = &risk.classification {
1417                md.push_str(&format!("**Classification:** {:?}\n\n", classification));
1418            }
1419            if let Some(areas) = &risk.impact_areas
1420                && !areas.is_empty()
1421            {
1422                let areas_str: Vec<String> = areas.iter().map(|a| format!("{:?}", a)).collect();
1423                md.push_str(&format!("**Impact Areas:** {}\n\n", areas_str.join(", ")));
1424            }
1425            if let Some(intended) = &risk.intended_use {
1426                md.push_str(&format!("**Intended Use:** {}\n\n", intended));
1427            }
1428            if let Some(out_of_scope) = &risk.out_of_scope_use {
1429                md.push_str(&format!("**Out of Scope:** {}\n\n", out_of_scope));
1430            }
1431            if let Some(mitigations) = &risk.mitigations
1432                && !mitigations.is_empty()
1433            {
1434                md.push_str("**Mitigations:**\n");
1435                for m in mitigations {
1436                    md.push_str(&format!("- {} ({:?})\n", m.description, m.status));
1437                }
1438                md.push('\n');
1439            }
1440        }
1441
1442        // Compliance section
1443        if let Some(compliance) = &asset.compliance {
1444            md.push_str("## Compliance\n\n");
1445            if let Some(frameworks) = &compliance.frameworks
1446                && !frameworks.is_empty()
1447            {
1448                md.push_str("### Frameworks\n\n");
1449                md.push_str("| Name | Category | Status |\n");
1450                md.push_str("|------|----------|--------|\n");
1451                for fw in frameworks {
1452                    let cat = fw.category.as_deref().unwrap_or("-");
1453                    md.push_str(&format!("| {} | {} | {:?} |\n", fw.name, cat, fw.status));
1454                }
1455                md.push('\n');
1456            }
1457            if let Some(controls) = &compliance.controls
1458                && !controls.is_empty()
1459            {
1460                md.push_str("### Controls\n\n");
1461                md.push_str("| ID | Description |\n");
1462                md.push_str("|----|-------------|\n");
1463                for ctrl in controls {
1464                    md.push_str(&format!("| {} | {} |\n", ctrl.id, ctrl.description));
1465                }
1466                md.push('\n');
1467            }
1468        }
1469
1470        // Tags
1471        if !asset.tags.is_empty() {
1472            md.push_str("---\n\n");
1473            let tag_strings: Vec<String> = asset.tags.iter().map(|t| format!("`{}`", t)).collect();
1474            md.push_str(&format!("**Tags:** {}\n\n", tag_strings.join(" ")));
1475        }
1476
1477        // Timestamps
1478        if asset.created_at.is_some() || asset.updated_at.is_some() {
1479            md.push_str("---\n\n");
1480            if let Some(created) = &asset.created_at {
1481                md.push_str(&format!(
1482                    "*Created: {}",
1483                    created.format("%Y-%m-%d %H:%M UTC")
1484                ));
1485                if let Some(updated) = &asset.updated_at {
1486                    md.push_str(&format!(
1487                        " | Last Updated: {}",
1488                        updated.format("%Y-%m-%d %H:%M UTC")
1489                    ));
1490                }
1491                md.push_str("*\n\n");
1492            } else if let Some(updated) = &asset.updated_at {
1493                md.push_str(&format!(
1494                    "*Last Updated: {}*\n\n",
1495                    updated.format("%Y-%m-%d %H:%M UTC")
1496                ));
1497            }
1498        }
1499
1500        md
1501    }
1502
1503    /// Generate PDF from markdown content
1504    fn generate_pdf(
1505        &self,
1506        title: &str,
1507        markdown: &str,
1508        filename: &str,
1509        doc_type: &str,
1510    ) -> Result<PdfExportResult, ExportError> {
1511        let pdf_content = self.create_pdf_document(title, markdown, doc_type)?;
1512        let pdf_base64 =
1513            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &pdf_content);
1514
1515        // Estimate page count based on content length
1516        let chars_per_page = 3000;
1517        let page_count = std::cmp::max(1, (markdown.len() / chars_per_page) as u32 + 1);
1518
1519        Ok(PdfExportResult {
1520            pdf_base64,
1521            filename: filename.to_string(),
1522            page_count,
1523            title: title.to_string(),
1524        })
1525    }
1526
1527    /// Create a PDF document with proper GFM rendering and multi-page support
1528    fn create_pdf_document(
1529        &self,
1530        title: &str,
1531        markdown: &str,
1532        doc_type: &str,
1533    ) -> Result<Vec<u8>, ExportError> {
1534        let (width, height) = self.branding.page_size.dimensions_mm();
1535        let width_pt = width * 2.83465;
1536        let height_pt = height * 2.83465;
1537
1538        // Generate all page content streams
1539        let page_streams =
1540            self.render_markdown_to_pdf_pages(title, markdown, width_pt, height_pt, doc_type);
1541        let page_count = page_streams.len();
1542
1543        let mut pdf = Vec::new();
1544
1545        // PDF Header
1546        pdf.extend_from_slice(b"%PDF-1.4\n");
1547        pdf.extend_from_slice(b"%\xE2\xE3\xCF\xD3\n");
1548
1549        let mut xref_positions: Vec<usize> = Vec::new();
1550
1551        // Object 1: Catalog
1552        xref_positions.push(pdf.len());
1553        pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
1554
1555        // Object 2: Pages - will be written later after we know all page refs
1556        let pages_obj_position = xref_positions.len();
1557        xref_positions.push(0); // Placeholder, will update
1558
1559        // For each page, we need: Page object + Content stream object
1560        // Object numbering: 3,4 for page 1; 5,6 for page 2; etc.
1561        // Then fonts start after all pages
1562        let mut page_obj_ids: Vec<usize> = Vec::new();
1563        let font_obj_start = 3 + (page_count * 2); // First font object ID
1564
1565        for (page_idx, content_stream) in page_streams.iter().enumerate() {
1566            let page_obj_id = 3 + (page_idx * 2);
1567            let content_obj_id = page_obj_id + 1;
1568            page_obj_ids.push(page_obj_id);
1569
1570            // Page object
1571            xref_positions.push(pdf.len());
1572            let page_obj = format!(
1573                "{} 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",
1574                page_obj_id,
1575                width_pt,
1576                height_pt,
1577                content_obj_id,
1578                font_obj_start,
1579                font_obj_start + 1
1580            );
1581            pdf.extend_from_slice(page_obj.as_bytes());
1582
1583            // Content stream object
1584            xref_positions.push(pdf.len());
1585            let content_obj = format!(
1586                "{} 0 obj\n<< /Length {} >>\nstream\n{}\nendstream\nendobj\n",
1587                content_obj_id,
1588                content_stream.len(),
1589                content_stream
1590            );
1591            pdf.extend_from_slice(content_obj.as_bytes());
1592        }
1593
1594        // Now write the Pages object with correct kids list
1595        let pages_position = pdf.len();
1596        let kids_list: Vec<String> = page_obj_ids
1597            .iter()
1598            .map(|id| format!("{} 0 R", id))
1599            .collect();
1600        let pages_obj = format!(
1601            "2 0 obj\n<< /Type /Pages /Kids [{}] /Count {} >>\nendobj\n",
1602            kids_list.join(" "),
1603            page_count
1604        );
1605        pdf.extend_from_slice(pages_obj.as_bytes());
1606        xref_positions[pages_obj_position] = pages_position;
1607
1608        // Font objects
1609        xref_positions.push(pdf.len());
1610        let font1_obj = format!(
1611            "{} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>\nendobj\n",
1612            font_obj_start
1613        );
1614        pdf.extend_from_slice(font1_obj.as_bytes());
1615
1616        xref_positions.push(pdf.len());
1617        let font2_obj = format!(
1618            "{} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>\nendobj\n",
1619            font_obj_start + 1
1620        );
1621        pdf.extend_from_slice(font2_obj.as_bytes());
1622
1623        // Info dictionary
1624        let info_obj_id = font_obj_start + 2;
1625        xref_positions.push(pdf.len());
1626        let timestamp = if self.branding.show_timestamp {
1627            Utc::now().format("D:%Y%m%d%H%M%S").to_string()
1628        } else {
1629            String::new()
1630        };
1631
1632        let escaped_title = self.escape_pdf_string(title);
1633        let producer = "Open Data Modelling SDK";
1634        let company = self
1635            .branding
1636            .company_name
1637            .as_deref()
1638            .unwrap_or("opendatamodelling.com");
1639
1640        let info_obj = format!(
1641            "{} 0 obj\n<< /Title ({}) /Producer ({}) /Creator ({}) /CreationDate ({}) >>\nendobj\n",
1642            info_obj_id, escaped_title, producer, company, timestamp
1643        );
1644        pdf.extend_from_slice(info_obj.as_bytes());
1645
1646        // Cross-reference table
1647        let xref_start = pdf.len();
1648        pdf.extend_from_slice(b"xref\n");
1649        pdf.extend_from_slice(format!("0 {}\n", xref_positions.len() + 1).as_bytes());
1650        pdf.extend_from_slice(b"0000000000 65535 f \n");
1651        for pos in &xref_positions {
1652            pdf.extend_from_slice(format!("{:010} 00000 n \n", pos).as_bytes());
1653        }
1654
1655        // Trailer
1656        pdf.extend_from_slice(b"trailer\n");
1657        pdf.extend_from_slice(
1658            format!(
1659                "<< /Size {} /Root 1 0 R /Info {} 0 R >>\n",
1660                xref_positions.len() + 1,
1661                info_obj_id
1662            )
1663            .as_bytes(),
1664        );
1665        pdf.extend_from_slice(b"startxref\n");
1666        pdf.extend_from_slice(format!("{}\n", xref_start).as_bytes());
1667        pdf.extend_from_slice(b"%%EOF\n");
1668
1669        Ok(pdf)
1670    }
1671
1672    /// Render markdown to PDF content streams with proper formatting (multi-page)
1673    fn render_markdown_to_pdf_pages(
1674        &self,
1675        title: &str,
1676        markdown: &str,
1677        width: f64,
1678        height: f64,
1679        doc_type: &str,
1680    ) -> Vec<String> {
1681        let mut pages: Vec<String> = Vec::new();
1682        let mut stream = String::new();
1683        let margin = 50.0;
1684        let footer_height = 40.0; // Reserve space for footer
1685        let header_height = 100.0; // Reserve space for header/logo/title/doc type
1686        let body_font_size = self.branding.font_size as f64;
1687        let line_height = body_font_size * 1.4;
1688        let max_width = width - (2.0 * margin);
1689        let mut page_num = 1;
1690
1691        // === HEADER SECTION WITH LOGO ===
1692
1693        // Draw the logo circle with gradient-like effect (blue circle)
1694        let logo_cx = margin + 15.0;
1695        let logo_cy = height - margin - 10.0;
1696        let logo_r = 12.0;
1697
1698        // Draw filled blue circle
1699        stream.push_str("q\n");
1700        stream.push_str("0 0.4 0.8 rg\n"); // RGB for #0066CC
1701        stream.push_str(&format!("{:.2} {:.2} m\n", logo_cx + logo_r, logo_cy));
1702        // Approximate circle with bezier curves
1703        let k = 0.5523; // bezier constant for circle
1704        stream.push_str(&format!(
1705            "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n",
1706            logo_cx + logo_r,
1707            logo_cy + logo_r * k,
1708            logo_cx + logo_r * k,
1709            logo_cy + logo_r,
1710            logo_cx,
1711            logo_cy + logo_r
1712        ));
1713        stream.push_str(&format!(
1714            "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n",
1715            logo_cx - logo_r * k,
1716            logo_cy + logo_r,
1717            logo_cx - logo_r,
1718            logo_cy + logo_r * k,
1719            logo_cx - logo_r,
1720            logo_cy
1721        ));
1722        stream.push_str(&format!(
1723            "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n",
1724            logo_cx - logo_r,
1725            logo_cy - logo_r * k,
1726            logo_cx - logo_r * k,
1727            logo_cy - logo_r,
1728            logo_cx,
1729            logo_cy - logo_r
1730        ));
1731        stream.push_str(&format!(
1732            "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n",
1733            logo_cx + logo_r * k,
1734            logo_cy - logo_r,
1735            logo_cx + logo_r,
1736            logo_cy - logo_r * k,
1737            logo_cx + logo_r,
1738            logo_cy
1739        ));
1740        stream.push_str("f\n"); // Fill the circle
1741        stream.push_str("Q\n");
1742
1743        // Draw white cross inside the circle
1744        stream.push_str("q\n");
1745        stream.push_str("1 1 1 RG\n"); // White stroke
1746        stream.push_str("2 w\n"); // Line width
1747        stream.push_str("1 J\n"); // Round line cap
1748        // Vertical line
1749        stream.push_str(&format!(
1750            "{:.2} {:.2} m\n{:.2} {:.2} l\nS\n",
1751            logo_cx,
1752            logo_cy - logo_r * 0.6,
1753            logo_cx,
1754            logo_cy + logo_r * 0.6
1755        ));
1756        // Horizontal line
1757        stream.push_str(&format!(
1758            "{:.2} {:.2} m\n{:.2} {:.2} l\nS\n",
1759            logo_cx - logo_r * 0.6,
1760            logo_cy,
1761            logo_cx + logo_r * 0.6,
1762            logo_cy
1763        ));
1764        stream.push_str("Q\n");
1765
1766        // Render "Open Data Modelling" text next to logo
1767        stream.push_str("BT\n");
1768        let logo_text_x = margin + 35.0;
1769        let logo_text_y = height - margin - 5.0;
1770        stream.push_str("/F2 11 Tf\n"); // Bold font
1771        stream.push_str(&format!("{:.2} {:.2} Td\n", logo_text_x, logo_text_y));
1772        stream.push_str("(Open Data) Tj\n");
1773        stream.push_str(&format!("0 {:.2} Td\n", -12.0));
1774        stream.push_str("(Modelling) Tj\n");
1775        stream.push_str("ET\n");
1776
1777        // Draw header line below logo
1778        let header_line_y = height - margin - 30.0;
1779        stream.push_str(&format!(
1780            "q\n0.7 G\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
1781            margin,
1782            header_line_y,
1783            width - margin,
1784            header_line_y
1785        ));
1786
1787        // === DOCUMENT TYPE TITLE (e.g., "DECISION RECORD" or "KNOWLEDGE BASE") ===
1788        stream.push_str("BT\n");
1789        let doc_type_y = height - margin - 48.0;
1790        stream.push_str("/F2 12 Tf\n"); // Bold font for document type
1791        stream.push_str("0.3 0.3 0.3 rg\n"); // Dark gray color
1792        stream.push_str(&format!("{:.2} {:.2} Td\n", margin, doc_type_y));
1793        stream.push_str(&format!(
1794            "({}) Tj\n",
1795            self.escape_pdf_string(&doc_type.to_uppercase())
1796        ));
1797        stream.push_str("ET\n");
1798
1799        // === DOCUMENT TITLE ===
1800        stream.push_str("BT\n");
1801        stream.push_str("0 0 0 rg\n"); // Black color for title
1802        let title_y = height - margin - 68.0;
1803        stream.push_str("/F2 16 Tf\n"); // Bold, large font for title
1804        stream.push_str(&format!("{:.2} {:.2} Td\n", margin, title_y));
1805        stream.push_str(&format!("({}) Tj\n", self.escape_pdf_string(title)));
1806        stream.push_str("ET\n");
1807
1808        // Note: Footer is rendered by render_page_header_footer closure for all pages
1809        // This ensures consistent footer across all pages including page numbers
1810
1811        // === BODY CONTENT ===
1812        let content_top = height - margin - header_height;
1813        let content_bottom = margin + footer_height;
1814        let mut y_pos = content_top;
1815        let mut in_table = false;
1816        let mut in_code_block = false;
1817
1818        // Helper closure to render footer on each page
1819        let render_page_header_footer =
1820            |stream: &mut String,
1821             page_num: u32,
1822             width: f64,
1823             height: f64,
1824             margin: f64,
1825             footer_height: f64| {
1826                // Draw logo on continuation pages (smaller, simpler)
1827                if page_num > 1 {
1828                    // Small logo indicator
1829                    stream.push_str("BT\n");
1830                    stream.push_str("/F2 9 Tf\n");
1831                    stream.push_str("0.3 0.3 0.3 rg\n");
1832                    stream.push_str(&format!(
1833                        "1 0 0 1 {:.2} {:.2} Tm\n",
1834                        margin,
1835                        height - margin - 10.0
1836                    ));
1837                    stream.push_str("(Open Data Modelling) Tj\n");
1838                    stream.push_str("ET\n");
1839                }
1840
1841                // Footer line
1842                let footer_line_y = margin + footer_height - 10.0;
1843                stream.push_str(&format!(
1844                    "q\n0.3 G\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
1845                    margin,
1846                    footer_line_y,
1847                    width - margin,
1848                    footer_line_y
1849                ));
1850
1851                let footer_y = margin + 15.0;
1852
1853                // Copyright text on the left (use octal \251 for © symbol)
1854                stream.push_str("BT\n");
1855                stream.push_str("/F1 9 Tf\n");
1856                stream.push_str("0 0 0 rg\n");
1857                stream.push_str(&format!("1 0 0 1 {:.2} {:.2} Tm\n", margin, footer_y));
1858                stream.push_str("(\\251 opendatamodelling.com) Tj\n");
1859                stream.push_str("ET\n");
1860
1861                // Page number on the right
1862                stream.push_str("BT\n");
1863                stream.push_str("/F1 9 Tf\n");
1864                stream.push_str("0 0 0 rg\n");
1865                stream.push_str(&format!(
1866                    "1 0 0 1 {:.2} {:.2} Tm\n",
1867                    width - margin - 40.0,
1868                    footer_y
1869                ));
1870                stream.push_str(&format!("(Page {}) Tj\n", page_num));
1871                stream.push_str("ET\n");
1872            };
1873
1874        for line in markdown.lines() {
1875            // Check if we need a new page
1876            if y_pos < content_bottom + line_height {
1877                // Render footer on current page
1878                render_page_header_footer(
1879                    &mut stream,
1880                    page_num,
1881                    width,
1882                    height,
1883                    margin,
1884                    footer_height,
1885                );
1886
1887                // Save current page and start new one
1888                pages.push(stream);
1889                stream = String::new();
1890                page_num += 1;
1891                y_pos = height - margin - 30.0; // Start content higher on continuation pages
1892            }
1893
1894            let trimmed = line.trim();
1895
1896            // Handle code blocks
1897            if trimmed.starts_with("```") {
1898                in_code_block = !in_code_block;
1899                y_pos -= line_height * 0.5;
1900                continue;
1901            }
1902
1903            if in_code_block {
1904                // Draw dark background for code block line
1905                let code_bg_padding = 3.0;
1906                let code_line_height = line_height * 0.9;
1907                stream.push_str("q\n");
1908                stream.push_str("0.15 0.15 0.15 rg\n"); // Dark gray background (#262626)
1909                stream.push_str(&format!(
1910                    "{:.2} {:.2} {:.2} {:.2} re f\n",
1911                    margin + 15.0,
1912                    y_pos - code_bg_padding,
1913                    max_width - 15.0,
1914                    code_line_height + code_bg_padding
1915                ));
1916                stream.push_str("Q\n");
1917
1918                // Render code with light text using absolute positioning
1919                stream.push_str("BT\n");
1920                let code_font_size = body_font_size - 1.0;
1921                stream.push_str(&format!("/F1 {:.1} Tf\n", code_font_size));
1922                stream.push_str("0.9 0.9 0.9 rg\n"); // Light gray text for code
1923                stream.push_str(&format!("1 0 0 1 {:.2} {:.2} Tm\n", margin + 20.0, y_pos));
1924                stream.push_str(&format!("({}) Tj\n", self.escape_pdf_string(line)));
1925                stream.push_str("ET\n");
1926                y_pos -= code_line_height;
1927                continue;
1928            }
1929
1930            // Skip image references and markdown footer (already rendered)
1931            if trimmed.starts_with("![") {
1932                continue;
1933            }
1934
1935            // Skip the copyright line in content (already in footer)
1936            if trimmed.starts_with("©") || trimmed == DEFAULT_COPYRIGHT {
1937                continue;
1938            }
1939
1940            // Handle horizontal rules
1941            if trimmed == "---" || trimmed == "***" || trimmed == "___" {
1942                y_pos -= line_height * 0.3;
1943                // Draw a line
1944                stream.push_str(&format!(
1945                    "q\n0.7 G\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
1946                    margin,
1947                    y_pos,
1948                    width - margin,
1949                    y_pos
1950                ));
1951                y_pos -= line_height * 0.5;
1952                continue;
1953            }
1954
1955            // Handle table rows
1956            if trimmed.starts_with("|") && trimmed.ends_with("|") {
1957                // Skip separator rows
1958                if trimmed.contains("---") {
1959                    in_table = true;
1960                    continue;
1961                }
1962
1963                let cells: Vec<&str> = trimmed
1964                    .trim_matches('|')
1965                    .split('|')
1966                    .map(|s| s.trim())
1967                    .collect();
1968
1969                let cell_width = max_width / cells.len() as f64;
1970
1971                // Calculate max chars per cell based on cell width and font size
1972                let font_size = if in_table {
1973                    body_font_size - 1.0
1974                } else {
1975                    body_font_size
1976                };
1977                // Approximate character width for Helvetica
1978                // Using a conservative factor to ensure text fits
1979                let char_width_factor = 0.45;
1980                let max_chars_per_line =
1981                    ((cell_width - 10.0) / (font_size * char_width_factor)) as usize;
1982                let max_chars_per_line = max_chars_per_line.max(10); // Minimum 10 chars per line
1983
1984                // Word-wrap each cell and find maximum number of lines needed
1985                let mut wrapped_cells: Vec<(Vec<String>, bool)> = Vec::new();
1986                let mut max_lines = 1usize;
1987
1988                for cell in &cells {
1989                    // Check if cell content is bold
1990                    let (text, is_bold) = if cell.starts_with("**") && cell.ends_with("**") {
1991                        (cell.trim_matches('*'), true)
1992                    } else {
1993                        (*cell, false)
1994                    };
1995
1996                    // Word wrap the text
1997                    let lines = self.word_wrap(text, max_chars_per_line);
1998                    max_lines = max_lines.max(lines.len());
1999                    wrapped_cells.push((lines, is_bold));
2000                }
2001
2002                // Check if we have enough space for this row
2003                let row_height = line_height * max_lines as f64;
2004                if y_pos - row_height < content_bottom {
2005                    // Need a new page
2006                    render_page_header_footer(
2007                        &mut stream,
2008                        page_num,
2009                        width,
2010                        height,
2011                        margin,
2012                        footer_height,
2013                    );
2014                    pages.push(stream);
2015                    stream = String::new();
2016                    page_num += 1;
2017                    y_pos = height - margin - 30.0;
2018                }
2019
2020                // Render each line of each cell
2021                for line_idx in 0..max_lines {
2022                    let mut x_pos = margin;
2023                    let line_y = y_pos - (line_idx as f64 * line_height);
2024
2025                    for (lines, is_bold) in &wrapped_cells {
2026                        let font = if *is_bold || !in_table { "/F2" } else { "/F1" };
2027                        let text = lines.get(line_idx).map(|s| s.as_str()).unwrap_or("");
2028
2029                        if !text.is_empty() {
2030                            stream.push_str("BT\n");
2031                            stream.push_str(&format!("{} {:.1} Tf\n", font, font_size));
2032                            stream.push_str("0 0 0 rg\n");
2033                            stream.push_str(&format!("1 0 0 1 {:.2} {:.2} Tm\n", x_pos, line_y));
2034                            stream.push_str(&format!("({}) Tj\n", self.escape_pdf_string(text)));
2035                            stream.push_str("ET\n");
2036                        }
2037                        x_pos += cell_width;
2038                    }
2039                }
2040
2041                y_pos -= row_height + (line_height * 0.2); // Add small padding between rows
2042                in_table = true;
2043                continue;
2044            } else if in_table && !trimmed.is_empty() {
2045                in_table = false;
2046                y_pos -= line_height * 0.3;
2047            }
2048
2049            // Handle headings - Skip H1 since we render the title in the header
2050            if trimmed.starts_with("# ") && !trimmed.starts_with("## ") {
2051                // Skip the main H1 title as it's already in the header
2052                continue;
2053            }
2054
2055            if trimmed.starts_with("## ") {
2056                let text = trimmed.trim_start_matches("## ");
2057                let h2_size = body_font_size + 3.0;
2058
2059                // Check if we have enough space for heading + at least 4 lines of content
2060                // If not, start a new page to keep section together
2061                let min_section_space = line_height * 5.0;
2062                if y_pos - min_section_space < content_bottom {
2063                    render_page_header_footer(
2064                        &mut stream,
2065                        page_num,
2066                        width,
2067                        height,
2068                        margin,
2069                        footer_height,
2070                    );
2071                    pages.push(stream);
2072                    stream = String::new();
2073                    page_num += 1;
2074                    y_pos = height - margin - 30.0;
2075                }
2076
2077                y_pos -= line_height * 0.3;
2078                stream.push_str("BT\n");
2079                stream.push_str(&format!("/F2 {:.1} Tf\n", h2_size));
2080                stream.push_str("0 0 0 rg\n");
2081                stream.push_str(&format!("1 0 0 1 {:.2} {:.2} Tm\n", margin, y_pos));
2082                stream.push_str(&format!("({}) Tj\n", self.escape_pdf_string(text)));
2083                stream.push_str("ET\n");
2084                y_pos -= line_height * 1.2;
2085                continue;
2086            }
2087
2088            if trimmed.starts_with("### ") {
2089                let text = trimmed.trim_start_matches("### ");
2090
2091                // Check if we have enough space for subheading + at least 3 lines of content
2092                let min_subsection_space = line_height * 4.0;
2093                if y_pos - min_subsection_space < content_bottom {
2094                    render_page_header_footer(
2095                        &mut stream,
2096                        page_num,
2097                        width,
2098                        height,
2099                        margin,
2100                        footer_height,
2101                    );
2102                    pages.push(stream);
2103                    stream = String::new();
2104                    page_num += 1;
2105                    y_pos = height - margin - 30.0;
2106                }
2107                let h3_size = body_font_size + 1.0;
2108                stream.push_str("BT\n");
2109                stream.push_str(&format!("/F2 {:.1} Tf\n", h3_size));
2110                stream.push_str("0 0 0 rg\n");
2111                stream.push_str(&format!("1 0 0 1 {:.2} {:.2} Tm\n", margin, y_pos));
2112                stream.push_str(&format!("({}) Tj\n", self.escape_pdf_string(text)));
2113                stream.push_str("ET\n");
2114                y_pos -= line_height * 1.1;
2115                continue;
2116            }
2117
2118            // Handle list items
2119            if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
2120                let text = trimmed[2..].to_string();
2121                stream.push_str("BT\n");
2122                stream.push_str(&format!("/F1 {:.1} Tf\n", body_font_size));
2123                stream.push_str("0 0 0 rg\n");
2124                stream.push_str(&format!("1 0 0 1 {:.2} {:.2} Tm\n", margin + 10.0, y_pos));
2125                stream.push_str(&format!(
2126                    "(\\267 {}) Tj\n",
2127                    self.escape_pdf_string(&self.strip_markdown_formatting(&text))
2128                ));
2129                stream.push_str("ET\n");
2130                y_pos -= line_height;
2131                continue;
2132            }
2133
2134            // Handle numbered list items
2135            if let Some(rest) = self.parse_numbered_list(trimmed) {
2136                stream.push_str("BT\n");
2137                stream.push_str(&format!("/F1 {:.1} Tf\n", body_font_size));
2138                stream.push_str("0 0 0 rg\n");
2139                stream.push_str(&format!("1 0 0 1 {:.2} {:.2} Tm\n", margin + 10.0, y_pos));
2140                stream.push_str(&format!(
2141                    "({}) Tj\n",
2142                    self.escape_pdf_string(&self.strip_markdown_formatting(rest))
2143                ));
2144                stream.push_str("ET\n");
2145                y_pos -= line_height;
2146                continue;
2147            }
2148
2149            // Handle empty lines
2150            if trimmed.is_empty() {
2151                y_pos -= line_height * 0.5;
2152                continue;
2153            }
2154
2155            // Handle italic text (*text*)
2156            let display_text = self.strip_markdown_formatting(trimmed);
2157
2158            // Check if this is bold text
2159            let (text, font) = if trimmed.starts_with("**") && trimmed.ends_with("**") {
2160                (display_text.as_str(), "/F2")
2161            } else if trimmed.starts_with("*")
2162                && trimmed.ends_with("*")
2163                && !trimmed.starts_with("**")
2164            {
2165                // Italic - we don't have an italic font, so just use regular
2166                (display_text.as_str(), "/F1")
2167            } else {
2168                (display_text.as_str(), "/F1")
2169            };
2170
2171            // Word wrap regular text
2172            let wrapped_lines = self.word_wrap(text, (max_width / (body_font_size * 0.5)) as usize);
2173            for wrapped_line in wrapped_lines {
2174                // Check for page break
2175                if y_pos < content_bottom + line_height {
2176                    render_page_header_footer(
2177                        &mut stream,
2178                        page_num,
2179                        width,
2180                        height,
2181                        margin,
2182                        footer_height,
2183                    );
2184                    pages.push(stream);
2185                    stream = String::new();
2186                    page_num += 1;
2187                    y_pos = height - margin - 30.0;
2188                }
2189                stream.push_str("BT\n");
2190                stream.push_str(&format!("{} {:.1} Tf\n", font, body_font_size));
2191                stream.push_str("0 0 0 rg\n");
2192                stream.push_str(&format!("1 0 0 1 {:.2} {:.2} Tm\n", margin, y_pos));
2193                stream.push_str(&format!("({}) Tj\n", self.escape_pdf_string(&wrapped_line)));
2194                stream.push_str("ET\n");
2195                y_pos -= line_height;
2196            }
2197        }
2198
2199        // Render footer on last page and add it to pages
2200        render_page_header_footer(&mut stream, page_num, width, height, margin, footer_height);
2201        pages.push(stream);
2202
2203        pages
2204    }
2205
2206    /// Strip markdown formatting from text for display
2207    fn strip_markdown_formatting(&self, text: &str) -> String {
2208        let mut result = text.to_string();
2209
2210        // Remove bold markers
2211        while result.contains("**") {
2212            result = result.replacen("**", "", 2);
2213        }
2214
2215        // Remove italic markers (single asterisk)
2216        // Be careful not to remove list markers
2217        let chars: Vec<char> = result.chars().collect();
2218        let mut cleaned = String::new();
2219        let mut i = 0;
2220        while i < chars.len() {
2221            if chars[i] == '*' && i + 1 < chars.len() && chars[i + 1] != '*' && chars[i + 1] != ' '
2222            {
2223                // This might be italic start
2224                if result[i + 1..].contains('*') {
2225                    // Skip the asterisk
2226                    i += 1;
2227                    continue;
2228                }
2229            }
2230            cleaned.push(chars[i]);
2231            i += 1;
2232        }
2233
2234        // Remove backticks (inline code)
2235        result = cleaned.replace('`', "");
2236
2237        // Remove link formatting [text](url) -> text
2238        while let Some(start) = result.find('[') {
2239            if let Some(mid) = result[start..].find("](")
2240                && let Some(end) = result[start + mid..].find(')')
2241            {
2242                let link_text = &result[start + 1..start + mid];
2243                let before = &result[..start];
2244                let after = &result[start + mid + end + 1..];
2245                result = format!("{}{}{}", before, link_text, after);
2246                continue;
2247            }
2248            break;
2249        }
2250
2251        result
2252    }
2253
2254    /// Parse numbered list item, returns the text after the number
2255    fn parse_numbered_list<'a>(&self, text: &'a str) -> Option<&'a str> {
2256        let bytes = text.as_bytes();
2257        let mut i = 0;
2258
2259        // Skip digits
2260        while i < bytes.len() && bytes[i].is_ascii_digit() {
2261            i += 1;
2262        }
2263
2264        // Check for period and space
2265        if i > 0 && i < bytes.len() - 1 && bytes[i] == b'.' && bytes[i + 1] == b' ' {
2266            return Some(&text[i + 2..]);
2267        }
2268
2269        None
2270    }
2271
2272    /// Escape special characters for PDF strings
2273    fn escape_pdf_string(&self, s: &str) -> String {
2274        let mut result = String::new();
2275        for c in s.chars() {
2276            match c {
2277                '\\' => result.push_str("\\\\"),
2278                '(' => result.push_str("\\("),
2279                ')' => result.push_str("\\)"),
2280                '\n' => result.push_str("\\n"),
2281                '\r' => result.push_str("\\r"),
2282                '\t' => result.push_str("\\t"),
2283                // Handle special characters by using octal encoding
2284                '©' => result.push_str("\\251"), // Copyright symbol in WinAnsiEncoding
2285                '®' => result.push_str("\\256"), // Registered trademark
2286                '™' => result.push_str("\\231"), // Trademark
2287                '•' => result.push_str("\\267"), // Bullet
2288                '–' => result.push_str("\\226"), // En dash
2289                '—' => result.push_str("\\227"), // Em dash
2290                '…' => result.push_str("\\205"), // Ellipsis
2291                _ if c.is_ascii() => result.push(c),
2292                // For non-ASCII characters, try to use closest ASCII equivalent
2293                _ => result.push('?'),
2294            }
2295        }
2296        result
2297    }
2298
2299    /// Word wrap text to fit within max characters per line.
2300    /// Long words (URLs, field names) that exceed max_chars are broken with a
2301    /// continuation marker (→) to indicate the text continues on the next line.
2302    fn word_wrap(&self, text: &str, max_chars: usize) -> Vec<String> {
2303        let mut lines = Vec::new();
2304        let mut current_line = String::new();
2305        // Ensure we have at least some space for the continuation marker
2306        let effective_max = max_chars.max(5);
2307
2308        for word in text.split_whitespace() {
2309            // If the word itself is too long, break it with continuation markers
2310            if word.len() > effective_max {
2311                // First, flush any current content
2312                if !current_line.is_empty() {
2313                    lines.push(current_line);
2314                    current_line = String::new();
2315                }
2316                // Break the long word into chunks
2317                let broken = self.break_long_word(word, effective_max);
2318                for (i, chunk) in broken.iter().enumerate() {
2319                    if i < broken.len() - 1 {
2320                        // Not the last chunk, push it as its own line
2321                        lines.push(chunk.clone());
2322                    } else {
2323                        // Last chunk becomes start of current line
2324                        current_line = chunk.clone();
2325                    }
2326                }
2327            } else if current_line.is_empty() {
2328                current_line = word.to_string();
2329            } else if current_line.len() + 1 + word.len() <= effective_max {
2330                current_line.push(' ');
2331                current_line.push_str(word);
2332            } else {
2333                lines.push(current_line);
2334                current_line = word.to_string();
2335            }
2336        }
2337
2338        if !current_line.is_empty() {
2339            lines.push(current_line);
2340        }
2341
2342        if lines.is_empty() {
2343            lines.push(String::new());
2344        }
2345
2346        lines
2347    }
2348
2349    /// Break a long word into chunks that fit within max_chars.
2350    /// Each chunk except the last ends with a hyphen (-) to indicate continuation.
2351    fn break_long_word(&self, word: &str, max_chars: usize) -> Vec<String> {
2352        let mut chunks = Vec::new();
2353        let chars: Vec<char> = word.chars().collect();
2354        // Use hyphen as continuation marker (ASCII-safe for PDF)
2355        let continuation_marker = "-";
2356        // Reserve 1 char for the continuation marker on non-final chunks
2357        let chunk_size = (max_chars - 1).max(1);
2358
2359        let mut start = 0;
2360        while start < chars.len() {
2361            let remaining = chars.len() - start;
2362            if remaining <= max_chars {
2363                // Final chunk - no continuation marker needed
2364                chunks.push(chars[start..].iter().collect());
2365                break;
2366            } else {
2367                // Not final - add continuation marker
2368                let end = start + chunk_size;
2369                let mut chunk: String = chars[start..end].iter().collect();
2370                chunk.push_str(continuation_marker);
2371                chunks.push(chunk);
2372                start = end;
2373            }
2374        }
2375
2376        if chunks.is_empty() {
2377            chunks.push(word.to_string());
2378        }
2379
2380        chunks
2381    }
2382}
2383
2384#[cfg(test)]
2385mod tests {
2386    use super::*;
2387    use crate::models::decision::Decision;
2388    use crate::models::knowledge::KnowledgeArticle;
2389
2390    #[test]
2391    fn test_branding_config_default() {
2392        let config = BrandingConfig::default();
2393        assert_eq!(config.brand_color, "#0066CC");
2394        assert!(config.show_page_numbers);
2395        assert!(config.show_timestamp);
2396        assert_eq!(config.font_size, 11);
2397        assert_eq!(config.page_size, PageSize::A4);
2398        assert_eq!(config.logo_url, Some(DEFAULT_LOGO_URL.to_string()));
2399        assert_eq!(config.footer, Some(DEFAULT_COPYRIGHT.to_string()));
2400    }
2401
2402    #[test]
2403    fn test_page_size_dimensions() {
2404        let a4 = PageSize::A4;
2405        let (w, h) = a4.dimensions_mm();
2406        assert_eq!(w, 210.0);
2407        assert_eq!(h, 297.0);
2408
2409        let letter = PageSize::Letter;
2410        let (w, h) = letter.dimensions_mm();
2411        assert!((w - 215.9).abs() < 0.1);
2412        assert!((h - 279.4).abs() < 0.1);
2413    }
2414
2415    #[test]
2416    fn test_pdf_exporter_with_branding() {
2417        let branding = BrandingConfig {
2418            header: Some("Company Header".to_string()),
2419            footer: Some("Confidential".to_string()),
2420            company_name: Some("Test Corp".to_string()),
2421            brand_color: "#FF0000".to_string(),
2422            ..Default::default()
2423        };
2424
2425        let exporter = PdfExporter::with_branding(branding.clone());
2426        assert_eq!(
2427            exporter.branding().header,
2428            Some("Company Header".to_string())
2429        );
2430        assert_eq!(exporter.branding().brand_color, "#FF0000");
2431    }
2432
2433    #[test]
2434    fn test_export_decision_to_pdf() {
2435        let decision = Decision::new(
2436            1,
2437            "Use Rust for SDK",
2438            "We need to choose a language for the SDK implementation.",
2439            "Use Rust for type safety and performance.",
2440        );
2441
2442        let exporter = PdfExporter::new();
2443        let result = exporter.export_decision(&decision);
2444        assert!(result.is_ok());
2445
2446        let pdf_result = result.unwrap();
2447        assert!(!pdf_result.pdf_base64.is_empty());
2448        assert!(pdf_result.filename.ends_with(".pdf"));
2449        assert!(pdf_result.page_count >= 1);
2450        assert!(pdf_result.title.contains("ADR-"));
2451    }
2452
2453    #[test]
2454    fn test_export_knowledge_to_pdf() {
2455        let article = KnowledgeArticle::new(
2456            1,
2457            "Getting Started Guide",
2458            "A guide to getting started with the SDK.",
2459            "This guide covers the basics...",
2460            "author@example.com",
2461        );
2462
2463        let exporter = PdfExporter::new();
2464        let result = exporter.export_knowledge(&article);
2465        assert!(result.is_ok());
2466
2467        let pdf_result = result.unwrap();
2468        assert!(!pdf_result.pdf_base64.is_empty());
2469        assert!(pdf_result.filename.ends_with(".pdf"));
2470        assert!(pdf_result.title.contains("KB-"));
2471    }
2472
2473    #[test]
2474    fn test_export_table_to_pdf() {
2475        use crate::models::{Column, Table};
2476
2477        let mut table = Table::new(
2478            "users".to_string(),
2479            vec![
2480                Column::new("id".to_string(), "BIGINT".to_string()),
2481                Column::new("name".to_string(), "VARCHAR(255)".to_string()),
2482                Column::new("email".to_string(), "VARCHAR(255)".to_string()),
2483            ],
2484        );
2485        table.schema_name = Some("public".to_string());
2486        table.owner = Some("Data Engineering".to_string());
2487        table.notes = Some("Core user table for the application".to_string());
2488
2489        let exporter = PdfExporter::new();
2490        let result = exporter.export_table(&table);
2491        assert!(result.is_ok());
2492
2493        let pdf_result = result.unwrap();
2494        assert!(!pdf_result.pdf_base64.is_empty());
2495        assert!(pdf_result.filename.ends_with(".pdf"));
2496        assert_eq!(pdf_result.title, "users");
2497    }
2498
2499    #[test]
2500    fn test_export_data_product_to_pdf() {
2501        use crate::models::odps::{ODPSDataProduct, ODPSDescription, ODPSOutputPort, ODPSStatus};
2502
2503        let product = ODPSDataProduct {
2504            api_version: "v1.0.0".to_string(),
2505            kind: "DataProduct".to_string(),
2506            id: "dp-customer-360".to_string(),
2507            name: Some("Customer 360".to_string()),
2508            version: Some("1.0.0".to_string()),
2509            status: ODPSStatus::Active,
2510            domain: Some("Customer".to_string()),
2511            tenant: None,
2512            authoritative_definitions: None,
2513            description: Some(ODPSDescription {
2514                purpose: Some("Unified customer view across all touchpoints".to_string()),
2515                limitations: Some("Does not include real-time data".to_string()),
2516                usage: Some("Use for analytics and reporting".to_string()),
2517                authoritative_definitions: None,
2518                custom_properties: None,
2519            }),
2520            custom_properties: None,
2521            tags: vec![],
2522            input_ports: None,
2523            output_ports: Some(vec![ODPSOutputPort {
2524                name: "customer-data".to_string(),
2525                version: "1.0.0".to_string(),
2526                description: Some("Customer master data".to_string()),
2527                r#type: Some("table".to_string()),
2528                contract_id: Some("contract-123".to_string()),
2529                sbom: None,
2530                input_contracts: None,
2531                tags: vec![],
2532                custom_properties: None,
2533                authoritative_definitions: None,
2534            }]),
2535            management_ports: None,
2536            support: None,
2537            team: None,
2538            product_created_ts: None,
2539            created_at: None,
2540            updated_at: None,
2541        };
2542
2543        let exporter = PdfExporter::new();
2544        let result = exporter.export_data_product(&product);
2545        assert!(result.is_ok());
2546
2547        let pdf_result = result.unwrap();
2548        assert!(!pdf_result.pdf_base64.is_empty());
2549        assert!(pdf_result.filename.ends_with(".pdf"));
2550        assert_eq!(pdf_result.title, "Customer 360");
2551    }
2552
2553    #[test]
2554    fn test_export_cads_asset_to_pdf() {
2555        use crate::models::cads::{
2556            CADSAsset, CADSDescription, CADSKind, CADSStatus, CADSTeamMember,
2557        };
2558
2559        let asset = CADSAsset {
2560            api_version: "v1.0".to_string(),
2561            kind: CADSKind::AIModel,
2562            id: "model-sentiment-v1".to_string(),
2563            name: "Sentiment Analysis Model".to_string(),
2564            version: "1.0.0".to_string(),
2565            status: CADSStatus::Production,
2566            domain: Some("NLP".to_string()),
2567            tags: vec![],
2568            description: Some(CADSDescription {
2569                purpose: Some("Analyze sentiment in customer feedback".to_string()),
2570                usage: Some("Call the /predict endpoint with text input".to_string()),
2571                limitations: Some("English language only".to_string()),
2572                external_links: None,
2573            }),
2574            runtime: None,
2575            sla: None,
2576            pricing: None,
2577            team: Some(vec![CADSTeamMember {
2578                role: "Owner".to_string(),
2579                name: "ML Team".to_string(),
2580                contact: Some("ml-team@example.com".to_string()),
2581            }]),
2582            risk: None,
2583            compliance: None,
2584            validation_profiles: None,
2585            bpmn_models: None,
2586            dmn_models: None,
2587            openapi_specs: None,
2588            custom_properties: None,
2589            created_at: None,
2590            updated_at: None,
2591        };
2592
2593        let exporter = PdfExporter::new();
2594        let result = exporter.export_cads_asset(&asset);
2595        assert!(result.is_ok());
2596
2597        let pdf_result = result.unwrap();
2598        assert!(!pdf_result.pdf_base64.is_empty());
2599        assert!(pdf_result.filename.ends_with(".pdf"));
2600        assert_eq!(pdf_result.title, "Sentiment Analysis Model");
2601    }
2602
2603    #[test]
2604    fn test_export_markdown_to_pdf() {
2605        let exporter = PdfExporter::new();
2606        let result = exporter.export_markdown(
2607            "Test Document",
2608            "# Test\n\nThis is a test document.\n\n## Section\n\n- Item 1\n- Item 2",
2609            "test.pdf",
2610        );
2611        assert!(result.is_ok());
2612
2613        let pdf_result = result.unwrap();
2614        assert!(!pdf_result.pdf_base64.is_empty());
2615        assert_eq!(pdf_result.filename, "test.pdf");
2616    }
2617
2618    #[test]
2619    fn test_escape_pdf_string() {
2620        let exporter = PdfExporter::new();
2621        assert_eq!(exporter.escape_pdf_string("Hello"), "Hello");
2622        assert_eq!(exporter.escape_pdf_string("(test)"), "\\(test\\)");
2623        assert_eq!(exporter.escape_pdf_string("back\\slash"), "back\\\\slash");
2624    }
2625
2626    #[test]
2627    fn test_word_wrap() {
2628        let exporter = PdfExporter::new();
2629
2630        let wrapped = exporter.word_wrap("Hello world this is a test", 10);
2631        assert!(wrapped.len() > 1);
2632
2633        let wrapped = exporter.word_wrap("Short", 100);
2634        assert_eq!(wrapped.len(), 1);
2635        assert_eq!(wrapped[0], "Short");
2636    }
2637
2638    #[test]
2639    fn test_word_wrap_long_url() {
2640        let exporter = PdfExporter::new();
2641
2642        // Test that a long URL without spaces gets broken with continuation markers
2643        let long_url = "https://example.com/very/long/path/to/some/resource/file.json";
2644        let wrapped = exporter.word_wrap(long_url, 20);
2645
2646        // Should be broken into multiple lines
2647        assert!(
2648            wrapped.len() > 1,
2649            "Long URL should be wrapped into multiple lines"
2650        );
2651
2652        // All lines except the last should end with hyphen
2653        for (i, line) in wrapped.iter().enumerate() {
2654            if i < wrapped.len() - 1 {
2655                assert!(
2656                    line.ends_with('-'),
2657                    "Non-final line should end with hyphen: {}",
2658                    line
2659                );
2660            }
2661        }
2662
2663        // When joined (removing hyphens), should reconstruct the original
2664        let reconstructed: String = wrapped
2665            .iter()
2666            .map(|s| s.trim_end_matches('-'))
2667            .collect::<Vec<_>>()
2668            .join("");
2669        assert_eq!(reconstructed, long_url);
2670    }
2671
2672    #[test]
2673    fn test_word_wrap_mixed_content() {
2674        let exporter = PdfExporter::new();
2675
2676        // Test mixed content with short words and a long URL
2677        let text = "See https://example.com/very/long/path/to/resource for details";
2678        let wrapped = exporter.word_wrap(text, 25);
2679
2680        // Should have multiple lines
2681        assert!(wrapped.len() > 1);
2682
2683        // The URL should be broken across lines
2684        let all_text = wrapped.join(" ");
2685        assert!(all_text.contains("https://"));
2686    }
2687
2688    #[test]
2689    fn test_break_long_word() {
2690        let exporter = PdfExporter::new();
2691
2692        // Test breaking a long word
2693        let long_word = "abcdefghijklmnopqrstuvwxyz";
2694        let broken = exporter.break_long_word(long_word, 10);
2695
2696        // Should be broken into multiple chunks
2697        assert!(broken.len() > 1);
2698
2699        // All chunks except the last should end with hyphen
2700        for (i, chunk) in broken.iter().enumerate() {
2701            if i < broken.len() - 1 {
2702                assert!(
2703                    chunk.ends_with('-'),
2704                    "Chunk should end with hyphen: {}",
2705                    chunk
2706                );
2707                assert!(chunk.len() <= 10, "Chunk should fit within max_chars");
2708            }
2709        }
2710
2711        // Reconstruct and verify
2712        let reconstructed: String = broken
2713            .iter()
2714            .map(|s| s.trim_end_matches('-'))
2715            .collect::<Vec<_>>()
2716            .join("");
2717        assert_eq!(reconstructed, long_word);
2718    }
2719
2720    #[test]
2721    fn test_pdf_result_serialization() {
2722        let result = PdfExportResult {
2723            pdf_base64: "dGVzdA==".to_string(),
2724            filename: "test.pdf".to_string(),
2725            page_count: 1,
2726            title: "Test".to_string(),
2727        };
2728
2729        let json = serde_json::to_string(&result).unwrap();
2730        assert!(json.contains("pdf_base64"));
2731        assert!(json.contains("filename"));
2732    }
2733
2734    #[test]
2735    fn test_strip_markdown_formatting() {
2736        let exporter = PdfExporter::new();
2737        assert_eq!(exporter.strip_markdown_formatting("**bold**"), "bold");
2738        assert_eq!(exporter.strip_markdown_formatting("`code`"), "code");
2739        assert_eq!(
2740            exporter.strip_markdown_formatting("[link](http://example.com)"),
2741            "link"
2742        );
2743    }
2744
2745    /// Generate sample PDFs for visual inspection (writes to /tmp)
2746    /// Run with: cargo test generate_sample_pdfs_for_inspection -- --ignored --nocapture
2747    #[test]
2748    #[ignore]
2749    fn generate_sample_pdfs_for_inspection() {
2750        use crate::models::decision::DecisionOption;
2751        use base64::Engine;
2752
2753        // Create a Decision with rich content including options with pros/cons
2754        let mut decision = Decision::new(
2755            2501100001,
2756            "Use Rust for SDK Implementation",
2757            "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.",
2758            "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.",
2759        );
2760
2761        // Add options with pros and cons
2762        decision.options = vec![
2763            DecisionOption::with_details(
2764                "Rust",
2765                "A systems programming language focused on safety and performance.",
2766                vec![
2767                    "Memory safety without garbage collection".to_string(),
2768                    "Excellent performance".to_string(),
2769                    "Strong type system".to_string(),
2770                    "First-class WASM support".to_string(),
2771                    "Growing ecosystem".to_string(),
2772                ],
2773                vec![
2774                    "Steeper learning curve".to_string(),
2775                    "Longer compilation times".to_string(),
2776                    "Smaller talent pool".to_string(),
2777                ],
2778                true, // selected
2779            ),
2780            DecisionOption::with_details(
2781                "TypeScript",
2782                "A typed superset of JavaScript.",
2783                vec![
2784                    "Large developer community".to_string(),
2785                    "Easy to learn".to_string(),
2786                    "Good tooling".to_string(),
2787                ],
2788                vec![
2789                    "Runtime type checking only".to_string(),
2790                    "Performance limitations".to_string(),
2791                    "Node.js dependency".to_string(),
2792                ],
2793                false,
2794            ),
2795            DecisionOption::with_details(
2796                "Go",
2797                "A statically typed language designed at Google.",
2798                vec![
2799                    "Simple syntax".to_string(),
2800                    "Fast compilation".to_string(),
2801                    "Good concurrency support".to_string(),
2802                ],
2803                vec![
2804                    "Limited generics".to_string(),
2805                    "No WASM support".to_string(),
2806                    "Verbose error handling".to_string(),
2807                ],
2808                false,
2809            ),
2810        ];
2811
2812        decision.consequences =
2813            Some("This decision will have significant impact on the project.".to_string());
2814
2815        // Debug: print the generated markdown to see what's being rendered
2816        let exporter = PdfExporter::new();
2817        let md = exporter.decision_to_markdown(&decision);
2818        println!("Generated markdown length: {} chars", md.len());
2819        println!(
2820            "Contains 'Options Considered': {}",
2821            md.contains("Options Considered")
2822        );
2823        println!("Contains 'Pros': {}", md.contains("Pros"));
2824
2825        // Create a Knowledge Article with code blocks
2826        let article = KnowledgeArticle::new(
2827            2501100001,
2828            "Getting Started with the SDK",
2829            "A comprehensive guide to getting started with the Open Data Modelling SDK.",
2830            r#"## Installation
2831
2832Install the SDK using cargo:
2833
2834```bash
2835cargo add data-modelling-sdk
2836```
2837
2838## Basic Usage
2839
2840Here's a simple example:
2841
2842```rust
2843use data_modelling_core::models::decision::Decision;
2844
2845fn main() {
2846    let decision = Decision::new(
2847        1,
2848        "Use microservices",
2849        "Context here",
2850        "Decision here",
2851    );
2852    println!("Created: {}", decision.title);
2853}
2854```
2855
2856## Configuration
2857
2858Configure using YAML:
2859
2860```yaml
2861sdk:
2862  log_level: info
2863  storage_path: ./data
2864```
2865
2866For more information, see the documentation."#,
2867            "docs@opendatamodelling.com",
2868        );
2869
2870        let exporter = PdfExporter::new();
2871
2872        // Export Decision
2873        let result = exporter.export_decision(&decision).unwrap();
2874        let pdf_bytes = base64::engine::general_purpose::STANDARD
2875            .decode(&result.pdf_base64)
2876            .unwrap();
2877        std::fs::write("/tmp/sample_decision.pdf", &pdf_bytes).unwrap();
2878        println!("Wrote /tmp/sample_decision.pdf ({} bytes)", pdf_bytes.len());
2879
2880        // Export Knowledge Article
2881        let result = exporter.export_knowledge(&article).unwrap();
2882        let pdf_bytes = base64::engine::general_purpose::STANDARD
2883            .decode(&result.pdf_base64)
2884            .unwrap();
2885        std::fs::write("/tmp/sample_knowledge.pdf", &pdf_bytes).unwrap();
2886        println!(
2887            "Wrote /tmp/sample_knowledge.pdf ({} bytes)",
2888            pdf_bytes.len()
2889        );
2890
2891        // Export Data Contract (Table)
2892        use crate::models::{Column, Table};
2893
2894        let mut table = Table::new(
2895            "customer_orders".to_string(),
2896            vec![
2897                {
2898                    let mut col = Column::new("order_id".to_string(), "BIGINT".to_string());
2899                    col.primary_key = true;
2900                    col.description = "Unique identifier for each order".to_string();
2901                    col
2902                },
2903                {
2904                    let mut col = Column::new("customer_id".to_string(), "BIGINT".to_string());
2905                    col.description = "Foreign key reference to customers table".to_string();
2906                    col
2907                },
2908                {
2909                    let mut col = Column::new("order_date".to_string(), "TIMESTAMP".to_string());
2910                    col.description = "Date and time when the order was placed".to_string();
2911                    col.nullable = false;
2912                    col
2913                },
2914                {
2915                    let mut col = Column::new("status".to_string(), "VARCHAR(50)".to_string());
2916                    col.description = "Current status of the order".to_string();
2917                    col.enum_values = vec![
2918                        "pending".to_string(),
2919                        "processing".to_string(),
2920                        "shipped".to_string(),
2921                        "delivered".to_string(),
2922                        "cancelled".to_string(),
2923                    ];
2924                    col.business_name = Some("Order Status".to_string());
2925                    col
2926                },
2927                {
2928                    let mut col =
2929                        Column::new("total_amount".to_string(), "DECIMAL(10,2)".to_string());
2930                    col.description = "Total order amount in USD".to_string();
2931                    col
2932                },
2933            ],
2934        );
2935        table.schema_name = Some("sales".to_string());
2936        table.catalog_name = Some("production".to_string());
2937        table.owner = Some("Data Engineering Team".to_string());
2938        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());
2939
2940        // Add ODCS metadata to test full export
2941        table
2942            .odcl_metadata
2943            .insert("apiVersion".to_string(), serde_json::json!("v3.0.2"));
2944        table
2945            .odcl_metadata
2946            .insert("kind".to_string(), serde_json::json!("DataContract"));
2947        table
2948            .odcl_metadata
2949            .insert("status".to_string(), serde_json::json!("active"));
2950        table
2951            .odcl_metadata
2952            .insert("version".to_string(), serde_json::json!("1.2.0"));
2953        table
2954            .odcl_metadata
2955            .insert("domain".to_string(), serde_json::json!("Sales"));
2956        table.odcl_metadata.insert(
2957            "dataProduct".to_string(),
2958            serde_json::json!("Customer Orders Analytics"),
2959        );
2960
2961        // Add SLA information
2962        use crate::models::table::SlaProperty;
2963        table.sla = Some(vec![
2964            SlaProperty {
2965                property: "availability".to_string(),
2966                value: serde_json::json!("99.9"),
2967                unit: "%".to_string(),
2968                element: None,
2969                driver: Some("operational".to_string()),
2970                description: Some("Guaranteed uptime for data access".to_string()),
2971                scheduler: None,
2972                schedule: None,
2973            },
2974            SlaProperty {
2975                property: "freshness".to_string(),
2976                value: serde_json::json!(24),
2977                unit: "hours".to_string(),
2978                element: None,
2979                driver: Some("analytics".to_string()),
2980                description: Some("Maximum data staleness".to_string()),
2981                scheduler: None,
2982                schedule: None,
2983            },
2984        ]);
2985
2986        // Add contact details
2987        use crate::models::table::ContactDetails;
2988        table.contact_details = Some(ContactDetails {
2989            name: Some("John Smith".to_string()),
2990            email: Some("john.smith@example.com".to_string()),
2991            role: Some("Data Steward".to_string()),
2992            phone: Some("+1-555-0123".to_string()),
2993            other: None,
2994        });
2995
2996        let result = exporter.export_table(&table).unwrap();
2997        let pdf_bytes = base64::engine::general_purpose::STANDARD
2998            .decode(&result.pdf_base64)
2999            .unwrap();
3000        std::fs::write("/tmp/sample_table.pdf", &pdf_bytes).unwrap();
3001        println!("Wrote /tmp/sample_table.pdf ({} bytes)", pdf_bytes.len());
3002
3003        // Export Data Product (ODPS)
3004        use crate::models::odps::{
3005            ODPSDataProduct, ODPSDescription, ODPSInputPort, ODPSOutputPort, ODPSStatus,
3006            ODPSSupport, ODPSTeam, ODPSTeamMember,
3007        };
3008
3009        let product = ODPSDataProduct {
3010            api_version: "v1.0.0".to_string(),
3011            kind: "DataProduct".to_string(),
3012            id: "dp-customer-360-view".to_string(),
3013            name: Some("Customer 360 View".to_string()),
3014            version: Some("2.1.0".to_string()),
3015            status: ODPSStatus::Active,
3016            domain: Some("Customer Intelligence".to_string()),
3017            tenant: Some("ACME Corp".to_string()),
3018            authoritative_definitions: None,
3019            description: Some(ODPSDescription {
3020                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()),
3021                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()),
3022                usage: Some("Use this data product for customer analytics, segmentation, personalization, and churn prediction models.".to_string()),
3023                authoritative_definitions: None,
3024                custom_properties: None,
3025            }),
3026            custom_properties: None,
3027            tags: vec![],
3028            input_ports: Some(vec![
3029                ODPSInputPort {
3030                    name: "crm-contacts".to_string(),
3031                    version: "1.0.0".to_string(),
3032                    contract_id: "contract-crm-001".to_string(),
3033                    tags: vec![],
3034                    custom_properties: None,
3035                    authoritative_definitions: None,
3036                },
3037                ODPSInputPort {
3038                    name: "transaction-history".to_string(),
3039                    version: "2.0.0".to_string(),
3040                    contract_id: "contract-txn-002".to_string(),
3041                    tags: vec![],
3042                    custom_properties: None,
3043                    authoritative_definitions: None,
3044                },
3045            ]),
3046            output_ports: Some(vec![
3047                ODPSOutputPort {
3048                    name: "customer-profile".to_string(),
3049                    version: "2.1.0".to_string(),
3050                    description: Some("Unified customer profile with demographics, preferences, and behavioral scores".to_string()),
3051                    r#type: Some("table".to_string()),
3052                    contract_id: Some("contract-profile-001".to_string()),
3053                    sbom: None,
3054                    input_contracts: None,
3055                    tags: vec![],
3056                    custom_properties: None,
3057                    authoritative_definitions: None,
3058                },
3059                ODPSOutputPort {
3060                    name: "customer-segments".to_string(),
3061                    version: "1.5.0".to_string(),
3062                    description: Some("Customer segmentation based on RFM analysis and behavioral clustering".to_string()),
3063                    r#type: Some("table".to_string()),
3064                    contract_id: Some("contract-segments-001".to_string()),
3065                    sbom: None,
3066                    input_contracts: None,
3067                    tags: vec![],
3068                    custom_properties: None,
3069                    authoritative_definitions: None,
3070                },
3071            ]),
3072            management_ports: None,
3073            support: Some(vec![ODPSSupport {
3074                channel: "Slack".to_string(),
3075                url: "https://acme.slack.com/channels/customer-data".to_string(),
3076                description: Some("Primary support channel for data product questions".to_string()),
3077                tool: Some("Slack".to_string()),
3078                scope: None,
3079                invitation_url: None,
3080                tags: vec![],
3081                custom_properties: None,
3082                authoritative_definitions: None,
3083            }]),
3084            team: Some(ODPSTeam {
3085                name: Some("Customer Data Team".to_string()),
3086                description: Some("Responsible for customer data products and analytics".to_string()),
3087                members: Some(vec![
3088                    ODPSTeamMember {
3089                        username: "john.doe@acme.com".to_string(),
3090                        name: Some("John Doe".to_string()),
3091                        role: Some("Product Owner".to_string()),
3092                        description: None,
3093                        date_in: None,
3094                        date_out: None,
3095                        replaced_by_username: None,
3096                        tags: vec![],
3097                        custom_properties: None,
3098                        authoritative_definitions: None,
3099                    },
3100                    ODPSTeamMember {
3101                        username: "jane.smith@acme.com".to_string(),
3102                        name: Some("Jane Smith".to_string()),
3103                        role: Some("Data Engineer".to_string()),
3104                        description: None,
3105                        date_in: None,
3106                        date_out: None,
3107                        replaced_by_username: None,
3108                        tags: vec![],
3109                        custom_properties: None,
3110                        authoritative_definitions: None,
3111                    },
3112                ]),
3113                tags: vec![],
3114                custom_properties: None,
3115                authoritative_definitions: None,
3116            }),
3117            product_created_ts: None,
3118            created_at: Some(chrono::Utc::now()),
3119            updated_at: Some(chrono::Utc::now()),
3120        };
3121
3122        let result = exporter.export_data_product(&product).unwrap();
3123        let pdf_bytes = base64::engine::general_purpose::STANDARD
3124            .decode(&result.pdf_base64)
3125            .unwrap();
3126        std::fs::write("/tmp/sample_data_product.pdf", &pdf_bytes).unwrap();
3127        println!(
3128            "Wrote /tmp/sample_data_product.pdf ({} bytes)",
3129            pdf_bytes.len()
3130        );
3131
3132        // Export CADS Asset
3133        use crate::models::cads::{
3134            CADSAsset, CADSDescription, CADSImpactArea, CADSKind, CADSRisk, CADSRiskClassification,
3135            CADSRuntime, CADSRuntimeResources, CADSStatus, CADSTeamMember,
3136        };
3137
3138        let asset = CADSAsset {
3139            api_version: "v1.0".to_string(),
3140            kind: CADSKind::AIModel,
3141            id: "urn:cads:ai-model:sentiment-analysis:v2".to_string(),
3142            name: "Customer Sentiment Analysis Model".to_string(),
3143            version: "2.3.1".to_string(),
3144            status: CADSStatus::Production,
3145            domain: Some("Natural Language Processing".to_string()),
3146            tags: vec![],
3147            description: Some(CADSDescription {
3148                purpose: Some("Analyzes customer feedback, reviews, and support tickets to determine sentiment polarity (positive, negative, neutral) and emotion categories.".to_string()),
3149                usage: Some("Send text via REST API to /v2/predict endpoint. Supports batch processing up to 100 items per request.".to_string()),
3150                limitations: Some("English language only. Maximum 5000 characters per text input. Not suitable for sarcasm detection.".to_string()),
3151                external_links: None,
3152            }),
3153            runtime: Some(CADSRuntime {
3154                environment: Some("Kubernetes".to_string()),
3155                endpoints: Some(vec![
3156                    "https://api.example.com/ml/sentiment/v2".to_string(),
3157                ]),
3158                container: None,
3159                resources: Some(CADSRuntimeResources {
3160                    cpu: Some("4 cores".to_string()),
3161                    memory: Some("16 GB".to_string()),
3162                    gpu: Some("1x NVIDIA T4".to_string()),
3163                }),
3164            }),
3165            sla: None,
3166            pricing: None,
3167            team: Some(vec![
3168                CADSTeamMember {
3169                    role: "Model Owner".to_string(),
3170                    name: "Dr. Sarah Chen".to_string(),
3171                    contact: Some("sarah.chen@example.com".to_string()),
3172                },
3173                CADSTeamMember {
3174                    role: "ML Engineer".to_string(),
3175                    name: "Alex Kumar".to_string(),
3176                    contact: Some("alex.kumar@example.com".to_string()),
3177                },
3178            ]),
3179            risk: Some(CADSRisk {
3180                classification: Some(CADSRiskClassification::Medium),
3181                impact_areas: Some(vec![CADSImpactArea::Fairness, CADSImpactArea::Privacy]),
3182                intended_use: Some("Analyzing customer sentiment for product improvement and support prioritization".to_string()),
3183                out_of_scope_use: Some("Medical diagnosis, legal decisions, credit scoring".to_string()),
3184                assessment: None,
3185                mitigations: None,
3186            }),
3187            compliance: None,
3188            validation_profiles: None,
3189            bpmn_models: None,
3190            dmn_models: None,
3191            openapi_specs: None,
3192            custom_properties: None,
3193            created_at: Some(chrono::Utc::now()),
3194            updated_at: Some(chrono::Utc::now()),
3195        };
3196
3197        let result = exporter.export_cads_asset(&asset).unwrap();
3198        let pdf_bytes = base64::engine::general_purpose::STANDARD
3199            .decode(&result.pdf_base64)
3200            .unwrap();
3201        std::fs::write("/tmp/sample_cads_asset.pdf", &pdf_bytes).unwrap();
3202        println!(
3203            "Wrote /tmp/sample_cads_asset.pdf ({} bytes)",
3204            pdf_bytes.len()
3205        );
3206    }
3207}