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::layout::PageRanges;
18use typst::syntax::FileId;
19
20#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
22pub struct Scalar(f32);
23
24impl TryFrom<f32> for Scalar {
25 type Error = &'static str;
26
27 fn try_from(value: f32) -> Result<Self, Self::Error> {
28 if value.is_nan() {
29 Err("NaN is not a valid scalar value")
30 } else {
31 Ok(Scalar(value))
32 }
33 }
34}
35
36impl Scalar {
37 pub fn to_f32(self) -> f32 {
39 self.0
40 }
41}
42
43impl PartialEq for Scalar {
44 fn eq(&self, other: &Self) -> bool {
45 self.0 == other.0
46 }
47}
48
49impl Eq for Scalar {}
50
51impl Hash for Scalar {
52 fn hash<H: Hasher>(&self, state: &mut H) {
53 self.0.to_bits().hash(state);
54 }
55}
56
57impl PartialOrd for Scalar {
58 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
59 Some(self.cmp(other))
60 }
61}
62
63impl Ord for Scalar {
64 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
65 self.0.partial_cmp(&other.0).unwrap()
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub struct Id(String);
73
74impl Id {
75 pub fn new(s: String) -> Self {
77 Id(s)
78 }
79
80 pub fn from_world<F: CompilerFeat>(world: &CompilerWorld<F>, ctx: CtxPath) -> Option<Self> {
82 let entry = world.entry_state();
83 let id = unix_slash(entry.main()?.vpath().as_rootless_path());
84
85 let path = &ResourcePath::from_user_sys(Path::new(&id), ctx);
87 Some(path.into())
88 }
89}
90
91impl From<&ResourcePath> for Id {
92 fn from(value: &ResourcePath) -> Self {
93 Id::new(value.to_string())
94 }
95}
96
97impl fmt::Display for Id {
98 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 f.write_str(&self.0)
100 }
101}
102
103#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
112pub struct PathPattern(pub EcoString);
113
114impl fmt::Display for PathPattern {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 f.write_str(&self.0)
117 }
118}
119
120impl PathPattern {
121 pub fn new(pattern: &str) -> Self {
123 Self(pattern.into())
124 }
125
126 pub fn relative_to(self, base: &Path) -> Self {
128 if self.0.is_empty() {
129 return self;
130 }
131
132 let path = Path::new(self.0.as_str());
133 if path.is_absolute() {
134 let rel_path = tinymist_std::path::diff(path, base);
135
136 match rel_path {
137 Some(rel) => PathPattern(unix_slash(&rel).into()),
138 None => self,
139 }
140 } else {
141 self
142 }
143 }
144
145 pub fn substitute(&self, entry: &EntryState) -> Option<ImmutPath> {
147 self.substitute_impl(entry.root(), entry.main())
148 }
149
150 #[comemo::memoize]
151 fn substitute_impl(&self, root: Option<ImmutPath>, main: Option<FileId>) -> Option<ImmutPath> {
152 log::debug!("Check path {main:?} and root {root:?} with output directory {self:?}");
153
154 let (root, main) = root.zip(main)?;
155
156 if WorkspaceResolver::is_package_file(main) {
158 return None;
159 }
160 let path = main.vpath().resolve(&root)?;
162
163 if let Ok(path) = path.strip_prefix("/untitled") {
165 let tmp = std::env::temp_dir();
166 let path = tmp.join("typst").join(path);
167 return Some(path.as_path().into());
168 }
169
170 let path = path.strip_prefix(&root).ok()?;
171 let dir = path.parent();
172 let file_name = path.file_name().unwrap_or_default();
173
174 let w = root.to_string_lossy();
175 let f = file_name.to_string_lossy();
176 let f = f.as_ref().strip_suffix(".typ").unwrap_or(f.as_ref());
177
178 if self.0.is_empty() {
179 let dest = dir
180 .map(|d| root.join(d).join(f))
181 .unwrap_or_else(|| root.join(f));
182 return Some(dest.clean().into());
183 }
184
185 let mut path = self.0.replace("$root", &w);
187 if let Some(dir) = dir {
188 let d = dir.to_string_lossy();
189 let d = if d.is_empty() { "." } else { d.as_ref() };
190 path = path.replace("$dir", d);
191 }
192 path = path.replace("$name", f);
193
194 Some(Path::new(path.as_str()).clean().into())
195 }
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Hash)]
204pub struct Pages(pub RangeInclusive<Option<NonZeroUsize>>);
205
206impl Pages {
207 pub const FIRST: Pages = Pages(NonZeroUsize::new(1)..=NonZeroUsize::new(1));
209}
210
211impl FromStr for Pages {
212 type Err = &'static str;
213
214 fn from_str(value: &str) -> Result<Self, Self::Err> {
215 match value
216 .split('-')
217 .map(str::trim)
218 .collect::<Vec<_>>()
219 .as_slice()
220 {
221 [] | [""] => Err("page export range must not be empty"),
222 [single_page] => {
223 let page_number = parse_page_number(single_page)?;
224 Ok(Pages(Some(page_number)..=Some(page_number)))
225 }
226 ["", ""] => Err("page export range must have start or end"),
227 [start, ""] => Ok(Pages(Some(parse_page_number(start)?)..=None)),
228 ["", end] => Ok(Pages(None..=Some(parse_page_number(end)?))),
229 [start, end] => {
230 let start = parse_page_number(start)?;
231 let end = parse_page_number(end)?;
232 if start > end {
233 Err("page export range must end at a page after the start")
234 } else {
235 Ok(Pages(Some(start)..=Some(end)))
236 }
237 }
238 [_, _, _, ..] => Err("page export range must have a single hyphen"),
239 }
240 }
241}
242
243pub fn exported_page_ranges(pages: &[Pages]) -> PageRanges {
245 PageRanges::new(pages.iter().map(|p| p.0.clone()).collect())
246}
247
248impl fmt::Display for Pages {
249 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250 let start = match self.0.start() {
251 Some(start) => start.to_string(),
252 None => String::from(""),
253 };
254 let end = match self.0.end() {
255 Some(end) => end.to_string(),
256 None => String::from(""),
257 };
258 write!(f, "{start}-{end}")
259 }
260}
261
262impl serde::Serialize for Pages {
263 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
264 where
265 S: serde::Serializer,
266 {
267 serializer.serialize_str(&self.to_string())
268 }
269}
270
271impl<'de> serde::Deserialize<'de> for Pages {
272 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
273 where
274 D: serde::Deserializer<'de>,
275 {
276 let value = String::deserialize(deserializer)?;
277 value.parse().map_err(serde::de::Error::custom)
278 }
279}
280
281fn parse_page_number(value: &str) -> Result<NonZeroUsize, &'static str> {
283 if value == "0" {
284 Err("page numbers start at one")
285 } else {
286 NonZeroUsize::from_str(value).map_err(|_| "not a valid page number")
287 }
288}
289
290#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
292pub struct ResourcePath(EcoString, String);
293
294impl fmt::Display for ResourcePath {
295 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296 write!(f, "{}:{}", self.0, self.1)
297 }
298}
299
300impl FromStr for ResourcePath {
301 type Err = &'static str;
302
303 fn from_str(value: &str) -> Result<Self, Self::Err> {
304 let mut parts = value.split(':');
305 let scheme = parts.next().ok_or("missing scheme")?;
306 let path = parts.next().ok_or("missing path")?;
307 if parts.next().is_some() {
308 Err("too many colons")
309 } else {
310 Ok(ResourcePath(scheme.into(), path.to_string()))
311 }
312 }
313}
314
315impl serde::Serialize for ResourcePath {
316 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
317 where
318 S: serde::Serializer,
319 {
320 serializer.serialize_str(&self.to_string())
321 }
322}
323
324impl<'de> serde::Deserialize<'de> for ResourcePath {
325 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
326 where
327 D: serde::Deserializer<'de>,
328 {
329 let value = String::deserialize(deserializer)?;
330 value.parse().map_err(serde::de::Error::custom)
331 }
332}
333
334pub type CtxPath<'a, 'b> = (&'a Path, &'b Path);
338
339impl ResourcePath {
340 pub fn from_user_sys(inp: &Path, (cwd, lock_dir): CtxPath) -> Self {
342 let abs = if inp.is_absolute() {
343 inp.to_path_buf()
344 } else {
345 cwd.join(inp)
346 };
347 let resource_path = if let Some(rel) = tinymist_std::path::diff(&abs, lock_dir) {
348 rel
349 } else {
350 abs
351 };
352 let rel = unix_slash(&resource_path.clean());
355 ResourcePath("file".into(), rel.to_string())
356 }
357
358 pub fn from_file_id(id: FileId) -> Self {
360 let package = id.package();
361 match package {
362 Some(package) => ResourcePath(
363 "file_id".into(),
364 format!("{package}{}", unix_slash(id.vpath().as_rooted_path())),
365 ),
366 None => ResourcePath(
367 "file_id".into(),
368 format!("$root{}", unix_slash(id.vpath().as_rooted_path())),
369 ),
370 }
371 }
372
373 pub fn relative_to(&self, base: &Path) -> Option<Self> {
376 if self.0 == "file" {
377 let path = Path::new(&self.1);
378 if path.is_absolute() {
379 let rel_path = tinymist_std::path::diff(path, base)?;
380 Some(ResourcePath(self.0.clone(), unix_slash(&rel_path)))
381 } else {
382 Some(ResourcePath(self.0.clone(), self.1.clone()))
383 }
384 } else {
385 Some(self.clone())
386 }
387 }
388
389 pub fn to_rel_path(&self, base: &Path) -> Option<PathBuf> {
392 if self.0 == "file" {
393 let path = Path::new(&self.1);
394 if path.is_absolute() {
395 Some(tinymist_std::path::diff(path, base).unwrap_or_else(|| path.to_owned()))
396 } else {
397 Some(path.to_owned())
398 }
399 } else {
400 None
401 }
402 }
403
404 pub fn to_abs_path(&self, base: &Path) -> Option<PathBuf> {
406 if self.0 == "file" {
407 let path = Path::new(&self.1);
408 if path.is_absolute() {
409 Some(path.to_owned())
410 } else {
411 Some(base.join(path))
412 }
413 } else {
414 None
415 }
416 }
417}
418
419pub mod output_template {
422 const INDEXABLE: [&str; 3] = ["{p}", "{0p}", "{n}"];
423
424 pub fn has_indexable_template(output: &str) -> bool {
426 INDEXABLE.iter().any(|template| output.contains(template))
427 }
428
429 pub fn format(output: &str, this_page: usize, total_pages: usize) -> String {
432 fn width(i: usize) -> usize {
434 1 + i.checked_ilog10().unwrap_or(0) as usize
435 }
436
437 let other_templates = ["{t}"];
438 INDEXABLE
439 .iter()
440 .chain(other_templates.iter())
441 .fold(output.to_string(), |out, template| {
442 let replacement = match *template {
443 "{p}" => format!("{this_page}"),
444 "{0p}" | "{n}" => format!("{:01$}", this_page, width(total_pages)),
445 "{t}" => format!("{total_pages}"),
446 _ => unreachable!("unhandled template placeholder {template}"),
447 };
448 out.replace(template, replacement.as_str())
449 })
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use typst::syntax::VirtualPath;
457
458 fn test_root() -> PathBuf {
459 if cfg!(windows) {
460 PathBuf::from(r"C:\dummy-root")
461 } else {
462 PathBuf::from("/dummy-root")
463 }
464 }
465
466 fn test_entry(path: &str) -> EntryState {
467 let root = test_root();
468 EntryState::new_rooted(root.into(), Some(VirtualPath::new(path)))
469 }
470
471 #[test]
472 fn test_substitute_path() {
473 let root = Path::new("/dummy-root");
474 let entry =
475 EntryState::new_rooted(root.into(), Some(VirtualPath::new("/dir1/dir2/file.txt")));
476
477 assert_eq!(
478 PathPattern::new("/substitute/$dir/$name").substitute(&entry),
479 Some(PathBuf::from("/substitute/dir1/dir2/file.txt").into())
480 );
481 assert_eq!(
482 PathPattern::new("/substitute/$dir/../$name").substitute(&entry),
483 Some(PathBuf::from("/substitute/dir1/file.txt").into())
484 );
485 assert_eq!(
486 PathPattern::new("/substitute/$name").substitute(&entry),
487 Some(PathBuf::from("/substitute/file.txt").into())
488 );
489 assert_eq!(
490 PathPattern::new("/substitute/target/$dir/$name").substitute(&entry),
491 Some(PathBuf::from("/substitute/target/dir1/dir2/file.txt").into())
492 );
493 }
494
495 #[test]
496 fn test_substitute_path_keeps_workspace_root_relative() {
497 let entry = test_entry("/Chapter 1.1.typ");
498
499 assert_eq!(
500 PathPattern::new("$dir/$name").substitute(&entry),
501 Some(PathBuf::from("Chapter 1.1").into())
502 );
503 }
504
505 #[test]
506 fn test_substitute_default_path_matches_documented_behavior() {
507 let root = test_root();
508 let entry = test_entry("/Chapter 1.1.typ");
509
510 assert_eq!(
511 PathPattern::default().substitute(&entry),
512 Some(root.join("Chapter 1.1").into())
513 );
514 }
515
516 #[test]
517 fn test_substitute_path_preserves_multi_dot_stem() {
518 let root = test_root();
519 let entry = test_entry("/chapters/Chapter 1.1.1.typ");
520
521 assert_eq!(
522 PathPattern::new("$root/out/$dir/$name").substitute(&entry),
523 Some(root.join("out/chapters/Chapter 1.1.1").into())
524 );
525 }
526
527 #[test]
528 fn test_substitute_path_preserves_name_without_extension() {
529 let root = test_root();
530 let entry = test_entry("/README");
531
532 assert_eq!(
533 PathPattern::new("$root/$dir/$name").substitute(&entry),
534 Some(root.join("README").into())
535 );
536 }
537}