1use indexmap::{IndexMap, IndexSet};
4use serde::{Deserialize, Serialize};
5
6use crate::{
7 Manifest, McdPackage,
8 annotations::{
9 AnnotationMetadata, AnnotationTarget, load_manifest_annotations,
10 validate_annotation_markers,
11 },
12 directives::{ImagePlacement, TableDisplay, TablePlacement},
13 document::{AnnotationRef, DocumentBlock, McdDocument, SourceSpan},
14 errors::{Diagnostic, McdError},
15 images::{ImageMetadata, ImageRole},
16 manifest::ExternalDataManifestEntry,
17 provenance::{ProvenanceMetadata, load_manifest_provenance},
18 schema::{ColumnType, ForeignKeySchema, TableColumnSchema},
19 table_view::{ChartEncoding, TableView, ViewColumn},
20 tables::{DataTable, TableRow, TypedValue},
21};
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct JsonExport {
26 pub manifest: Manifest,
28 pub document: McdDocument,
30 #[serde(default, skip_serializing_if = "Vec::is_empty")]
32 pub tables: Vec<DataTable>,
33 #[serde(default, skip_serializing_if = "Vec::is_empty")]
35 pub views: Vec<TableViewExport>,
36 #[serde(default, skip_serializing_if = "Vec::is_empty")]
38 pub images: Vec<ImageMetadata>,
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
41 pub annotations: Vec<AnnotationMetadata>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub provenance: Option<ProvenanceMetadata>,
45 #[serde(default, skip_serializing_if = "Vec::is_empty")]
47 pub charts: Vec<ChartExportItem>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct TableExport {
53 pub tables: Vec<DataTable>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct ImageExport {
60 pub images: Vec<ImageMetadata>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct AnnotationExport {
67 pub annotations: Vec<AnnotationMetadata>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct ExternalDataExport {
75 pub external_data: Vec<ExternalDataManifestEntry>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct ProvenanceExport {
82 pub provenance: Option<ProvenanceMetadata>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub struct TableViewExport {
90 pub table_id: String,
92 pub views: Vec<TableView>,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct ChartExport {
99 pub charts: Vec<ChartExportItem>,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "camelCase")]
106pub struct ChartExportItem {
107 pub block_id: String,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub placement_ref: Option<String>,
112 pub table_id: String,
114 pub view_id: String,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub caption: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub source: Option<SourceSpan>,
122 pub view: TableView,
124 pub rows: Vec<TableRow>,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct SchemaSummaryExport {
131 pub schemas: Vec<TableSchemaSummary>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct TableSchemaSummary {
139 pub table_id: String,
141 #[serde(default, skip_serializing_if = "Vec::is_empty")]
143 pub primary_key: Vec<String>,
144 #[serde(default, skip_serializing_if = "Vec::is_empty")]
146 pub foreign_keys: Vec<ForeignKeySchema>,
147 pub columns: Vec<TableColumnSchema>,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154pub struct AgentContextExport {
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub title: Option<String>,
158 pub source_path: String,
160 pub blocks: Vec<DocumentBlock>,
162 #[serde(default, skip_serializing_if = "Vec::is_empty")]
164 pub tables: Vec<DataTable>,
165 #[serde(default, skip_serializing_if = "Vec::is_empty")]
167 pub charts: Vec<AgentChartContext>,
168 #[serde(default, skip_serializing_if = "Vec::is_empty")]
170 pub images: Vec<AgentImageContext>,
171 #[serde(default, skip_serializing_if = "Vec::is_empty")]
173 pub annotations: Vec<AnnotationMetadata>,
174 #[serde(default, skip_serializing_if = "Vec::is_empty")]
176 pub external_data: Vec<ExternalDataManifestEntry>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub provenance: Option<ProvenanceMetadata>,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "camelCase")]
185pub struct AgentChartContext {
186 pub block_id: String,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub placement_ref: Option<String>,
191 pub table_id: String,
193 pub view_id: String,
195 pub chart: serde_json::Value,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub style: Option<serde_json::Value>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub source: Option<SourceSpan>,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(rename_all = "camelCase")]
208pub struct AgentImageContext {
209 pub id: String,
211 pub asset: String,
213 pub role: ImageRole,
215 pub non_semantic: bool,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub alt: Option<String>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub caption: Option<String>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub meaningful_content: Option<crate::images::MeaningfulContent>,
226}
227
228pub fn json_export(package: &McdPackage) -> crate::Result<JsonExport> {
230 let manifest = package.manifest()?;
231 let document = McdDocument::from_package(package, &manifest)?;
232 let tables = crate::tables::load_manifest_tables(package, &manifest)?;
233 let views = load_manifest_table_views(package, &manifest)?;
234 let images = crate::images::load_manifest_images(package, &manifest)?;
235 let annotations = load_manifest_annotations(package, &manifest, &document)?;
236 validate_annotation_markers(&document, &annotations)?;
237 let provenance = load_manifest_provenance(package, &manifest)?;
238 let charts = chart_export_from_parts(&document, &tables, &views)?.charts;
239 Ok(JsonExport {
240 manifest,
241 document,
242 tables: tables.into_values().collect(),
243 views: views
244 .into_iter()
245 .map(|(table_id, table_views)| TableViewExport {
246 table_id,
247 views: table_views.into_values().collect(),
248 })
249 .collect(),
250 images: images.into_values().collect(),
251 annotations: annotations.into_values().collect(),
252 provenance,
253 charts,
254 })
255}
256
257pub fn original_markdown_export(package: &McdPackage) -> crate::Result<String> {
259 let manifest = package.manifest()?;
260 package.read_to_string(&manifest.entrypoint)
261}
262
263pub fn expanded_markdown_export(package: &McdPackage) -> crate::Result<String> {
265 let manifest = package.manifest()?;
266 let document = McdDocument::from_package(package, &manifest)?;
267 let tables = crate::tables::load_manifest_tables(package, &manifest)?;
268 let views = load_manifest_table_views(package, &manifest)?;
269 let images = crate::images::load_manifest_images(package, &manifest)?;
270 let annotations = load_manifest_annotations(package, &manifest, &document)?;
271 validate_annotation_markers(&document, &annotations)?;
272
273 let mut parts = Vec::new();
274 for block in &document.blocks {
275 parts.push(render_expanded_block(
276 block,
277 &document,
278 &tables,
279 &views,
280 &images,
281 &annotations,
282 )?);
283 }
284
285 Ok(parts
286 .into_iter()
287 .filter(|part| !part.trim().is_empty())
288 .collect::<Vec<_>>()
289 .join("\n\n"))
290}
291
292pub fn table_export(package: &McdPackage) -> crate::Result<TableExport> {
294 let manifest = package.manifest()?;
295 let tables = crate::tables::load_manifest_tables(package, &manifest)?;
296 Ok(TableExport {
297 tables: tables.into_values().collect(),
298 })
299}
300
301pub fn image_export(package: &McdPackage) -> crate::Result<ImageExport> {
303 let manifest = package.manifest()?;
304 let images = crate::images::load_manifest_images(package, &manifest)?
305 .into_values()
306 .collect();
307 Ok(ImageExport { images })
308}
309
310pub fn annotation_export(package: &McdPackage) -> crate::Result<AnnotationExport> {
312 let manifest = package.manifest()?;
313 let document = McdDocument::from_package(package, &manifest)?;
314 let annotations = load_manifest_annotations(package, &manifest, &document)?;
315 validate_annotation_markers(&document, &annotations)?;
316 Ok(AnnotationExport {
317 annotations: annotations.into_values().collect(),
318 })
319}
320
321pub fn external_data_export(package: &McdPackage) -> crate::Result<ExternalDataExport> {
323 let manifest = package.manifest()?;
324 Ok(ExternalDataExport {
325 external_data: manifest.external_data,
326 })
327}
328
329pub fn provenance_export(package: &McdPackage) -> crate::Result<ProvenanceExport> {
331 let manifest = package.manifest()?;
332 let provenance = load_manifest_provenance(package, &manifest)?;
333 Ok(ProvenanceExport { provenance })
334}
335
336pub fn chart_export(package: &McdPackage) -> crate::Result<ChartExport> {
338 let manifest = package.manifest()?;
339 let document = McdDocument::from_package(package, &manifest)?;
340 let tables = crate::tables::load_manifest_tables(package, &manifest)?;
341 let views = load_manifest_table_views(package, &manifest)?;
342 chart_export_from_parts(&document, &tables, &views)
343}
344
345pub fn schema_summary_export(package: &McdPackage) -> crate::Result<SchemaSummaryExport> {
347 let manifest = package.manifest()?;
348 let tables = crate::tables::load_manifest_tables(package, &manifest)?;
349 Ok(SchemaSummaryExport {
350 schemas: manifest
351 .tables
352 .iter()
353 .filter_map(|entry| tables.get(&entry.id))
354 .map(|table| TableSchemaSummary {
355 table_id: table.id.clone(),
356 primary_key: table.schema.primary_key.clone(),
357 foreign_keys: table.schema.foreign_keys.clone(),
358 columns: table.schema.columns.clone(),
359 })
360 .collect(),
361 })
362}
363
364pub fn agent_context_export(package: &McdPackage) -> crate::Result<AgentContextExport> {
366 let manifest = package.manifest()?;
367 let document = McdDocument::from_package(package, &manifest)?;
368 let tables = crate::tables::load_manifest_tables(package, &manifest)?;
369 let views = load_manifest_table_views(package, &manifest)?;
370 let images = crate::images::load_manifest_images(package, &manifest)?;
371 let annotations = load_manifest_annotations(package, &manifest, &document)?;
372 validate_annotation_markers(&document, &annotations)?;
373 let provenance = load_manifest_provenance(package, &manifest)?;
374 let chart_export = chart_export_from_parts(&document, &tables, &views)?;
375
376 Ok(AgentContextExport {
377 title: manifest.title.clone(),
378 source_path: document.source_path.clone(),
379 blocks: document.blocks,
380 tables: tables.into_values().collect(),
381 charts: chart_export
382 .charts
383 .into_iter()
384 .map(|chart| AgentChartContext {
385 block_id: chart.block_id,
386 placement_ref: chart.placement_ref,
387 table_id: chart.table_id,
388 view_id: chart.view_id,
389 chart: serde_json::to_value(chart.view.chart).unwrap_or(serde_json::Value::Null),
390 style: chart.view.style,
391 source: chart.source,
392 })
393 .collect(),
394 images: images
395 .into_values()
396 .map(|image| AgentImageContext {
397 id: image.id,
398 asset: image.asset,
399 role: image.role,
400 non_semantic: image.role == ImageRole::Decorative,
401 alt: image.alt,
402 caption: image.caption,
403 meaningful_content: image.meaningful_content,
404 })
405 .collect(),
406 annotations: annotations.into_values().collect(),
407 external_data: manifest.external_data,
408 provenance,
409 })
410}
411
412pub fn load_manifest_table_views(
414 package: &McdPackage,
415 manifest: &Manifest,
416) -> crate::Result<IndexMap<String, IndexMap<String, TableView>>> {
417 let mut all_views = IndexMap::new();
418 for table in &manifest.tables {
419 let mut table_views = IndexMap::new();
420 for (view_id, path) in &table.views {
421 let view = TableView::from_package(package, path)?;
422 table_views.insert(view_id.clone(), view);
423 }
424 all_views.insert(table.id.clone(), table_views);
425 }
426 Ok(all_views)
427}
428
429fn chart_export_from_parts(
430 document: &McdDocument,
431 tables: &IndexMap<String, DataTable>,
432 views: &IndexMap<String, IndexMap<String, TableView>>,
433) -> crate::Result<ChartExport> {
434 let mut charts = Vec::new();
435 for block in &document.blocks {
436 let DocumentBlock::TableRef {
437 id,
438 placement,
439 source,
440 } = block
441 else {
442 continue;
443 };
444 if placement.display != TableDisplay::Chart {
445 continue;
446 }
447
448 let view_id = placement.view.as_deref().ok_or_else(|| {
449 export_error(
450 "export.chart.view.missing",
451 "Chart placement does not include a view id.",
452 document,
453 *source,
454 )
455 })?;
456 let table = tables.get(&placement.table).ok_or_else(|| {
457 export_error(
458 "export.chart.table.missing",
459 format!(
460 "Chart placement references missing table '{}'.",
461 placement.table
462 ),
463 document,
464 *source,
465 )
466 })?;
467 let view = views
468 .get(&placement.table)
469 .and_then(|table_views| table_views.get(view_id))
470 .ok_or_else(|| {
471 export_error(
472 "export.chart.view.missing",
473 format!(
474 "Chart placement references missing view '{}' for table '{}'.",
475 view_id, placement.table
476 ),
477 document,
478 *source,
479 )
480 })?;
481
482 charts.push(ChartExportItem {
483 block_id: id.clone(),
484 placement_ref: placement.ref_id.clone(),
485 table_id: placement.table.clone(),
486 view_id: view_id.to_owned(),
487 caption: placement.caption.clone(),
488 source: *source,
489 view: view.clone(),
490 rows: table.rows.clone(),
491 });
492 }
493 Ok(ChartExport { charts })
494}
495
496fn render_expanded_block(
497 block: &DocumentBlock,
498 document: &McdDocument,
499 tables: &IndexMap<String, DataTable>,
500 views: &IndexMap<String, IndexMap<String, TableView>>,
501 images: &IndexMap<String, ImageMetadata>,
502 annotations: &IndexMap<String, AnnotationMetadata>,
503) -> crate::Result<String> {
504 let markdown: crate::Result<String> = match block {
505 DocumentBlock::Heading { level, text, .. } => Ok(format!(
506 "{} {}",
507 "#".repeat(usize::from(*level)),
508 render_annotated_markdown_text(text, block.annotation_refs(), annotations)
509 )),
510 DocumentBlock::Paragraph { text, .. } => Ok(render_annotated_markdown_text(
511 text,
512 block.annotation_refs(),
513 annotations,
514 )),
515 DocumentBlock::List { text, .. } => {
516 Ok(
517 render_annotated_markdown_text(text, block.annotation_refs(), annotations)
518 .lines()
519 .map(|line| format!("- {line}"))
520 .collect::<Vec<_>>()
521 .join("\n"),
522 )
523 }
524 DocumentBlock::CodeBlock { language, text, .. } => {
525 let mut parts = vec![format!(
526 "```{}\n{}\n```",
527 language.as_deref().unwrap_or_default(),
528 text.trim_end()
529 )];
530 parts.extend(block_annotation_markdown(
531 block.annotation_refs(),
532 annotations,
533 ));
534 Ok(parts.join("\n"))
535 }
536 DocumentBlock::Quote { text, .. } => {
537 Ok(
538 render_annotated_markdown_text(text, block.annotation_refs(), annotations)
539 .lines()
540 .map(|line| format!("> {line}"))
541 .collect::<Vec<_>>()
542 .join("\n"),
543 )
544 }
545 DocumentBlock::MathBlock { text, .. } => Ok(format!("$$\n{}\n$$", text.trim())),
546 DocumentBlock::TableRef { placement, .. } => {
547 let mut parts = vec![render_table_placement(placement, tables, views)?];
548 parts.extend(block_annotation_markdown(
549 block.annotation_refs(),
550 annotations,
551 ));
552 Ok(parts.join("\n\n"))
553 }
554 DocumentBlock::ImageRef { placement, .. } => {
555 let mut parts = vec![render_image_placement(placement, images)?];
556 parts.extend(block_annotation_markdown(
557 block.annotation_refs(),
558 annotations,
559 ));
560 Ok(parts.join("\n\n"))
561 }
562 };
563 let markdown = markdown?;
564
565 Ok(append_path_annotations(
566 markdown,
567 block,
568 document,
569 annotations,
570 ))
571}
572
573fn render_annotated_markdown_text(
574 text: &str,
575 refs: &[AnnotationRef],
576 annotations: &IndexMap<String, AnnotationMetadata>,
577) -> String {
578 let mut inline_refs = refs
579 .iter()
580 .filter_map(|annotation_ref| {
581 annotation_ref
582 .text_offset
583 .map(|offset| (offset, annotation_ref))
584 })
585 .collect::<Vec<_>>();
586 inline_refs.sort_by_key(|(offset, _)| *offset);
587
588 let mut markdown = String::new();
589 let mut cursor = 0;
590 for (offset, annotation_ref) in inline_refs {
591 if offset > text.len() || offset < cursor {
592 continue;
593 }
594 markdown.push_str(&text[cursor..offset]);
595 if let Some(annotation) = annotations.get(&annotation_ref.id) {
596 markdown.push_str(&annotation_markdown(annotation));
597 }
598 cursor = offset;
599 }
600 markdown.push_str(&text[cursor..]);
601
602 let block_annotations = refs
603 .iter()
604 .filter(|annotation_ref| annotation_ref.text_offset.is_none())
605 .filter_map(|annotation_ref| annotations.get(&annotation_ref.id))
606 .map(annotation_markdown)
607 .collect::<Vec<_>>();
608 if !block_annotations.is_empty() {
609 if !markdown.is_empty() {
610 markdown.push('\n');
611 }
612 markdown.push_str(&block_annotations.join("\n"));
613 }
614
615 markdown
616}
617
618fn block_annotation_markdown(
619 refs: &[AnnotationRef],
620 annotations: &IndexMap<String, AnnotationMetadata>,
621) -> Vec<String> {
622 refs.iter()
623 .filter_map(|annotation_ref| annotations.get(&annotation_ref.id))
624 .map(annotation_markdown)
625 .collect()
626}
627
628fn append_path_annotations(
629 mut markdown: String,
630 block: &DocumentBlock,
631 document: &McdDocument,
632 annotations: &IndexMap<String, AnnotationMetadata>,
633) -> String {
634 let path_annotations = annotations
635 .values()
636 .filter(|annotation| path_annotation_matches_block(annotation, block, document))
637 .map(annotation_markdown)
638 .collect::<Vec<_>>();
639 if path_annotations.is_empty() {
640 return markdown;
641 }
642 if !markdown.trim().is_empty() {
643 markdown.push('\n');
644 }
645 markdown.push_str(&path_annotations.join("\n"));
646 markdown
647}
648
649fn path_annotation_matches_block(
650 annotation: &AnnotationMetadata,
651 block: &DocumentBlock,
652 document: &McdDocument,
653) -> bool {
654 let AnnotationTarget::Path { path, source } = &annotation.target else {
655 return false;
656 };
657 if path != &document.source_path {
658 return false;
659 }
660 let Some(annotation_source) = source else {
661 return block_index_is_first(block);
662 };
663 let Some(block_source) = block_source(block) else {
664 return false;
665 };
666 spans_overlap(*annotation_source, block_source)
667}
668
669fn block_index_is_first(block: &DocumentBlock) -> bool {
670 matches!(
671 block
672 .id()
673 .strip_prefix("block-")
674 .and_then(|rest| rest.get(..4)),
675 Some("0001")
676 )
677}
678
679fn block_source(block: &DocumentBlock) -> Option<SourceSpan> {
680 match block {
681 DocumentBlock::Heading { source, .. }
682 | DocumentBlock::Paragraph { source, .. }
683 | DocumentBlock::List { source, .. }
684 | DocumentBlock::CodeBlock { source, .. }
685 | DocumentBlock::Quote { source, .. }
686 | DocumentBlock::MathBlock { source, .. }
687 | DocumentBlock::TableRef { source, .. }
688 | DocumentBlock::ImageRef { source, .. } => *source,
689 }
690}
691
692fn spans_overlap(left: SourceSpan, right: SourceSpan) -> bool {
693 left.start_line <= right.end_line && right.start_line <= left.end_line
694}
695
696fn annotation_markdown(annotation: &AnnotationMetadata) -> String {
697 format!(
698 "(@annotation: [{}])",
699 escape_annotation_text(&annotation.body)
700 )
701}
702
703fn escape_annotation_text(value: &str) -> String {
704 escape_markdown_text(value).replace(']', r"\]")
705}
706
707fn render_table_placement(
708 placement: &TablePlacement,
709 tables: &IndexMap<String, DataTable>,
710 views: &IndexMap<String, IndexMap<String, TableView>>,
711) -> crate::Result<String> {
712 let table = tables.get(&placement.table).ok_or_else(|| {
713 simple_export_error(
714 "export.table.missing",
715 format!("Table '{}' is not available for export.", placement.table),
716 )
717 })?;
718 let view = placement
719 .view
720 .as_deref()
721 .and_then(|view_id| views.get(&placement.table)?.get(view_id));
722
723 let mut parts = Vec::new();
724 if let Some(caption) = &placement.caption {
725 parts.push(format!("**{}**", escape_markdown_text(caption)));
726 }
727 if placement.display == TableDisplay::Chart {
728 let view_id = placement.view.as_deref().unwrap_or("default");
729 let chart_type = view
730 .and_then(|view| view.chart.as_ref())
731 .map(|chart| format!("{:?}", chart.chart_type).to_ascii_lowercase())
732 .unwrap_or_else(|| "chart".to_owned());
733 parts.push(format!(
734 "**Chart metadata:** table `{}`, view `{}`, type `{}`.",
735 placement.table, view_id, chart_type
736 ));
737 }
738 parts.push(markdown_table_for_placement(
739 table,
740 view,
741 placement.display,
742 )?);
743 Ok(parts.join("\n\n"))
744}
745
746fn render_image_placement(
747 placement: &ImagePlacement,
748 images: &IndexMap<String, ImageMetadata>,
749) -> crate::Result<String> {
750 let image = resolve_image_placement(placement, images).ok_or_else(|| {
751 simple_export_error(
752 "export.image.missing",
753 "Image placement does not resolve to metadata.",
754 )
755 })?;
756 let alt = placement.alt.as_ref().or(image.alt.as_ref());
757 let caption = placement.caption.as_ref().or(image.caption.as_ref());
758
759 let mut parts = Vec::new();
760 if image.role != ImageRole::Decorative {
761 parts.push(format!(
762 "",
763 escape_markdown_text(alt.map(String::as_str).unwrap_or_default()),
764 image.asset
765 ));
766 }
767 if let Some(caption) = caption {
768 parts.push(format!("*{}*", escape_markdown_text(caption)));
769 }
770 if let Some(alt) = alt {
771 parts.push(format!("Alt text: {}", escape_markdown_text(alt)));
772 }
773 Ok(parts.join("\n\n"))
774}
775
776fn markdown_table_for_placement(
777 table: &DataTable,
778 view: Option<&TableView>,
779 display: TableDisplay,
780) -> crate::Result<String> {
781 let columns = column_specs(table, view, display)?;
782 let headers = columns
783 .iter()
784 .map(|column| escape_table_cell(&column.label))
785 .collect::<Vec<_>>();
786 let alignments = columns
787 .iter()
788 .map(|column| alignment_marker(column.column_type))
789 .collect::<Vec<_>>();
790
791 let mut lines = Vec::new();
792 lines.push(format!("| {} |", headers.join(" | ")));
793 lines.push(format!("| {} |", alignments.join(" | ")));
794 for row in &table.rows {
795 let cells = columns
796 .iter()
797 .map(|column| {
798 row.cells
799 .get(&column.name)
800 .map(|value| escape_table_cell(&format_value(value, column)))
801 .unwrap_or_default()
802 })
803 .collect::<Vec<_>>();
804 lines.push(format!("| {} |", cells.join(" | ")));
805 }
806 Ok(lines.join("\n"))
807}
808
809fn column_specs(
810 table: &DataTable,
811 view: Option<&TableView>,
812 display: TableDisplay,
813) -> crate::Result<Vec<ColumnExportSpec>> {
814 if display == TableDisplay::Chart
815 && let Some(view) = view
816 && let Some(chart) = &view.chart
817 {
818 let mut names = IndexSet::new();
819 names.insert(chart.x.column.clone());
820 names.insert(chart.y.column.clone());
821 if let Some(series) = &chart.series {
822 names.insert(series.column.clone());
823 }
824 if let Some(grouping) = &chart.grouping {
825 names.insert(grouping.column.clone());
826 }
827 if let Some(mark_labels) = &chart.mark_labels
828 && let Some(column) = &mark_labels.column
829 {
830 names.insert(column.clone());
831 }
832 return names
833 .into_iter()
834 .map(|name| {
835 let encoding = encoding_for_column(view, &name);
836 spec_from_schema(table, &name, encoding)
837 })
838 .collect();
839 }
840
841 if let Some(view) = view
842 && !view.columns.is_empty()
843 {
844 return view
845 .columns
846 .iter()
847 .map(|column| spec_from_view_column(table, column))
848 .collect();
849 }
850
851 Ok(table
852 .schema
853 .columns
854 .iter()
855 .map(|column| spec_from_column_schema(column, None, None, None, false))
856 .collect())
857}
858
859fn encoding_for_column<'a>(view: &'a TableView, name: &str) -> Option<&'a ChartEncoding> {
860 let chart = view.chart.as_ref()?;
861 [&chart.x, &chart.y]
862 .into_iter()
863 .chain(chart.series.as_ref())
864 .chain(chart.grouping.as_ref())
865 .find(|encoding| encoding.column == name)
866}
867
868fn spec_from_view_column(
869 table: &DataTable,
870 column: &ViewColumn,
871) -> crate::Result<ColumnExportSpec> {
872 let schema_column = table.schema.column(&column.name).ok_or_else(|| {
873 simple_export_error(
874 "export.view.column.missing",
875 format!("View references missing column '{}'.", column.name),
876 )
877 })?;
878 Ok(spec_from_column_schema(
879 schema_column,
880 column.label.as_deref(),
881 column.format.as_deref(),
882 column.currency.as_deref().or(column.unit_label.as_deref()),
883 column.percent,
884 ))
885}
886
887fn spec_from_schema(
888 table: &DataTable,
889 name: &str,
890 encoding: Option<&ChartEncoding>,
891) -> crate::Result<ColumnExportSpec> {
892 let schema_column = table.schema.column(name).ok_or_else(|| {
893 simple_export_error(
894 "export.chart.column.missing",
895 format!("Chart references missing column '{name}'."),
896 )
897 })?;
898 Ok(spec_from_column_schema(
899 schema_column,
900 encoding.and_then(|encoding| encoding.label.as_deref()),
901 encoding.and_then(|encoding| encoding.format.as_deref()),
902 encoding.and_then(|encoding| {
903 encoding
904 .currency
905 .as_deref()
906 .or(encoding.unit_label.as_deref())
907 }),
908 encoding.is_some_and(|encoding| encoding.percent),
909 ))
910}
911
912fn spec_from_column_schema(
913 column: &TableColumnSchema,
914 view_label: Option<&str>,
915 format: Option<&str>,
916 suffix_or_currency: Option<&str>,
917 percent: bool,
918) -> ColumnExportSpec {
919 ColumnExportSpec {
920 name: column.name.clone(),
921 label: view_label
922 .or(column.label.as_deref())
923 .unwrap_or(&column.name)
924 .to_owned(),
925 column_type: column.value_type,
926 format: format.map(ToOwned::to_owned),
927 suffix_or_currency: suffix_or_currency
928 .or_else(|| column.unit.as_ref().and_then(|unit| unit.display_label()))
929 .map(ToOwned::to_owned),
930 percent,
931 }
932}
933
934#[derive(Debug, Clone, PartialEq, Eq)]
935struct ColumnExportSpec {
936 name: String,
937 label: String,
938 column_type: ColumnType,
939 format: Option<String>,
940 suffix_or_currency: Option<String>,
941 percent: bool,
942}
943
944fn format_value(value: &TypedValue, column: &ColumnExportSpec) -> String {
945 let raw = match value {
946 TypedValue::Null => return String::new(),
947 TypedValue::String(value)
948 | TypedValue::Decimal(value)
949 | TypedValue::Date(value)
950 | TypedValue::Datetime(value)
951 | TypedValue::Time(value)
952 | TypedValue::Enum(value) => value.clone(),
953 TypedValue::Integer(value) => value.to_string(),
954 TypedValue::Boolean(value) => value.to_string(),
955 };
956
957 match column.format.as_deref() {
958 Some("currency") => match &column.suffix_or_currency {
959 Some(currency) => format!("{currency} {raw}"),
960 None => raw,
961 },
962 Some("percent") => format!("{raw}%"),
963 Some("number" | "date" | "datetime" | "time" | "string") | None => {
964 if column.percent {
965 format!("{raw}%")
966 } else if let Some(unit) = &column.suffix_or_currency
967 && column.format.as_deref() != Some("currency")
968 {
969 format!("{raw} {unit}")
970 } else {
971 raw
972 }
973 }
974 Some(_) => raw,
975 }
976}
977
978fn alignment_marker(column_type: ColumnType) -> &'static str {
979 match column_type {
980 ColumnType::Integer | ColumnType::Decimal => "---:",
981 ColumnType::Boolean => ":---:",
982 ColumnType::String
983 | ColumnType::Date
984 | ColumnType::Datetime
985 | ColumnType::Time
986 | ColumnType::Enum => "---",
987 }
988}
989
990fn resolve_image_placement<'a>(
991 placement: &ImagePlacement,
992 images: &'a IndexMap<String, ImageMetadata>,
993) -> Option<&'a ImageMetadata> {
994 if let Some(image_id) = &placement.image {
995 return images.get(image_id);
996 }
997 let asset = placement.asset.as_deref()?;
998 images
999 .get(asset)
1000 .or_else(|| images.values().find(|image| image.asset == asset))
1001 .or_else(|| {
1002 images
1003 .values()
1004 .find(|image| image.asset.strip_prefix("assets/") == Some(asset))
1005 })
1006}
1007
1008fn escape_table_cell(value: &str) -> String {
1009 escape_markdown_text(value).replace('|', r"\|")
1010}
1011
1012fn escape_markdown_text(value: &str) -> String {
1013 value.replace('\n', " ")
1014}
1015
1016fn export_error(
1017 code: impl Into<String>,
1018 message: impl Into<String>,
1019 document: &McdDocument,
1020 source: Option<SourceSpan>,
1021) -> McdError {
1022 let source = source
1023 .map(|span| format!("{}:{span}", document.source_path))
1024 .unwrap_or_else(|| document.source_path.clone());
1025 McdError::from_diagnostic(Diagnostic::error(code, message).with_source(source))
1026}
1027
1028fn simple_export_error(code: impl Into<String>, message: impl Into<String>) -> McdError {
1029 McdError::from_diagnostic(Diagnostic::error(code, message))
1030}
1031
1032#[cfg(test)]
1033mod tests {
1034 use super::*;
1035 use std::io::{Cursor, Write};
1036 use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
1037
1038 #[test]
1039 fn expanded_markdown_renders_table_and_chart_source_data() {
1040 let package = package_with(
1041 "# Revenue\n\n:::table\ntable: revenue\nview: default\ncaption: Revenue table\n:::\n\n:::table\ntable: revenue\nview: chart\ndisplay: chart\ncaption: Revenue chart\n:::\n",
1042 );
1043
1044 let markdown = expanded_markdown_export(&package).expect("expanded markdown");
1045
1046 assert!(markdown.contains("**Revenue table**"));
1047 assert!(markdown.contains("| Quarter | Revenue |"));
1048 assert!(markdown.contains("| Q1 | GBP 125000 |"));
1049 assert!(
1050 markdown.contains("**Chart metadata:** table `revenue`, view `chart`, type `bar`.")
1051 );
1052 }
1053
1054 #[test]
1055 fn expanded_markdown_embeds_annotation_metadata() {
1056 let package = package_with_annotations(
1057 "# Revenue\n\nRevenue[[annotation:review-intro]] increased.\n\n:::table\nref: revenue-table\ntable: revenue\nannotations: review-table\n:::\n",
1058 );
1059
1060 let markdown = expanded_markdown_export(&package).expect("expanded markdown");
1061
1062 assert!(markdown.contains("Revenue(@annotation: [Review the opening copy.]) increased."));
1063 assert!(markdown.contains("(@annotation: [Line-level follow-up.])"));
1064 assert!(markdown.contains("(@annotation: [Review table totals.])"));
1065 }
1066
1067 #[test]
1068 fn expanded_markdown_preserves_inline_math() {
1069 let package = package_with("# Math\n\nInline $x^2 + y^2 = z^2$ remains math.\n");
1070
1071 let markdown = expanded_markdown_export(&package).expect("expanded markdown");
1072
1073 assert!(markdown.contains("Inline $x^2 + y^2 = z^2$ remains math."));
1074 }
1075
1076 #[test]
1077 fn chart_export_contains_exact_typed_rows_and_view_refs() {
1078 let package = package_with(
1079 ":::table\nref: revenue-chart\ntable: revenue\nview: chart\ndisplay: chart\n:::\n",
1080 );
1081
1082 let charts = chart_export(&package).expect("chart export");
1083
1084 assert_eq!(charts.charts.len(), 1);
1085 assert_eq!(charts.charts[0].table_id, "revenue");
1086 assert_eq!(charts.charts[0].view_id, "chart");
1087 assert_eq!(charts.charts[0].rows.len(), 1);
1088 }
1089
1090 #[test]
1091 fn agent_context_marks_decorative_images_non_semantic() {
1092 let package = McdPackage::from_bytes(&zip_bytes(&[
1093 ("mimetype", crate::package::MCD_MIMETYPE),
1094 (
1095 "manifest.json",
1096 r#"{"format":"MCD","version":"0.1","profile":"MCD-Core","entrypoint":"content/main.md","images":[{"id":"logo","metadata":"images/logo.image.json"}]}"#,
1097 ),
1098 ("content/main.md", ":::image\nimage: logo\n:::\n"),
1099 ("assets/logo.svg", r#"<svg xmlns="http://www.w3.org/2000/svg"/>"#),
1100 (
1101 "images/logo.image.json",
1102 r#"{"id":"logo","asset":"assets/logo.svg","mediaType":"image/svg+xml","role":"decorative","alt":""}"#,
1103 ),
1104 ]))
1105 .expect("package opens");
1106
1107 let context = agent_context_export(&package).expect("agent context");
1108
1109 assert_eq!(context.images.len(), 1);
1110 assert!(context.images[0].non_semantic);
1111 }
1112
1113 fn package_with(markdown: &str) -> McdPackage {
1114 McdPackage::from_bytes(&zip_bytes(&[
1115 ("mimetype", crate::package::MCD_MIMETYPE),
1116 ("manifest.json", manifest()),
1117 ("content/main.md", markdown),
1118 ("tables/revenue.csv", "quarter,revenue_gbp\nQ1,125000.00\n"),
1119 (
1120 "tables/revenue.schema.json",
1121 r#"{"id":"revenue","columns":[
1122 {"name":"quarter","type":"string","label":"Quarter"},
1123 {"name":"revenue_gbp","type":"decimal","label":"Revenue"}
1124 ]}"#,
1125 ),
1126 (
1127 "tables/revenue.view.json",
1128 r#"{"id":"default","table":"revenue","columns":[
1129 {"name":"quarter","label":"Quarter"},
1130 {"name":"revenue_gbp","label":"Revenue","format":"currency","currency":"GBP"}
1131 ]}"#,
1132 ),
1133 (
1134 "tables/revenue.chart.view.json",
1135 r#"{"id":"chart","table":"revenue","display":"chart","chart":{
1136 "type":"bar",
1137 "x":{"column":"quarter","label":"Quarter"},
1138 "y":{"column":"revenue_gbp","label":"Revenue","format":"currency","currency":"GBP"}
1139 }}"#,
1140 ),
1141 ]))
1142 .expect("package opens")
1143 }
1144
1145 fn package_with_annotations(markdown: &str) -> McdPackage {
1146 McdPackage::from_bytes(&zip_bytes(&[
1147 ("mimetype", crate::package::MCD_MIMETYPE),
1148 ("manifest.json", annotation_manifest()),
1149 ("content/main.md", markdown),
1150 ("tables/revenue.csv", "quarter,revenue_gbp\nQ1,125000.00\n"),
1151 (
1152 "tables/revenue.schema.json",
1153 r#"{"id":"revenue","columns":[
1154 {"name":"quarter","type":"string","label":"Quarter"},
1155 {"name":"revenue_gbp","type":"decimal","label":"Revenue"}
1156 ]}"#,
1157 ),
1158 (
1159 "annotations/review-intro.annotation.json",
1160 r#"{"id":"review-intro","target":{"type":"document"},"kind":"comment","status":"open","body":"Review the opening copy."}"#,
1161 ),
1162 (
1163 "annotations/review-line.annotation.json",
1164 r#"{"id":"review-line","target":{"type":"path","path":"content/main.md","source":{"startLine":3,"startColumn":1,"endLine":3,"endColumn":1}},"kind":"comment","status":"open","body":"Line-level follow-up."}"#,
1165 ),
1166 (
1167 "annotations/review-table.annotation.json",
1168 r#"{"id":"review-table","target":{"type":"placement","ref":"revenue-table"},"kind":"comment","status":"open","body":"Review table totals."}"#,
1169 ),
1170 ]))
1171 .expect("package opens")
1172 }
1173
1174 fn manifest() -> &'static str {
1175 r#"{
1176 "format":"MCD",
1177 "version":"0.1",
1178 "profile":"MCD-Core",
1179 "entrypoint":"content/main.md",
1180 "tables":[{
1181 "id":"revenue",
1182 "data":"tables/revenue.csv",
1183 "schema":"tables/revenue.schema.json",
1184 "views":{
1185 "default":"tables/revenue.view.json",
1186 "chart":"tables/revenue.chart.view.json"
1187 }
1188 }]
1189 }"#
1190 }
1191
1192 fn annotation_manifest() -> &'static str {
1193 r#"{
1194 "format":"MCD",
1195 "version":"0.1",
1196 "profile":"MCD-Core",
1197 "entrypoint":"content/main.md",
1198 "tables":[{
1199 "id":"revenue",
1200 "data":"tables/revenue.csv",
1201 "schema":"tables/revenue.schema.json"
1202 }],
1203 "annotations":[
1204 {"id":"review-intro","metadata":"annotations/review-intro.annotation.json"},
1205 {"id":"review-line","metadata":"annotations/review-line.annotation.json"},
1206 {"id":"review-table","metadata":"annotations/review-table.annotation.json"}
1207 ]
1208 }"#
1209 }
1210
1211 fn zip_bytes(entries: &[(&str, &str)]) -> Vec<u8> {
1212 let cursor = Cursor::new(Vec::new());
1213 let mut writer = ZipWriter::new(cursor);
1214 let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
1215
1216 for (path, content) in entries {
1217 writer.start_file(*path, options).expect("start file");
1218 writer.write_all(content.as_bytes()).expect("write file");
1219 }
1220
1221 writer.finish().expect("finish zip").into_inner()
1222 }
1223}