1use crate::export::ExportError;
20use crate::models::decision::Decision;
21use crate::models::knowledge::KnowledgeArticle;
22use chrono::Utc;
23use serde::{Deserialize, Serialize};
24
25const DEFAULT_LOGO_URL: &str = "https://opendatamodelling.com/logo.png";
27
28const DEFAULT_COPYRIGHT: &str = "© opendatamodelling.com";
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct BrandingConfig {
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub logo_base64: Option<String>,
37
38 #[serde(default = "default_logo_url")]
40 pub logo_url: Option<String>,
41
42 #[serde(skip_serializing_if = "Option::is_none")]
44 pub header: Option<String>,
45
46 #[serde(default = "default_footer")]
48 pub footer: Option<String>,
49
50 #[serde(default = "default_brand_color")]
52 pub brand_color: String,
53
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub company_name: Option<String>,
57
58 #[serde(default = "default_true")]
60 pub show_page_numbers: bool,
61
62 #[serde(default = "default_true")]
64 pub show_timestamp: bool,
65
66 #[serde(default = "default_font_size")]
68 pub font_size: u8,
69
70 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
114#[serde(rename_all = "lowercase")]
115pub enum PageSize {
116 #[default]
118 A4,
119 Letter,
121}
122
123impl PageSize {
124 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#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(tag = "type", rename_all = "lowercase")]
136#[allow(clippy::large_enum_variant)]
137pub enum PdfContent {
138 Decision(Decision),
140 Knowledge(KnowledgeArticle),
142 Markdown { title: String, content: String },
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct PdfExportResult {
149 pub pdf_base64: String,
151 pub filename: String,
153 pub page_count: u32,
155 pub title: String,
157}
158
159pub struct PdfExporter {
161 branding: BrandingConfig,
162}
163
164impl Default for PdfExporter {
165 fn default() -> Self {
166 Self::new()
167 }
168}
169
170impl PdfExporter {
171 pub fn new() -> Self {
173 Self {
174 branding: BrandingConfig::default(),
175 }
176 }
177
178 pub fn with_branding(branding: BrandingConfig) -> Self {
180 Self { branding }
181 }
182
183 pub fn set_branding(&mut self, branding: BrandingConfig) {
185 self.branding = branding;
186 }
187
188 pub fn branding(&self) -> &BrandingConfig {
190 &self.branding
191 }
192
193 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 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 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 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 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 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 pub fn table_to_markdown_public(&self, table: &crate::models::Table) -> String {
277 self.table_to_markdown(table)
278 }
279
280 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 pub fn cads_asset_to_markdown_public(&self, asset: &crate::models::cads::CADSAsset) -> String {
294 self.cads_asset_to_markdown(asset)
295 }
296
297 fn decision_to_markdown(&self, decision: &Decision) -> String {
301 use crate::models::decision::DecisionStatus;
302
303 let mut md = String::new();
304
305 md.push_str(&format!(
307 "# {}: {}\n\n",
308 decision.formatted_number(),
309 decision.title
310 ));
311
312 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 md.push_str("## Context\n\n");
346 md.push_str(&decision.context);
347 md.push_str("\n\n");
348
349 md.push_str("## Decision\n\n");
351 md.push_str(&decision.decision);
352 md.push_str("\n\n");
353
354 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 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 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 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 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 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 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 md.push_str("---\n\n");
470
471 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 md.push_str("---\n\n");
480
481 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 fn knowledge_to_markdown(&self, article: &KnowledgeArticle) -> String {
495 use crate::models::knowledge::{KnowledgeStatus, KnowledgeType};
496
497 let mut md = String::new();
498
499 md.push_str(&format!(
501 "# {}: {}\n\n",
502 article.formatted_number(),
503 article.title
504 ));
505
506 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 md.push_str("## Summary\n\n");
560 md.push_str(&article.summary);
561 md.push_str("\n\n---\n\n");
562
563 md.push_str(&article.content);
565 md.push_str("\n\n");
566
567 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 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 md.push_str("---\n\n");
592
593 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 md.push_str("---\n\n");
602
603 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 fn table_to_markdown(&self, table: &crate::models::Table) -> String {
615 let mut md = String::new();
616
617 md.push_str(&format!("# {}\n\n", table.name));
619
620 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 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 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 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 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 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 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 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 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 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 if let Some(class) = &col.classification {
819 md.push_str(&format!("**Classification:** {}\n\n", class));
820 }
821
822 if let Some(enc_name) = &col.encrypted_name {
824 md.push_str(&format!("**Encrypted Name:** {}\n\n", enc_name));
825 }
826
827 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 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 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 if let Some(default) = &col.default_value {
883 md.push_str(&format!("**Default:** {}\n\n", default));
884 }
885
886 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 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 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 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 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 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 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 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 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 if !table.odcl_metadata.is_empty() {
1006 md.push_str("---\n\n## ODCS Contract Metadata\n\n");
1007 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 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 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 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 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 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 let title = product.name.as_deref().unwrap_or(&product.id);
1058 md.push_str(&format!("# {}\n\n", title));
1059
1060 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 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 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 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 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 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 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 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 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 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 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 md.push_str(&format!("# {}\n\n", asset.name));
1267
1268 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 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 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 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 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 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 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 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 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 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 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 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 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 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.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 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 let pages_obj_position = xref_positions.len();
1557 xref_positions.push(0); let mut page_obj_ids: Vec<usize> = Vec::new();
1563 let font_obj_start = 3 + (page_count * 2); 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 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 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 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 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 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 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 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 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; let header_height = 100.0; 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 let logo_cx = margin + 15.0;
1695 let logo_cy = height - margin - 10.0;
1696 let logo_r = 12.0;
1697
1698 stream.push_str("q\n");
1700 stream.push_str("0 0.4 0.8 rg\n"); stream.push_str(&format!("{:.2} {:.2} m\n", logo_cx + logo_r, logo_cy));
1702 let k = 0.5523; 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"); stream.push_str("Q\n");
1742
1743 stream.push_str("q\n");
1745 stream.push_str("1 1 1 RG\n"); stream.push_str("2 w\n"); stream.push_str("1 J\n"); 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 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 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"); 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 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 stream.push_str("BT\n");
1789 let doc_type_y = height - margin - 48.0;
1790 stream.push_str("/F2 12 Tf\n"); stream.push_str("0.3 0.3 0.3 rg\n"); 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 stream.push_str("BT\n");
1801 stream.push_str("0 0 0 rg\n"); let title_y = height - margin - 68.0;
1803 stream.push_str("/F2 16 Tf\n"); 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 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 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 if page_num > 1 {
1828 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 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 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 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 if y_pos < content_bottom + line_height {
1877 render_page_header_footer(
1879 &mut stream,
1880 page_num,
1881 width,
1882 height,
1883 margin,
1884 footer_height,
1885 );
1886
1887 pages.push(stream);
1889 stream = String::new();
1890 page_num += 1;
1891 y_pos = height - margin - 30.0; }
1893
1894 let trimmed = line.trim();
1895
1896 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 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"); 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 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"); 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 if trimmed.starts_with("![") {
1932 continue;
1933 }
1934
1935 if trimmed.starts_with("©") || trimmed == DEFAULT_COPYRIGHT {
1937 continue;
1938 }
1939
1940 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
1942 y_pos -= line_height * 0.3;
1943 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 if trimmed.starts_with("|") && trimmed.ends_with("|") {
1957 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 let font_size = if in_table {
1973 body_font_size - 1.0
1974 } else {
1975 body_font_size
1976 };
1977 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); let mut wrapped_cells: Vec<(Vec<String>, bool)> = Vec::new();
1986 let mut max_lines = 1usize;
1987
1988 for cell in &cells {
1989 let (text, is_bold) = if cell.starts_with("**") && cell.ends_with("**") {
1991 (cell.trim_matches('*'), true)
1992 } else {
1993 (*cell, false)
1994 };
1995
1996 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 let row_height = line_height * max_lines as f64;
2004 if y_pos - row_height < content_bottom {
2005 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 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); 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 if trimmed.starts_with("# ") && !trimmed.starts_with("## ") {
2051 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 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 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 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 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 if trimmed.is_empty() {
2151 y_pos -= line_height * 0.5;
2152 continue;
2153 }
2154
2155 let display_text = self.strip_markdown_formatting(trimmed);
2157
2158 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 (display_text.as_str(), "/F1")
2167 } else {
2168 (display_text.as_str(), "/F1")
2169 };
2170
2171 let wrapped_lines = self.word_wrap(text, (max_width / (body_font_size * 0.5)) as usize);
2173 for wrapped_line in wrapped_lines {
2174 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_page_header_footer(&mut stream, page_num, width, height, margin, footer_height);
2201 pages.push(stream);
2202
2203 pages
2204 }
2205
2206 fn strip_markdown_formatting(&self, text: &str) -> String {
2208 let mut result = text.to_string();
2209
2210 while result.contains("**") {
2212 result = result.replacen("**", "", 2);
2213 }
2214
2215 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 if result[i + 1..].contains('*') {
2225 i += 1;
2227 continue;
2228 }
2229 }
2230 cleaned.push(chars[i]);
2231 i += 1;
2232 }
2233
2234 result = cleaned.replace('`', "");
2236
2237 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 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 while i < bytes.len() && bytes[i].is_ascii_digit() {
2261 i += 1;
2262 }
2263
2264 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 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 '©' => result.push_str("\\251"), '®' => result.push_str("\\256"), '™' => result.push_str("\\231"), '•' => result.push_str("\\267"), '–' => result.push_str("\\226"), '—' => result.push_str("\\227"), '…' => result.push_str("\\205"), _ if c.is_ascii() => result.push(c),
2292 _ => result.push('?'),
2294 }
2295 }
2296 result
2297 }
2298
2299 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 let effective_max = max_chars.max(5);
2307
2308 for word in text.split_whitespace() {
2309 if word.len() > effective_max {
2311 if !current_line.is_empty() {
2313 lines.push(current_line);
2314 current_line = String::new();
2315 }
2316 let broken = self.break_long_word(word, effective_max);
2318 for (i, chunk) in broken.iter().enumerate() {
2319 if i < broken.len() - 1 {
2320 lines.push(chunk.clone());
2322 } else {
2323 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 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 let continuation_marker = "-";
2356 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 chunks.push(chars[start..].iter().collect());
2365 break;
2366 } else {
2367 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 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 assert!(
2648 wrapped.len() > 1,
2649 "Long URL should be wrapped into multiple lines"
2650 );
2651
2652 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 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 let text = "See https://example.com/very/long/path/to/resource for details";
2678 let wrapped = exporter.word_wrap(text, 25);
2679
2680 assert!(wrapped.len() > 1);
2682
2683 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 let long_word = "abcdefghijklmnopqrstuvwxyz";
2694 let broken = exporter.break_long_word(long_word, 10);
2695
2696 assert!(broken.len() > 1);
2698
2699 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 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 #[test]
2748 #[ignore]
2749 fn generate_sample_pdfs_for_inspection() {
2750 use crate::models::decision::DecisionOption;
2751 use base64::Engine;
2752
2753 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 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, ),
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 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 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 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 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 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 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 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 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 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 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}