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::Unknown(_n) => {
209 }
212 }
213 }
214 Ok(())
215}
216
217pub fn sanitize_filename(s: &str) -> String {
222 let mapped: String = s
223 .chars()
224 .map(|c| match c {
225 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_',
226 other => other,
227 })
228 .collect();
229 let trimmed = mapped.trim_matches(|c: char| c == '.' || c.is_whitespace());
230 if trimmed.is_empty() {
231 "_".to_owned()
232 } else {
233 trimmed.to_owned()
234 }
235}
236
237pub fn run(
256 doc_src: &str,
257 csv_src: &str,
258 project_dir: Option<&Path>,
259 out_dir: &Path,
260 name_by: Option<&str>,
261) -> Result<MergeReport, MergeError> {
262 let doc = KdlAdapter
264 .parse(doc_src.as_bytes())
265 .map_err(|e| MergeError::new(format!("error[parse.error]: {}", e.message)))?;
266
267 let mut bindings: Vec<DataBinding> = Vec::new();
269 let mut asset_bindings: Vec<AssetBinding> = Vec::new();
270 for page in &doc.body.pages {
271 collect_data_nodes(&page.children, &mut bindings, &mut asset_bindings)?;
272 }
273 if bindings.is_empty() && asset_bindings.is_empty() {
274 return Err(MergeError::new("no role=\"data.*\" template nodes found"));
275 }
276
277 if !asset_bindings.is_empty() && project_dir.is_none() {
279 return Err(MergeError::new(
280 "image data bindings require a project directory (the .zen file must be on disk)",
281 ));
282 }
283
284 let mut reader = csv::Reader::from_reader(csv_src.as_bytes());
286 let headers = reader
287 .headers()
288 .map_err(|e| MergeError::new(format!("CSV header error: {}", e)))?
289 .clone();
290
291 let header_index: BTreeMap<String, usize> = headers
293 .iter()
294 .enumerate()
295 .map(|(i, h)| (h.to_owned(), i))
296 .collect();
297
298 let unknown: Vec<String> = bindings
300 .iter()
301 .filter(|b| !header_index.contains_key(&b.column))
302 .map(|b| b.column.clone())
303 .collect();
304 if !unknown.is_empty() {
305 return Err(MergeError::new(format!(
306 "CSV column(s) not found in header: {}",
307 unknown.join(", ")
308 )));
309 }
310
311 let unknown_asset: Vec<String> = asset_bindings
313 .iter()
314 .filter(|b| !header_index.contains_key(&b.column))
315 .map(|b| b.column.clone())
316 .collect();
317 if !unknown_asset.is_empty() {
318 return Err(MergeError::new(format!(
319 "CSV column(s) not found in header: {}",
320 unknown_asset.join(", ")
321 )));
322 }
323
324 if let Some(col) = name_by
326 && !header_index.contains_key(col)
327 {
328 return Err(MergeError::new(format!(
329 "--name-by column {:?} not found in CSV header",
330 col
331 )));
332 }
333
334 let binding_indices: Vec<usize> = bindings
337 .iter()
338 .map(|b| -> Result<usize, MergeError> {
339 header_index
340 .get(&b.column)
341 .copied()
342 .ok_or_else(|| MergeError::new(format!("column {:?} not found", b.column)))
343 })
344 .collect::<Result<Vec<usize>, MergeError>>()?;
345
346 let asset_binding_indices: Vec<usize> = asset_bindings
347 .iter()
348 .map(|b| -> Result<usize, MergeError> {
349 header_index
350 .get(&b.column)
351 .copied()
352 .ok_or_else(|| MergeError::new(format!("column {:?} not found", b.column)))
353 })
354 .collect::<Result<Vec<usize>, MergeError>>()?;
355
356 let name_by_index: Option<usize> = match name_by {
357 None => None,
358 Some(col) => Some(
359 header_index
360 .get(col)
361 .copied()
362 .ok_or_else(|| MergeError::new(format!("--name-by column {:?} not found", col)))?,
363 ),
364 };
365
366 let fonts =
368 build_font_provider(&doc, project_dir, false).map_err(|e| MergeError::new(e.message))?;
369 let template_assets = match project_dir {
371 Some(dir) => {
372 build_asset_provider(&doc, dir, false).map_err(|e| MergeError::new(e.message))?
373 }
374 None => BytesAssetProvider::new(),
375 };
376
377 std::fs::create_dir_all(out_dir).map_err(|e| {
379 MergeError::new(format!(
380 "could not create output directory '{}': {}",
381 out_dir.display(),
382 e
383 ))
384 })?;
385
386 let mut rows: Vec<RowResult> = Vec::new();
388 let mut used_names: BTreeSet<String> = BTreeSet::new();
389
390 for (row_idx, record_result) in reader.records().enumerate() {
391 let record = match record_result {
392 Ok(r) => r,
393 Err(e) => {
394 push_failure(&mut rows, row_idx, None, format!("CSV read error: {}", e));
395 continue;
396 }
397 };
398
399 let row_key: Option<String> =
402 name_by_index.map(|col_idx| record.get(col_idx).unwrap_or("").to_owned());
403
404 let mut ops: Vec<Op> = bindings
406 .iter()
407 .zip(binding_indices.iter())
408 .map(|(binding, &col_idx)| {
409 let cell = record.get(col_idx).unwrap_or("");
410 Op::ReplaceText {
411 node: binding.node_id.clone(),
412 spans: vec![OpSpan {
413 text: cell.to_owned(),
414 fill: None,
415 font_weight: None,
416 italic: None,
417 underline: None,
418 strikethrough: None,
419 vertical_align: None,
420 footnote_ref: None,
421 }],
422 }
423 })
424 .collect();
425
426 for (binding, &col_idx) in asset_bindings.iter().zip(asset_binding_indices.iter()) {
428 let cell = record.get(col_idx).unwrap_or("").trim();
429 if cell.is_empty() {
430 continue;
432 }
433 let asset_id = row_asset_id(row_idx, &binding.column);
434 ops.push(Op::AddAsset {
435 id: asset_id.clone(),
436 kind: "image".to_owned(),
437 src: cell.to_owned(),
438 sha256: None,
439 });
440 ops.push(Op::SetAsset {
441 node_id: binding.node_id.clone(),
442 asset_id,
443 });
444 }
445
446 let tx = Transaction {
447 ops,
448 permissions: Default::default(),
449 };
450
451 let tx_result = match run_transaction(&doc, &tx) {
453 Ok(r) => r,
454 Err(e) => {
455 push_failure(
456 &mut rows,
457 row_idx,
458 row_key,
459 format!("transaction engine error: {}", e.message),
460 );
461 continue;
462 }
463 };
464
465 if tx_result.status == TxStatus::Rejected {
467 let msgs: Vec<String> = tx_result
468 .diagnostics
469 .iter()
470 .map(|d| {
471 format!(
472 "{}[{}]: {}",
473 crate::json_types::severity_str(&d.severity),
474 d.code,
475 d.message
476 )
477 })
478 .collect();
479 push_failure(
480 &mut rows,
481 row_idx,
482 row_key,
483 format!("transaction rejected: {}", msgs.join("; ")),
484 );
485 continue;
486 }
487
488 let mut row_doc = match KdlAdapter.parse(tx_result.source_after.as_bytes()) {
490 Ok(d) => d,
491 Err(e) => {
492 push_failure(
493 &mut rows,
494 row_idx,
495 row_key,
496 format!("post-transaction parse error: {}", e.message),
497 );
498 continue;
499 }
500 };
501
502 {
505 let mut text_src_diags: Vec<zenith_core::Diagnostic> = Vec::new();
506 resolve_text_sources(&mut row_doc, project_dir, &mut text_src_diags);
507 let hard: Vec<String> = text_src_diags
508 .iter()
509 .filter(|d| d.severity == Severity::Error)
510 .map(crate::commands::format_error_diag)
511 .collect();
512 if !hard.is_empty() {
513 push_failure(
514 &mut rows,
515 row_idx,
516 row_key,
517 format!("text source error(s): {}", hard.join("; ")),
518 );
519 continue;
520 }
521 }
522
523 let row_assets = if asset_bindings.is_empty() {
527 None
530 } else {
531 let Some(dir) = project_dir else {
532 push_failure(
533 &mut rows,
534 row_idx,
535 row_key,
536 "internal: project directory unexpectedly missing".to_owned(),
537 );
538 continue;
539 };
540 let mut row_provider =
542 build_asset_provider(&doc, dir, false).map_err(|e| MergeError::new(e.message))?;
543 let mut row_asset_missing = false;
545 for (binding, &col_idx) in asset_bindings.iter().zip(asset_binding_indices.iter()) {
546 let cell = record.get(col_idx).unwrap_or("").trim();
547 if cell.is_empty() {
548 continue;
549 }
550 let asset_id = row_asset_id(row_idx, &binding.column);
551 let img_path = dir.join(cell);
552 match std::fs::read(&img_path) {
553 Ok(bytes) => {
554 row_provider.register(&asset_id, AssetKind::Image, bytes.into());
555 }
556 Err(e) => {
557 push_failure(
558 &mut rows,
559 row_idx,
560 row_key.clone(),
561 format!(
562 "error[asset.missing]: asset '{}' file not found: '{}': {}",
563 asset_id,
564 img_path.display(),
565 e
566 ),
567 );
568 row_asset_missing = true;
569 break;
570 }
571 }
572 }
573 if row_asset_missing {
574 continue;
575 }
576 Some(row_provider)
577 };
578
579 if let Some(dir) = project_dir {
582 let missing_diags = collect_missing_asset_diagnostics(&row_doc, dir);
583 let hard: Vec<String> = missing_diags
584 .iter()
585 .filter(|d| d.severity == Severity::Error)
586 .map(crate::commands::format_error_diag)
587 .collect();
588 if !hard.is_empty() {
589 push_failure(
590 &mut rows,
591 row_idx,
592 row_key,
593 format!("asset error(s): {}", hard.join("; ")),
594 );
595 continue;
596 }
597 }
598
599 let page_count = row_doc.body.pages.len();
601 if page_count == 0 {
602 push_failure(
603 &mut rows,
604 row_idx,
605 row_key,
606 "row document has no pages".to_owned(),
607 );
608 continue;
609 }
610
611 let row_stem = match name_by_index {
613 Some(col_idx) => sanitize_filename(record.get(col_idx).unwrap_or("")),
614 None => format!("row-{:04}", row_idx + 1),
615 };
616
617 let page_filenames: Vec<String> = (0..page_count)
619 .map(|pi| page_filename(&row_stem, pi, page_count))
620 .collect();
621
622 let mut collided = false;
625 for fname in &page_filenames {
626 if used_names.contains(fname) {
627 push_failure(
628 &mut rows,
629 row_idx,
630 row_key.clone(),
631 format!("output filename collision: {fname}"),
632 );
633 collided = true;
634 break;
635 }
636 }
637 if collided {
638 continue;
639 }
640
641 let mut page_failures: Vec<String> = Vec::new();
643 let mut page_pngs: Vec<(String, Vec<u8>)> = Vec::new();
644
645 for (page_index, page_fname) in page_filenames.iter().enumerate() {
646 let compile_result = compile_page(&row_doc, &fonts, page_index, None);
647
648 let hard_diags: Vec<String> = compile_result
650 .diagnostics
651 .iter()
652 .filter(|d| d.severity == Severity::Error)
653 .map(crate::commands::format_error_diag)
654 .collect();
655 if !hard_diags.is_empty() {
656 page_failures.push(format!(
657 "page {}: compile error(s): {}",
658 page_index + 1,
659 hard_diags.join("; ")
660 ));
661 continue;
662 }
663
664 let png_result = match &row_assets {
666 Some(ra) => render_png(&compile_result.scene, &fonts, ra),
667 None => render_png(&compile_result.scene, &fonts, &template_assets),
668 };
669 match png_result {
670 Ok(bytes) => {
671 page_pngs.push((page_fname.clone(), bytes));
672 }
673 Err(e) => {
674 page_failures.push(format!("page {}: render error: {}", page_index + 1, e));
675 }
676 }
677 }
678
679 if !page_failures.is_empty() {
681 push_failure(&mut rows, row_idx, row_key, page_failures.join("; "));
682 continue;
683 }
684
685 let mut write_failed = false;
689 let mut newly_written: Vec<String> = Vec::new();
690 for (fname, bytes) in page_pngs {
691 let out_path = out_dir.join(&fname);
692 if let Err(e) = std::fs::write(&out_path, &bytes) {
693 push_failure(
694 &mut rows,
695 row_idx,
696 row_key.clone(),
697 format!("write error '{}': {}", out_path.display(), e),
698 );
699 write_failed = true;
700 break;
701 }
702 newly_written.push(fname);
703 }
704 if write_failed {
705 continue;
706 }
707 for fname in &newly_written {
708 used_names.insert(fname.clone());
709 }
710 rows.push(RowResult {
711 row: row_idx,
712 key: row_key,
713 outputs: newly_written,
714 failure: None,
715 });
716 }
717
718 Ok(MergeReport { rows })
719}
720
721fn push_failure(rows: &mut Vec<RowResult>, row: usize, key: Option<String>, reason: String) {
728 rows.push(RowResult {
729 row,
730 key,
731 outputs: Vec::new(),
732 failure: Some(reason),
733 });
734}
735
736fn row_asset_id(row_idx: usize, column: &str) -> String {
742 format!("merge.row.{}.asset.{}", row_idx, column)
743}
744
745fn page_filename(stem: &str, page_index: usize, page_count: usize) -> String {
751 if page_count == 1 {
752 format!("{stem}.png")
753 } else {
754 format!("{stem}-page-{}.png", page_index + 1)
755 }
756}
757
758pub fn build_manifest(
764 doc_src: &str,
765 csv_src: &str,
766 name_by: Option<&str>,
767 report: &MergeReport,
768) -> crate::json_types::MergeManifest {
769 use sha2::{Digest, Sha256};
770 const MANIFEST_FORMAT_VERSION: &str = "1";
774
775 let source_sha256 = format!("{:x}", Sha256::digest(doc_src.as_bytes()));
776 let data_sha256 = format!("{:x}", Sha256::digest(csv_src.as_bytes()));
777 let rows = report
778 .rows
779 .iter()
780 .filter(|r| r.failure.is_none())
781 .map(|r| crate::json_types::ManifestRow {
782 row: r.row,
783 key: r.key.clone(),
784 outputs: r.outputs.clone(),
785 })
786 .collect();
787 crate::json_types::MergeManifest {
788 schema: "zenith-merge-manifest-v1",
789 generator: MANIFEST_FORMAT_VERSION,
790 source_sha256,
791 data_sha256,
792 name_by: name_by.map(str::to_owned),
793 rows,
794 }
795}
796
797pub fn to_json_output(report: &MergeReport) -> MergeOutput {
799 let n_written = report.rows.iter().filter(|r| r.failure.is_none()).count();
800 let n_failed = report.rows.iter().filter(|r| r.failure.is_some()).count();
801 MergeOutput {
802 schema: "zenith-merge-v1",
803 total_rows: report.rows.len(),
804 written: n_written,
805 failed: n_failed,
806 rows: report
807 .rows
808 .iter()
809 .map(|r| MergeRowResult {
810 row: r.row,
811 key: r.key.clone(),
812 status: if r.failure.is_none() { "ok" } else { "failed" },
813 outputs: r.outputs.clone(),
814 diagnostics: match &r.failure {
815 None => Vec::new(),
816 Some(reason) => vec![DiagnosticJson {
817 code: "merge.row.failed".to_owned(),
818 severity: "error".to_owned(),
819 message: reason.clone(),
820 subject_id: None,
821 }],
822 },
823 })
824 .collect(),
825 }
826}