gammaloop_api/
templates.rs1use color_eyre::Result;
2use include_dir::Dir;
3use std::fs;
4use std::path::Path;
5
6const LINNEST_PACKAGE_DIR: &str = "crates/linnest/typst";
7const KURVST_PACKAGE_DIR: &str = "crates/kurvst/typst";
8
9pub struct Assets;
10
11include!(concat!(env!("OUT_DIR"), "/embedded_assets.rs"));
12
13impl Assets {
14 pub fn template_paths() -> impl Iterator<Item = String> {
16 GAMMALOOP_TEMPLATE_ASSETS
17 .files()
18 .map(|file| file.path().to_string_lossy().into_owned())
19 }
20
21 pub fn extract_templates<P: AsRef<Path>>(target_dir: P) -> Result<()> {
23 let target = target_dir.as_ref().join("drawings/templates");
24
25 fs::create_dir_all(&target)?;
26
27 extract_package(&GAMMALOOP_TEMPLATE_ASSETS, &target)?;
28 extract_package(&LINNEST_PACKAGE_ASSETS, &target.join(LINNEST_PACKAGE_DIR))?;
29 extract_package(&KURVST_PACKAGE_ASSETS, &target.join(KURVST_PACKAGE_DIR))?;
30
31 Ok(())
32 }
33
34 pub fn extract_justfile<P: AsRef<Path>>(target_dir: P) -> Result<()> {
36 let justfile_path = target_dir.as_ref().join("justfile");
37
38 fs::write(
39 &justfile_path,
40 include_bytes!("../../../assets/embedded/drawing/justfile"),
41 )?;
42
43 Ok(())
44 }
45}
46
47fn extract_package(assets: &Dir<'_>, target: &Path) -> Result<()> {
48 fs::create_dir_all(target)?;
49 for file in assets.files() {
50 let target_path = target.join(file.path());
51 if let Some(parent) = target_path.parent() {
52 fs::create_dir_all(parent)?;
53 }
54 fs::write(&target_path, file.contents())?;
55 }
56
57 Ok(())
58}
59
60#[cfg(test)]
61mod tests {
62 use super::*;
63
64 #[test]
65 fn extract_templates_uses_canonical_package_layout() -> Result<()> {
66 let tempdir = tempfile::tempdir()?;
67
68 Assets::extract_templates(tempdir.path())?;
69
70 let templates = tempdir.path().join("drawings/templates");
71 assert!(templates.join("figure.typ").is_file());
72 assert!(templates.join("grid.typ").is_file());
73 assert!(templates.join("layout.typ").is_file());
74 assert!(templates.join("layout-core.typ").is_file());
75 assert!(templates.join("physics-edge-style.typ").is_file());
76 assert!(templates.join("impl/physics-edge-style.typ").is_file());
77
78 assert!(templates
79 .join("crates/linnest/typst/linnest.wasm")
80 .is_file());
81 assert!(templates
82 .join("crates/linnest/typst/src/curve.typ")
83 .is_file());
84 assert!(templates
85 .join("crates/linnest/typst/src/impl/graph.typ")
86 .is_file());
87 assert!(templates
88 .join("crates/linnest/typst/src/render/layout.typ")
89 .is_file());
90 assert!(!templates
91 .join("crates/linnest/typst/src/physics-edge-style.typ")
92 .exists());
93 assert!(!templates
94 .join("crates/linnest/typst/src/impl/physics-edge-style.typ")
95 .exists());
96 assert!(templates.join("crates/kurvst/typst/kurvst.wasm").is_file());
97 assert!(templates.join("crates/kurvst/typst/src/lib.typ").is_file());
98 assert!(templates.join("crates/kurvst/typst/src/impl.typ").is_file());
99
100 assert!(!templates.join("linnest.wasm").exists());
101 assert!(!templates.join("kurvst.wasm").exists());
102 assert!(!templates.join("curve.typ").exists());
103 for package in [LINNEST_PACKAGE_DIR, KURVST_PACKAGE_DIR] {
104 for excluded in ["docs", "examples", "benches", "README.md", "LICENSE"] {
105 assert!(!templates.join(package).join(excluded).exists());
106 }
107 }
108
109 Assets::extract_justfile(tempdir.path())?;
110 assert!(tempdir.path().join("justfile").is_file());
111
112 Ok(())
113 }
114
115 #[cfg(unix)]
116 #[test]
117 #[ignore = "requires Typst, just, and LINNET_TEST_EXECUTABLE pointing to a built CLI"]
118 fn exported_model_drawings_render_and_cache_without_python() -> Result<()> {
119 use std::collections::BTreeMap;
120 use std::os::unix::fs::symlink;
121 use std::path::PathBuf;
122 use std::process::Command;
123
124 let temp = tempfile::tempdir()?;
125 let bin = temp.path().join("bin");
126 fs::create_dir(&bin)?;
127 let search_path = std::env::var_os("PATH").unwrap_or_default();
128 for (name, configured) in [
129 (
130 "linnet",
131 Some(
132 std::env::var_os("LINNET_TEST_EXECUTABLE").expect("set LINNET_TEST_EXECUTABLE"),
133 ),
134 ),
135 ("typst", std::env::var_os("TYPST_TEST_EXECUTABLE")),
136 ("just", None),
137 ("sh", None),
138 ] {
139 let executable = configured.map(PathBuf::from).unwrap_or_else(|| {
140 std::env::split_paths(&search_path)
141 .map(|directory| directory.join(name))
142 .find(|path| path.is_file())
143 .unwrap_or_else(|| panic!("{name} must be available on PATH"))
144 });
145 symlink(fs::canonicalize(executable)?, bin.join(name))?;
146 }
147 let absent = Command::new(bin.join("sh"))
148 .env("PATH", &bin)
149 .args(["-c", "! command -v python && ! command -v python3"])
150 .status()?;
151 assert!(absent.success());
152
153 Assets::extract_templates(temp.path())?;
154 Assets::extract_justfile(temp.path())?;
155 let templates = temp.path().join("drawings/templates");
156 let model = gammalooprs::utils::load_generic_model("scalars");
157 model.generate_edge_style_template(templates.join("edge-style.typ"))?;
158 let data = temp.path().join("processes/amplitudes/example.dot");
159 fs::create_dir_all(data.parent().unwrap())?;
160 fs::write(
161 &data,
162 r#"digraph amplitude {
163 a [pos="0,0!"]; b [pos="4,0!"];
164 incoming [style=invis]; outgoing [style=invis];
165 incoming -> a [particle="scalar_0", pos="z:4"];
166 a -> b [particle="scalar_0"];
167 b -> outgoing [particle="scalar_0", pos="z:-2"];
168 }"#,
169 )?;
170
171 for scenario in ["initial", "cached", "changed-source", "explicit-inputs"] {
172 if scenario == "changed-source" {
173 let implementation = templates.join("crates/linnest/typst/src/impl/draw.typ");
174 let source = fs::read_to_string(&implementation)?;
175 fs::write(
176 implementation,
177 format!("{source}\n// Cache invalidation probe.\n"),
178 )?;
179 }
180 let mut command = Command::new(bin.join("just"));
181 command.current_dir(temp.path()).env("PATH", &bin).args([
182 "steps=0",
183 "columns=1",
184 "draw",
185 ]);
186 if scenario == "explicit-inputs" {
187 command.args(["--input", "steps=2", "--input", "columns=2"]);
188 }
189 let output = command.output()?;
190 let expected = if scenario == "cached" {
191 "0 built, 1 reused"
192 } else {
193 "1 built, 0 reused"
194 };
195 assert!(
196 output.status.success(),
197 "{}",
198 String::from_utf8_lossy(&output.stderr)
199 );
200 assert!(
201 String::from_utf8_lossy(&output.stdout).contains(expected),
202 "{}",
203 String::from_utf8_lossy(&output.stdout)
204 );
205 for path in [
206 "drawings.pdf",
207 "drawings/figs/processes/amplitudes/example.pdf",
208 ] {
209 assert!(fs::read(temp.path().join(path))?.starts_with(b"%PDF-"));
210 }
211 let metadata: serde_json::Value = serde_json::from_slice(&fs::read(
212 temp.path().join("drawings/.cache/run-metadata.json"),
213 )?)?;
214 let inputs: Vec<(String, String)> = serde_json::from_value(metadata["input"].clone())?;
215 let inputs: BTreeMap<_, _> = inputs.into_iter().collect();
216 assert_eq!(
217 inputs["steps"],
218 if scenario == "explicit-inputs" {
219 "2"
220 } else {
221 "0"
222 }
223 );
224 assert_eq!(
225 inputs["columns"],
226 if scenario == "explicit-inputs" {
227 "2"
228 } else {
229 "1"
230 }
231 );
232 }
233
234 let assertions = temp.path().join("model-style.typ");
235 fs::write(
236 &assertions,
237 r#"#import "drawings/templates/edge-style.typ" as physics
238#import "drawings/templates/crates/linnest/typst/src/lib.typ": graph
239#let g = graph.parse(read("processes/amplitudes/example.dot")).first()
240#let particle-label = physics.map.at("scalar_0").label
241#let styles = physics.style(label-size: 12pt, label-fill: blue)
242#let expected-label = text(size: 12pt, fill: blue)[#particle-label]
243#assert(particle-label != none)
244#assert(graph.edges(g).len() == 3)
245// Drawing callbacks receive flattened fields, while graph.edges stores DOT
246// attributes in statements. Use the public mapper's normalized view.
247#let _ = graph.map(g, edge: edge => {
248 assert((styles.edge-label)(edge.fields) == expected-label)
249 none
250})
251[Model-generated particle labels work directly in Typst.]
252"#,
253 )?;
254 let output = Command::new(bin.join("typst"))
255 .env("PATH", &bin)
256 .arg("compile")
257 .arg("--root")
258 .arg(temp.path())
259 .arg(&assertions)
260 .arg(temp.path().join("model-style.pdf"))
261 .output()?;
262 assert!(
263 output.status.success(),
264 "{}",
265 String::from_utf8_lossy(&output.stderr)
266 );
267 Ok(())
268 }
269}