1use std::collections::BTreeMap;
9use std::collections::BTreeSet;
10use std::path::Path;
11
12use zenith_core::{AssetKind, BytesAssetProvider, KdlAdapter, KdlSource, Severity};
13use zenith_render::render_png;
14use zenith_scene::compile_page;
15use zenith_tx::{Op, OpSpan, Transaction, TxStatus, run_transaction};
16
17use crate::json_types::{DiagnosticJson, MergeOutput, MergeRowResult};
18
19use crate::commands::render::{
20 build_asset_provider, build_font_provider, collect_missing_asset_diagnostics,
21 resolve_text_sources,
22};
23
24#[derive(Debug)]
32pub struct MergeError {
33 pub message: String,
35 pub exit_code: u8,
37}
38
39impl MergeError {
40 fn new(msg: impl Into<String>) -> Self {
41 Self {
42 message: msg.into(),
43 exit_code: 2,
44 }
45 }
46}
47
48#[derive(Debug)]
52pub struct RowResult {
53 pub row: usize,
55 pub key: Option<String>,
57 pub outputs: Vec<String>,
59 pub failure: Option<String>,
61}
62
63#[derive(Debug)]
66pub struct MergeReport {
67 pub rows: Vec<RowResult>,
68}
69
70impl MergeReport {
71 pub fn written(&self) -> Vec<String> {
73 self.rows
74 .iter()
75 .flat_map(|r| r.outputs.iter().cloned())
76 .collect()
77 }
78 pub fn failed(&self) -> Vec<&RowResult> {
80 self.rows.iter().filter(|r| r.failure.is_some()).collect()
81 }
82}
83
84struct DataBinding {
88 node_id: String,
89 column: String,
90}
91
92struct AssetBinding {
94 node_id: String,
95 column: String,
96}
97
98fn reject_data_role_on_non_text(role: Option<&str>, id: &str) -> Result<(), MergeError> {
104 if let Some(role) = role
105 && role.starts_with("data.")
106 {
107 return Err(MergeError::new(format!(
108 "role=\"{}\" on non-text node {}: replace_text supports text nodes only",
109 role, id
110 )));
111 }
112 Ok(())
113}
114
115fn collect_data_nodes(
124 nodes: &[zenith_core::Node],
125 out: &mut Vec<DataBinding>,
126 asset_out: &mut Vec<AssetBinding>,
127) -> Result<(), MergeError> {
128 for node in nodes {
129 match node {
130 zenith_core::Node::Text(n) => {
131 if let Some(role) = n.role.as_deref()
132 && let Some(col) = role.strip_prefix("data.")
133 {
134 out.push(DataBinding {
135 node_id: n.id.clone(),
136 column: col.to_owned(),
137 });
138 }
139 }
140 zenith_core::Node::Image(n) => {
141 if let Some(role) = n.role.as_deref()
142 && let Some(col) = role.strip_prefix("data.")
143 {
144 asset_out.push(AssetBinding {
145 node_id: n.id.clone(),
146 column: col.to_owned(),
147 });
148 }
149 }
150 zenith_core::Node::Rect(n) => {
151 reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
152 }
153 zenith_core::Node::Ellipse(n) => {
154 reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
155 }
156 zenith_core::Node::Line(n) => {
157 reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
158 }
159 zenith_core::Node::Code(n) => {
160 reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
161 }
162 zenith_core::Node::Frame(n) => {
163 reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
164 collect_data_nodes(&n.children, out, asset_out)?;
165 }
166 zenith_core::Node::Group(n) => {
167 reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
168 collect_data_nodes(&n.children, out, asset_out)?;
169 }
170 zenith_core::Node::Polygon(n) => {
171 reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
172 }
173 zenith_core::Node::Polyline(n) => {
174 reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
175 }
176 zenith_core::Node::Instance(n) => {
177 reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
178 }
179 zenith_core::Node::Field(n) => {
180 reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
181 }
182 zenith_core::Node::Toc(n) => {
183 reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
184 }
185 zenith_core::Node::Footnote(n) => {
186 reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
187 }
188 zenith_core::Node::Table(n) => {
189 reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
190 for row in &n.rows {
191 for cell in &row.cells {
192 collect_data_nodes(&cell.children, out, asset_out)?;
193 }
194 }
195 }
196 zenith_core::Node::Shape(n) => {
197 reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
198 }
199 zenith_core::Node::Connector(n) => {
200 reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
201 }
202 zenith_core::Node::Pattern(n) => {
203 reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
204 }
205 zenith_core::Node::Chart(n) => {
206 reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
207 }
208 zenith_core::Node::Light(n) => {
209 reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
210 }
211 zenith_core::Node::Mesh(n) => {
212 reject_data_role_on_non_text(n.role.as_deref(), &n.id)?;
213 }
214 zenith_core::Node::Unknown(_n) => {
215 }
218 }
219 }
220 Ok(())
221}
222
223pub fn sanitize_filename(s: &str) -> String {
228 let mapped: String = s
229 .chars()
230 .map(|c| match c {
231 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_',
232 other => other,
233 })
234 .collect();
235 let trimmed = mapped.trim_matches(|c: char| c == '.' || c.is_whitespace());
236 if trimmed.is_empty() {
237 "_".to_owned()
238 } else {
239 trimmed.to_owned()
240 }
241}
242
243pub fn run(
262 doc_src: &str,
263 csv_src: &str,
264 project_dir: Option<&Path>,
265 out_dir: &Path,
266 name_by: Option<&str>,
267) -> Result<MergeReport, MergeError> {
268 let doc = KdlAdapter
270 .parse(doc_src.as_bytes())
271 .map_err(|e| MergeError::new(format!("error[parse.error]: {}", e.message)))?;
272
273 let mut bindings: Vec<DataBinding> = Vec::new();
275 let mut asset_bindings: Vec<AssetBinding> = Vec::new();
276 for page in &doc.body.pages {
277 collect_data_nodes(&page.children, &mut bindings, &mut asset_bindings)?;
278 }
279 if bindings.is_empty() && asset_bindings.is_empty() {
280 return Err(MergeError::new("no role=\"data.*\" template nodes found"));
281 }
282
283 if !asset_bindings.is_empty() && project_dir.is_none() {
285 return Err(MergeError::new(
286 "image data bindings require a project directory (the .zen file must be on disk)",
287 ));
288 }
289
290 let mut reader = csv::Reader::from_reader(csv_src.as_bytes());
292 let headers = reader
293 .headers()
294 .map_err(|e| MergeError::new(format!("CSV header error: {}", e)))?
295 .clone();
296
297 let header_index: BTreeMap<String, usize> = headers
299 .iter()
300 .enumerate()
301 .map(|(i, h)| (h.to_owned(), i))
302 .collect();
303
304 let unknown: Vec<String> = bindings
306 .iter()
307 .filter(|b| !header_index.contains_key(&b.column))
308 .map(|b| b.column.clone())
309 .collect();
310 if !unknown.is_empty() {
311 return Err(MergeError::new(format!(
312 "CSV column(s) not found in header: {}",
313 unknown.join(", ")
314 )));
315 }
316
317 let unknown_asset: Vec<String> = asset_bindings
319 .iter()
320 .filter(|b| !header_index.contains_key(&b.column))
321 .map(|b| b.column.clone())
322 .collect();
323 if !unknown_asset.is_empty() {
324 return Err(MergeError::new(format!(
325 "CSV column(s) not found in header: {}",
326 unknown_asset.join(", ")
327 )));
328 }
329
330 if let Some(col) = name_by
332 && !header_index.contains_key(col)
333 {
334 return Err(MergeError::new(format!(
335 "--name-by column {:?} not found in CSV header",
336 col
337 )));
338 }
339
340 let binding_indices: Vec<usize> = bindings
343 .iter()
344 .map(|b| -> Result<usize, MergeError> {
345 header_index
346 .get(&b.column)
347 .copied()
348 .ok_or_else(|| MergeError::new(format!("column {:?} not found", b.column)))
349 })
350 .collect::<Result<Vec<usize>, MergeError>>()?;
351
352 let asset_binding_indices: Vec<usize> = asset_bindings
353 .iter()
354 .map(|b| -> Result<usize, MergeError> {
355 header_index
356 .get(&b.column)
357 .copied()
358 .ok_or_else(|| MergeError::new(format!("column {:?} not found", b.column)))
359 })
360 .collect::<Result<Vec<usize>, MergeError>>()?;
361
362 let name_by_index: Option<usize> = match name_by {
363 None => None,
364 Some(col) => Some(
365 header_index
366 .get(col)
367 .copied()
368 .ok_or_else(|| MergeError::new(format!("--name-by column {:?} not found", col)))?,
369 ),
370 };
371
372 let fonts =
374 build_font_provider(&doc, project_dir, false).map_err(|e| MergeError::new(e.message))?;
375 let template_assets = match project_dir {
377 Some(dir) => {
378 build_asset_provider(&doc, dir, false).map_err(|e| MergeError::new(e.message))?
379 }
380 None => BytesAssetProvider::new(),
381 };
382
383 std::fs::create_dir_all(out_dir).map_err(|e| {
385 MergeError::new(format!(
386 "could not create output directory '{}': {}",
387 out_dir.display(),
388 e
389 ))
390 })?;
391
392 let mut rows: Vec<RowResult> = Vec::new();
394 let mut used_names: BTreeSet<String> = BTreeSet::new();
395
396 for (row_idx, record_result) in reader.records().enumerate() {
397 let record = match record_result {
398 Ok(r) => r,
399 Err(e) => {
400 push_failure(&mut rows, row_idx, None, format!("CSV read error: {}", e));
401 continue;
402 }
403 };
404
405 let row_key: Option<String> =
408 name_by_index.map(|col_idx| record.get(col_idx).unwrap_or("").to_owned());
409
410 let mut ops: Vec<Op> = bindings
412 .iter()
413 .zip(binding_indices.iter())
414 .map(|(binding, &col_idx)| {
415 let cell = record.get(col_idx).unwrap_or("");
416 Op::ReplaceText {
417 node: binding.node_id.clone(),
418 spans: vec![OpSpan {
419 text: cell.to_owned(),
420 fill: None,
421 font_weight: None,
422 italic: None,
423 underline: None,
424 strikethrough: None,
425 vertical_align: None,
426 footnote_ref: None,
427 }],
428 }
429 })
430 .collect();
431
432 for (binding, &col_idx) in asset_bindings.iter().zip(asset_binding_indices.iter()) {
434 let cell = record.get(col_idx).unwrap_or("").trim();
435 if cell.is_empty() {
436 continue;
438 }
439 let asset_id = row_asset_id(row_idx, &binding.column);
440 ops.push(Op::AddAsset {
441 id: asset_id.clone(),
442 kind: "image".to_owned(),
443 src: cell.to_owned(),
444 sha256: None,
445 });
446 ops.push(Op::SetAsset {
447 node_id: binding.node_id.clone(),
448 asset_id,
449 });
450 }
451
452 let tx = Transaction {
453 ops,
454 permissions: Default::default(),
455 };
456
457 let tx_result = match run_transaction(&doc, &tx) {
459 Ok(r) => r,
460 Err(e) => {
461 push_failure(
462 &mut rows,
463 row_idx,
464 row_key,
465 format!("transaction engine error: {}", e.message),
466 );
467 continue;
468 }
469 };
470
471 if tx_result.status == TxStatus::Rejected {
473 let msgs: Vec<String> = tx_result
474 .diagnostics
475 .iter()
476 .map(|d| {
477 format!(
478 "{}[{}]: {}",
479 crate::json_types::severity_str(&d.severity),
480 d.code,
481 d.message
482 )
483 })
484 .collect();
485 push_failure(
486 &mut rows,
487 row_idx,
488 row_key,
489 format!("transaction rejected: {}", msgs.join("; ")),
490 );
491 continue;
492 }
493
494 let mut row_doc = match KdlAdapter.parse(tx_result.source_after.as_bytes()) {
496 Ok(d) => d,
497 Err(e) => {
498 push_failure(
499 &mut rows,
500 row_idx,
501 row_key,
502 format!("post-transaction parse error: {}", e.message),
503 );
504 continue;
505 }
506 };
507
508 {
511 let mut text_src_diags: Vec<zenith_core::Diagnostic> = Vec::new();
512 resolve_text_sources(&mut row_doc, project_dir, &mut text_src_diags);
513 let hard: Vec<String> = text_src_diags
514 .iter()
515 .filter(|d| d.severity == Severity::Error)
516 .map(crate::commands::format_error_diag)
517 .collect();
518 if !hard.is_empty() {
519 push_failure(
520 &mut rows,
521 row_idx,
522 row_key,
523 format!("text source error(s): {}", hard.join("; ")),
524 );
525 continue;
526 }
527 }
528
529 let row_assets = if asset_bindings.is_empty() {
533 None
536 } else {
537 let Some(dir) = project_dir else {
538 push_failure(
539 &mut rows,
540 row_idx,
541 row_key,
542 "internal: project directory unexpectedly missing".to_owned(),
543 );
544 continue;
545 };
546 let mut row_provider =
548 build_asset_provider(&doc, dir, false).map_err(|e| MergeError::new(e.message))?;
549 let mut row_asset_missing = false;
551 for (binding, &col_idx) in asset_bindings.iter().zip(asset_binding_indices.iter()) {
552 let cell = record.get(col_idx).unwrap_or("").trim();
553 if cell.is_empty() {
554 continue;
555 }
556 let asset_id = row_asset_id(row_idx, &binding.column);
557 let img_path = dir.join(cell);
558 match std::fs::read(&img_path) {
559 Ok(bytes) => {
560 row_provider.register(&asset_id, AssetKind::Image, bytes.into());
561 }
562 Err(e) => {
563 push_failure(
564 &mut rows,
565 row_idx,
566 row_key.clone(),
567 format!(
568 "error[asset.missing]: asset '{}' file not found: '{}': {}",
569 asset_id,
570 img_path.display(),
571 e
572 ),
573 );
574 row_asset_missing = true;
575 break;
576 }
577 }
578 }
579 if row_asset_missing {
580 continue;
581 }
582 Some(row_provider)
583 };
584
585 if let Some(dir) = project_dir {
588 let missing_diags = collect_missing_asset_diagnostics(&row_doc, dir);
589 let hard: Vec<String> = missing_diags
590 .iter()
591 .filter(|d| d.severity == Severity::Error)
592 .map(crate::commands::format_error_diag)
593 .collect();
594 if !hard.is_empty() {
595 push_failure(
596 &mut rows,
597 row_idx,
598 row_key,
599 format!("asset error(s): {}", hard.join("; ")),
600 );
601 continue;
602 }
603 }
604
605 let page_count = row_doc.body.pages.len();
607 if page_count == 0 {
608 push_failure(
609 &mut rows,
610 row_idx,
611 row_key,
612 "row document has no pages".to_owned(),
613 );
614 continue;
615 }
616
617 let row_stem = match name_by_index {
619 Some(col_idx) => sanitize_filename(record.get(col_idx).unwrap_or("")),
620 None => format!("row-{:04}", row_idx + 1),
621 };
622
623 let page_filenames: Vec<String> = (0..page_count)
625 .map(|pi| page_filename(&row_stem, pi, page_count))
626 .collect();
627
628 let mut collided = false;
631 for fname in &page_filenames {
632 if used_names.contains(fname) {
633 push_failure(
634 &mut rows,
635 row_idx,
636 row_key.clone(),
637 format!("output filename collision: {fname}"),
638 );
639 collided = true;
640 break;
641 }
642 }
643 if collided {
644 continue;
645 }
646
647 let mut page_failures: Vec<String> = Vec::new();
649 let mut page_pngs: Vec<(String, Vec<u8>)> = Vec::new();
650
651 for (page_index, page_fname) in page_filenames.iter().enumerate() {
652 let compile_result = compile_page(&row_doc, &fonts, page_index, None);
653
654 let hard_diags: Vec<String> = compile_result
656 .diagnostics
657 .iter()
658 .filter(|d| d.severity == Severity::Error)
659 .map(crate::commands::format_error_diag)
660 .collect();
661 if !hard_diags.is_empty() {
662 page_failures.push(format!(
663 "page {}: compile error(s): {}",
664 page_index + 1,
665 hard_diags.join("; ")
666 ));
667 continue;
668 }
669
670 let png_result = match &row_assets {
672 Some(ra) => render_png(&compile_result.scene, &fonts, ra),
673 None => render_png(&compile_result.scene, &fonts, &template_assets),
674 };
675 match png_result {
676 Ok(bytes) => {
677 page_pngs.push((page_fname.clone(), bytes));
678 }
679 Err(e) => {
680 page_failures.push(format!("page {}: render error: {}", page_index + 1, e));
681 }
682 }
683 }
684
685 if !page_failures.is_empty() {
687 push_failure(&mut rows, row_idx, row_key, page_failures.join("; "));
688 continue;
689 }
690
691 let mut write_failed = false;
695 let mut newly_written: Vec<String> = Vec::new();
696 for (fname, bytes) in page_pngs {
697 let out_path = out_dir.join(&fname);
698 if let Err(e) = std::fs::write(&out_path, &bytes) {
699 push_failure(
700 &mut rows,
701 row_idx,
702 row_key.clone(),
703 format!("write error '{}': {}", out_path.display(), e),
704 );
705 write_failed = true;
706 break;
707 }
708 newly_written.push(fname);
709 }
710 if write_failed {
711 continue;
712 }
713 for fname in &newly_written {
714 used_names.insert(fname.clone());
715 }
716 rows.push(RowResult {
717 row: row_idx,
718 key: row_key,
719 outputs: newly_written,
720 failure: None,
721 });
722 }
723
724 Ok(MergeReport { rows })
725}
726
727fn push_failure(rows: &mut Vec<RowResult>, row: usize, key: Option<String>, reason: String) {
734 rows.push(RowResult {
735 row,
736 key,
737 outputs: Vec::new(),
738 failure: Some(reason),
739 });
740}
741
742fn row_asset_id(row_idx: usize, column: &str) -> String {
748 format!("merge.row.{}.asset.{}", row_idx, column)
749}
750
751fn page_filename(stem: &str, page_index: usize, page_count: usize) -> String {
757 if page_count == 1 {
758 format!("{stem}.png")
759 } else {
760 format!("{stem}-page-{}.png", page_index + 1)
761 }
762}
763
764pub fn build_manifest(
770 doc_src: &str,
771 csv_src: &str,
772 name_by: Option<&str>,
773 report: &MergeReport,
774) -> crate::json_types::MergeManifest {
775 use sha2::{Digest, Sha256};
776 const MANIFEST_FORMAT_VERSION: &str = "1";
780
781 let source_sha256 = format!("{:x}", Sha256::digest(doc_src.as_bytes()));
782 let data_sha256 = format!("{:x}", Sha256::digest(csv_src.as_bytes()));
783 let rows = report
784 .rows
785 .iter()
786 .filter(|r| r.failure.is_none())
787 .map(|r| crate::json_types::ManifestRow {
788 row: r.row,
789 key: r.key.clone(),
790 outputs: r.outputs.clone(),
791 })
792 .collect();
793 crate::json_types::MergeManifest {
794 schema: "zenith-merge-manifest-v1",
795 generator: MANIFEST_FORMAT_VERSION,
796 source_sha256,
797 data_sha256,
798 name_by: name_by.map(str::to_owned),
799 rows,
800 }
801}
802
803pub fn to_json_output(report: &MergeReport) -> MergeOutput {
805 let n_written = report.rows.iter().filter(|r| r.failure.is_none()).count();
806 let n_failed = report.rows.iter().filter(|r| r.failure.is_some()).count();
807 MergeOutput {
808 schema: "zenith-merge-v1",
809 total_rows: report.rows.len(),
810 written: n_written,
811 failed: n_failed,
812 rows: report
813 .rows
814 .iter()
815 .map(|r| MergeRowResult {
816 row: r.row,
817 key: r.key.clone(),
818 status: if r.failure.is_none() { "ok" } else { "failed" },
819 outputs: r.outputs.clone(),
820 diagnostics: match &r.failure {
821 None => Vec::new(),
822 Some(reason) => vec![DiagnosticJson {
823 code: "merge.row.failed".to_owned(),
824 severity: "error".to_owned(),
825 message: reason.clone(),
826 subject_id: None,
827 }],
828 },
829 })
830 .collect(),
831 }
832}