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