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