1pub use tinymist_world::args::{ExportTarget, OutputFormat, PdfStandard, TaskWhen};
2
3use core::fmt;
4use std::hash::{Hash, Hasher};
5use std::num::NonZeroUsize;
6use std::ops::RangeInclusive;
7use std::path::PathBuf;
8use std::{path::Path, str::FromStr};
9
10use serde::{Deserialize, Serialize};
11use tinymist_std::ImmutPath;
12use tinymist_std::error::prelude::*;
13use tinymist_std::path::{PathClean, unix_slash};
14use tinymist_world::vfs::WorkspaceResolver;
15use tinymist_world::{CompilerFeat, CompilerWorld, EntryReader, EntryState};
16use typst::diag::EcoString;
17use typst::syntax::FileId;
18
19#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
21pub struct Scalar(f32);
22
23impl TryFrom<f32> for Scalar {
24 type Error = &'static str;
25
26 fn try_from(value: f32) -> Result<Self, Self::Error> {
27 if value.is_nan() {
28 Err("NaN is not a valid scalar value")
29 } else {
30 Ok(Scalar(value))
31 }
32 }
33}
34
35impl Scalar {
36 pub fn to_f32(self) -> f32 {
38 self.0
39 }
40}
41
42impl PartialEq for Scalar {
43 fn eq(&self, other: &Self) -> bool {
44 self.0 == other.0
45 }
46}
47
48impl Eq for Scalar {}
49
50impl Hash for Scalar {
51 fn hash<H: Hasher>(&self, state: &mut H) {
52 self.0.to_bits().hash(state);
53 }
54}
55
56impl PartialOrd for Scalar {
57 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
58 Some(self.cmp(other))
59 }
60}
61
62impl Ord for Scalar {
63 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
64 self.0.partial_cmp(&other.0).unwrap()
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
70#[serde(rename_all = "kebab-case")]
71pub struct Id(String);
72
73impl Id {
74 pub fn new(s: String) -> Self {
76 Id(s)
77 }
78
79 pub fn from_world<F: CompilerFeat>(world: &CompilerWorld<F>, ctx: CtxPath) -> Option<Self> {
81 let entry = world.entry_state();
82 let id = unix_slash(entry.main()?.vpath().as_rootless_path());
83
84 let path = &ResourcePath::from_user_sys(Path::new(&id), ctx);
86 Some(path.into())
87 }
88}
89
90impl From<&ResourcePath> for Id {
91 fn from(value: &ResourcePath) -> Self {
92 Id::new(value.to_string())
93 }
94}
95
96impl fmt::Display for Id {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 f.write_str(&self.0)
99 }
100}
101
102#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
111pub struct PathPattern(pub EcoString);
112
113impl fmt::Display for PathPattern {
114 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115 f.write_str(&self.0)
116 }
117}
118
119impl PathPattern {
120 pub fn new(pattern: &str) -> Self {
122 Self(pattern.into())
123 }
124
125 pub fn relative_to(self, base: &Path) -> Self {
127 if self.0.is_empty() {
128 return self;
129 }
130
131 let path = Path::new(self.0.as_str());
132 if path.is_absolute() {
133 let rel_path = tinymist_std::path::diff(path, base);
134
135 match rel_path {
136 Some(rel) => PathPattern(unix_slash(&rel).into()),
137 None => self,
138 }
139 } else {
140 self
141 }
142 }
143
144 pub fn substitute(&self, entry: &EntryState) -> Option<ImmutPath> {
146 self.substitute_impl(entry.root(), entry.main())
147 }
148
149 #[comemo::memoize]
150 fn substitute_impl(&self, root: Option<ImmutPath>, main: Option<FileId>) -> Option<ImmutPath> {
151 log::debug!("Check path {main:?} and root {root:?} with output directory {self:?}");
152
153 let (root, main) = root.zip(main)?;
154
155 if WorkspaceResolver::is_package_file(main) {
157 return None;
158 }
159 let path = main.vpath().resolve(&root)?;
161
162 if let Ok(path) = path.strip_prefix("/untitled") {
164 let tmp = std::env::temp_dir();
165 let path = tmp.join("typst").join(path);
166 return Some(path.as_path().into());
167 }
168
169 if self.0.is_empty() {
170 return Some(path.to_path_buf().clean().into());
171 }
172
173 let path = path.strip_prefix(&root).ok()?;
174 let dir = path.parent();
175 let file_name = path.file_name().unwrap_or_default();
176
177 let w = root.to_string_lossy();
178 let f = file_name.to_string_lossy();
179
180 let mut path = self.0.replace("$root", &w);
182 if let Some(dir) = dir {
183 let d = dir.to_string_lossy();
184 path = path.replace("$dir", &d);
185 }
186 path = path.replace("$name", &f);
187
188 Some(Path::new(path.as_str()).clean().into())
189 }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Hash)]
198pub struct Pages(pub RangeInclusive<Option<NonZeroUsize>>);
199
200impl Pages {
201 pub const FIRST: Pages = Pages(NonZeroUsize::new(1)..=None);
203}
204
205impl FromStr for Pages {
206 type Err = &'static str;
207
208 fn from_str(value: &str) -> Result<Self, Self::Err> {
209 match value
210 .split('-')
211 .map(str::trim)
212 .collect::<Vec<_>>()
213 .as_slice()
214 {
215 [] | [""] => Err("page export range must not be empty"),
216 [single_page] => {
217 let page_number = parse_page_number(single_page)?;
218 Ok(Pages(Some(page_number)..=Some(page_number)))
219 }
220 ["", ""] => Err("page export range must have start or end"),
221 [start, ""] => Ok(Pages(Some(parse_page_number(start)?)..=None)),
222 ["", end] => Ok(Pages(None..=Some(parse_page_number(end)?))),
223 [start, end] => {
224 let start = parse_page_number(start)?;
225 let end = parse_page_number(end)?;
226 if start > end {
227 Err("page export range must end at a page after the start")
228 } else {
229 Ok(Pages(Some(start)..=Some(end)))
230 }
231 }
232 [_, _, _, ..] => Err("page export range must have a single hyphen"),
233 }
234 }
235}
236
237impl fmt::Display for Pages {
238 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239 let start = match self.0.start() {
240 Some(start) => start.to_string(),
241 None => String::from(""),
242 };
243 let end = match self.0.end() {
244 Some(end) => end.to_string(),
245 None => String::from(""),
246 };
247 write!(f, "{start}-{end}")
248 }
249}
250
251impl serde::Serialize for Pages {
252 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
253 where
254 S: serde::Serializer,
255 {
256 serializer.serialize_str(&self.to_string())
257 }
258}
259
260impl<'de> serde::Deserialize<'de> for Pages {
261 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
262 where
263 D: serde::Deserializer<'de>,
264 {
265 let value = String::deserialize(deserializer)?;
266 value.parse().map_err(serde::de::Error::custom)
267 }
268}
269
270fn parse_page_number(value: &str) -> Result<NonZeroUsize, &'static str> {
272 if value == "0" {
273 Err("page numbers start at one")
274 } else {
275 NonZeroUsize::from_str(value).map_err(|_| "not a valid page number")
276 }
277}
278
279#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
281pub struct ResourcePath(EcoString, String);
282
283impl fmt::Display for ResourcePath {
284 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
285 write!(f, "{}:{}", self.0, self.1)
286 }
287}
288
289impl FromStr for ResourcePath {
290 type Err = &'static str;
291
292 fn from_str(value: &str) -> Result<Self, Self::Err> {
293 let mut parts = value.split(':');
294 let scheme = parts.next().ok_or("missing scheme")?;
295 let path = parts.next().ok_or("missing path")?;
296 if parts.next().is_some() {
297 Err("too many colons")
298 } else {
299 Ok(ResourcePath(scheme.into(), path.to_string()))
300 }
301 }
302}
303
304impl serde::Serialize for ResourcePath {
305 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
306 where
307 S: serde::Serializer,
308 {
309 serializer.serialize_str(&self.to_string())
310 }
311}
312
313impl<'de> serde::Deserialize<'de> for ResourcePath {
314 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
315 where
316 D: serde::Deserializer<'de>,
317 {
318 let value = String::deserialize(deserializer)?;
319 value.parse().map_err(serde::de::Error::custom)
320 }
321}
322
323pub type CtxPath<'a, 'b> = (&'a Path, &'b Path);
327
328impl ResourcePath {
329 pub fn from_user_sys(inp: &Path, (cwd, lock_dir): CtxPath) -> Self {
331 let abs = if inp.is_absolute() {
332 inp.to_path_buf()
333 } else {
334 cwd.join(inp)
335 };
336 let resource_path = if let Some(rel) = tinymist_std::path::diff(&abs, lock_dir) {
337 rel
338 } else {
339 abs
340 };
341 let rel = unix_slash(&resource_path.clean());
344 ResourcePath("file".into(), rel.to_string())
345 }
346
347 pub fn from_file_id(id: FileId) -> Self {
349 let package = id.package();
350 match package {
351 Some(package) => ResourcePath(
352 "file_id".into(),
353 format!("{package}{}", unix_slash(id.vpath().as_rooted_path())),
354 ),
355 None => ResourcePath(
356 "file_id".into(),
357 format!("$root{}", unix_slash(id.vpath().as_rooted_path())),
358 ),
359 }
360 }
361
362 pub fn relative_to(&self, base: &Path) -> Option<Self> {
365 if self.0 == "file" {
366 let path = Path::new(&self.1);
367 if path.is_absolute() {
368 let rel_path = tinymist_std::path::diff(path, base)?;
369 Some(ResourcePath(self.0.clone(), unix_slash(&rel_path)))
370 } else {
371 Some(ResourcePath(self.0.clone(), self.1.clone()))
372 }
373 } else {
374 Some(self.clone())
375 }
376 }
377
378 pub fn to_rel_path(&self, base: &Path) -> Option<PathBuf> {
381 if self.0 == "file" {
382 let path = Path::new(&self.1);
383 if path.is_absolute() {
384 Some(tinymist_std::path::diff(path, base).unwrap_or_else(|| path.to_owned()))
385 } else {
386 Some(path.to_owned())
387 }
388 } else {
389 None
390 }
391 }
392
393 pub fn to_abs_path(&self, base: &Path) -> Option<PathBuf> {
395 if self.0 == "file" {
396 let path = Path::new(&self.1);
397 if path.is_absolute() {
398 Some(path.to_owned())
399 } else {
400 Some(base.join(path))
401 }
402 } else {
403 None
404 }
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use typst::syntax::VirtualPath;
412
413 #[test]
414 fn test_substitute_path() {
415 let root = Path::new("/dummy-root");
416 let entry =
417 EntryState::new_rooted(root.into(), Some(VirtualPath::new("/dir1/dir2/file.txt")));
418
419 assert_eq!(
420 PathPattern::new("/substitute/$dir/$name").substitute(&entry),
421 Some(PathBuf::from("/substitute/dir1/dir2/file.txt").into())
422 );
423 assert_eq!(
424 PathPattern::new("/substitute/$dir/../$name").substitute(&entry),
425 Some(PathBuf::from("/substitute/dir1/file.txt").into())
426 );
427 assert_eq!(
428 PathPattern::new("/substitute/$name").substitute(&entry),
429 Some(PathBuf::from("/substitute/file.txt").into())
430 );
431 assert_eq!(
432 PathPattern::new("/substitute/target/$dir/$name").substitute(&entry),
433 Some(PathBuf::from("/substitute/target/dir1/dir2/file.txt").into())
434 );
435 }
436}