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