diff --git a/Cargo.lock b/Cargo.lock
index 2f651e200..f30056cfa 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1468,7 +1468,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d16814a067484415fda653868c9be0ac5f2abd2ef5d951082a5f2fe1b3662944"
 dependencies = [
  "is-wsl",
- "pathdiff",
+ "pathdiff 0.2.1",
 ]
 
 [[package]]
@@ -1523,6 +1523,12 @@ version = "1.0.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9f746c4065a8fa3fe23974dd82f15431cc8d40779821001404d10d2e79ca7d79"
 
+[[package]]
+name = "pathdiff"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3bf70094d203e07844da868b634207e71bfab254fe713171fae9a6e751ccf31"
+
 [[package]]
 name = "pathdiff"
 version = "0.2.1"
@@ -2631,6 +2637,7 @@ dependencies = [
  "notify",
  "once_cell",
  "open",
+ "pathdiff 0.1.0",
  "same-file",
  "serde",
  "serde_json",
diff --git a/crates/typst-cli/Cargo.toml b/crates/typst-cli/Cargo.toml
index 52439bebb..c5b38be63 100644
--- a/crates/typst-cli/Cargo.toml
+++ b/crates/typst-cli/Cargo.toml
@@ -33,6 +33,7 @@ memmap2 = "0.5"
 notify = "5"
 once_cell = "1"
 open = "4.0.2"
+pathdiff = "0.1"
 same-file = "1"
 serde = "1.0.184"
 serde_json = "1"
diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs
index 0fa66d62c..5b51f4223 100644
--- a/crates/typst-cli/src/compile.rs
+++ b/crates/typst-cli/src/compile.rs
@@ -231,11 +231,23 @@ pub fn print_diagnostics(
 
 impl<'a> codespan_reporting::files::Files<'a> for SystemWorld {
     type FileId = FileId;
-    type Name = FileId;
+    type Name = String;
     type Source = Source;
 
     fn name(&'a self, id: FileId) -> CodespanResult<Self::Name> {
-        Ok(id)
+        let vpath = id.vpath();
+        Ok(if let Some(package) = id.package() {
+            format!("{package}{}", vpath.as_rooted_path().display())
+        } else {
+            // Try to express the path relative to the working directory.
+            vpath
+                .resolve(self.root())
+                .and_then(|abs| pathdiff::diff_paths(&abs, self.workdir()))
+                .as_deref()
+                .unwrap_or_else(|| vpath.as_rootless_path())
+                .to_string_lossy()
+                .into()
+        })
     }
 
     fn source(&'a self, id: FileId) -> CodespanResult<Self::Source> {
diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs
index fb8fc0c73..cfbe37910 100644
--- a/crates/typst-cli/src/world.rs
+++ b/crates/typst-cli/src/world.rs
@@ -11,8 +11,7 @@ use siphasher::sip128::{Hasher128, SipHasher13};
 use typst::diag::{FileError, FileResult, StrResult};
 use typst::eval::{eco_format, Bytes, Datetime, Library};
 use typst::font::{Font, FontBook};
-use typst::syntax::{FileId, Source};
-use typst::util::PathExt;
+use typst::syntax::{FileId, Source, VirtualPath};
 use typst::World;
 
 use crate::args::SharedArgs;
@@ -21,6 +20,8 @@ use crate::package::prepare_package;
 
 /// A world that provides access to the operating system.
 pub struct SystemWorld {
+    /// The working directory.
+    workdir: Option<PathBuf>,
     /// The root relative to which absolute paths are resolved.
     root: PathBuf,
     /// The input path.
@@ -49,7 +50,7 @@ impl SystemWorld {
         searcher.search(&command.font_paths);
 
         // Resolve the system-global input path.
-        let system_input = command.input.canonicalize().map_err(|_| {
+        let input = command.input.canonicalize().map_err(|_| {
             eco_format!("input file not found (searched at {})", command.input.display())
         })?;
 
@@ -58,22 +59,21 @@ impl SystemWorld {
             let path = command
                 .root
                 .as_deref()
-                .or_else(|| system_input.parent())
+                .or_else(|| input.parent())
                 .unwrap_or(Path::new("."));
             path.canonicalize().map_err(|_| {
                 eco_format!("root directory not found (searched at {})", path.display())
             })?
         };
 
-        // Resolve the input path within the project.
-        let project_input = system_input
-            .strip_prefix(&root)
-            .map(|path| Path::new("/").join(path))
-            .map_err(|_| "input file must be contained in project root")?;
+        // Resolve the virtual path of the main file within the project root.
+        let main_path = VirtualPath::within_root(&input, &root)
+            .ok_or("input file must be contained in project root")?;
 
         Ok(Self {
+            workdir: std::env::current_dir().ok(),
             root,
-            main: FileId::new(None, &project_input),
+            main: FileId::new(None, main_path),
             library: Prehashed::new(typst_library::build()),
             book: Prehashed::new(searcher.book),
             fonts: searcher.fonts,
@@ -88,6 +88,16 @@ impl SystemWorld {
         self.main
     }
 
+    /// The root relative to which absolute paths are resolved.
+    pub fn root(&self) -> &Path {
+        &self.root
+    }
+
+    /// The current working directory.
+    pub fn workdir(&self) -> &Path {
+        self.workdir.as_deref().unwrap_or(Path::new("."))
+    }
+
     /// Return all paths the last compilation depended on.
     pub fn dependencies(&mut self) -> impl Iterator<Item = &Path> {
         self.paths.get_mut().values().map(|slot| slot.system_path.as_path())
@@ -160,15 +170,16 @@ impl SystemWorld {
             .or_insert_with(|| {
                 // Determine the root path relative to which the file path
                 // will be resolved.
-                let root = match id.package() {
-                    Some(spec) => prepare_package(spec)?,
-                    None => self.root.clone(),
-                };
+                let buf;
+                let mut root = &self.root;
+                if let Some(spec) = id.package() {
+                    buf = prepare_package(spec)?;
+                    root = &buf;
+                }
 
                 // Join the path to the root. If it tries to escape, deny
                 // access. Note: It can still escape via symlinks.
-                system_path =
-                    root.join_rooted(id.path()).ok_or(FileError::AccessDenied)?;
+                system_path = id.vpath().resolve(root).ok_or(FileError::AccessDenied)?;
 
                 PathHash::new(&system_path)
             })
diff --git a/crates/typst-docs/src/html.rs b/crates/typst-docs/src/html.rs
index 38e56c093..4bb1def15 100644
--- a/crates/typst-docs/src/html.rs
+++ b/crates/typst-docs/src/html.rs
@@ -7,7 +7,7 @@ use typst::diag::FileResult;
 use typst::eval::{Bytes, Datetime, Tracer};
 use typst::font::{Font, FontBook};
 use typst::geom::{Point, Size};
-use typst::syntax::{FileId, Source};
+use typst::syntax::{FileId, Source, VirtualPath};
 use typst::World;
 use yaml_front_matter::YamlFrontMatter;
 
@@ -424,7 +424,7 @@ fn code_block(resolver: &dyn Resolver, lang: &str, text: &str) -> Html {
         return Html::new(format!("<pre>{}</pre>", highlighted.as_str()));
     }
 
-    let id = FileId::new(None, Path::new("/main.typ"));
+    let id = FileId::new(None, VirtualPath::new("main.typ"));
     let source = Source::new(id, compile);
     let world = DocWorld(source);
     let mut tracer = Tracer::default();
@@ -498,8 +498,8 @@ impl World for DocWorld {
     fn file(&self, id: FileId) -> FileResult<Bytes> {
         assert!(id.package().is_none());
         Ok(FILES
-            .get_file(id.path().strip_prefix("/").unwrap())
-            .unwrap_or_else(|| panic!("failed to load {:?}", id.path().display()))
+            .get_file(id.vpath().as_rootless_path())
+            .unwrap_or_else(|| panic!("failed to load {:?}", id.vpath()))
             .contents()
             .into())
     }
diff --git a/crates/typst-syntax/src/file.rs b/crates/typst-syntax/src/file.rs
index fc1bed212..6b3117cfb 100644
--- a/crates/typst-syntax/src/file.rs
+++ b/crates/typst-syntax/src/file.rs
@@ -16,6 +16,9 @@ use super::is_ident;
 static INTERNER: Lazy<RwLock<Interner>> =
     Lazy::new(|| RwLock::new(Interner { to_id: HashMap::new(), from_id: Vec::new() }));
 
+/// The path that we use for detached file ids.
+static DETACHED_PATH: Lazy<VirtualPath> = Lazy::new(|| VirtualPath::new("/unknown"));
+
 /// A package-path interner.
 struct Interner {
     to_id: HashMap<Pair, FileId>,
@@ -23,7 +26,7 @@ struct Interner {
 }
 
 /// An interned pair of a package specification and a path.
-type Pair = &'static (Option<PackageSpec>, PathBuf);
+type Pair = &'static (Option<PackageSpec>, VirtualPath);
 
 /// Identifies a file in a project or package.
 ///
@@ -37,16 +40,9 @@ impl FileId {
     /// The path must start with a `/` or this function will panic.
     /// Note that the path is normalized before interning.
     #[track_caller]
-    pub fn new(package: Option<PackageSpec>, path: &Path) -> Self {
-        assert_eq!(
-            path.components().next(),
-            Some(std::path::Component::RootDir),
-            "file path must be absolute within project or package: {}",
-            path.display(),
-        );
-
+    pub fn new(package: Option<PackageSpec>, path: VirtualPath) -> Self {
         // Try to find an existing entry that we can reuse.
-        let pair = (package, normalize_path(path));
+        let pair = (package, path);
         if let Some(&id) = INTERNER.read().unwrap().to_id.get(&pair) {
             return id;
         }
@@ -88,9 +84,9 @@ impl FileId {
 
     /// The absolute and normalized path to the file _within_ the project or
     /// package.
-    pub fn path(&self) -> &'static Path {
+    pub fn vpath(&self) -> &'static VirtualPath {
         if self.is_detached() {
-            Path::new("/detached.typ")
+            &DETACHED_PATH
         } else {
             &self.pair().1
         }
@@ -102,13 +98,7 @@ impl FileId {
             Err("cannot access file system from here")?;
         }
 
-        let package = self.package().cloned();
-        let base = self.path();
-        Ok(if let Some(parent) = base.parent() {
-            Self::new(package, &parent.join(path))
-        } else {
-            Self::new(package, Path::new(path))
-        })
+        Ok(Self::new(self.package().cloned(), self.vpath().join(path)))
     }
 
     /// Construct from a raw number.
@@ -127,47 +117,110 @@ impl FileId {
     }
 }
 
-impl Display for FileId {
-    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
-        let path = self.path().display();
+impl Debug for FileId {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        let vpath = self.vpath();
         match self.package() {
-            Some(package) => write!(f, "{package}{path}"),
-            None => write!(f, "{path}"),
+            Some(package) => write!(f, "{package:?}{vpath:?}"),
+            None => write!(f, "{vpath:?}"),
         }
     }
 }
 
-impl Debug for FileId {
-    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
-        Display::fmt(self, f)
-    }
-}
+/// An absolute path in the virtual file system of a project or package.
+#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct VirtualPath(PathBuf);
 
-/// Lexically normalize a path.
-fn normalize_path(path: &Path) -> PathBuf {
-    let mut out = PathBuf::new();
-    for component in path.components() {
-        match component {
-            Component::CurDir => {}
-            Component::ParentDir => match out.components().next_back() {
-                Some(Component::Normal(_)) => {
-                    out.pop();
-                }
-                _ => out.push(component),
-            },
-            Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
-                out.push(component)
+impl VirtualPath {
+    /// Create a new virtual path.
+    ///
+    /// Even if it doesn't start with `/` or `\`, it is still interpreted as
+    /// starting from the root.
+    pub fn new(path: impl AsRef<Path>) -> Self {
+        Self::new_impl(path.as_ref())
+    }
+
+    /// Non generic new implementation.
+    fn new_impl(path: &Path) -> Self {
+        let mut out = Path::new(&Component::RootDir).to_path_buf();
+        for component in path.components() {
+            match component {
+                Component::Prefix(_) | Component::RootDir => {}
+                Component::CurDir => {}
+                Component::ParentDir => match out.components().next_back() {
+                    Some(Component::Normal(_)) => {
+                        out.pop();
+                    }
+                    _ => out.push(component),
+                },
+                Component::Normal(_) => out.push(component),
             }
         }
+        Self(out)
     }
-    if out.as_os_str().is_empty() {
-        out.push(Component::CurDir);
+
+    /// Create a virtual path from a real path and a real root.
+    ///
+    /// Returns `None` if the file path is not contained in the root (i.e. if
+    /// `root` is not a lexical prefix of `path`). No file system operations are
+    /// performed.
+    pub fn within_root(path: &Path, root: &Path) -> Option<Self> {
+        path.strip_prefix(root).ok().map(Self::new)
+    }
+
+    /// Get the underlying path with a leading `/` or `\`.
+    pub fn as_rooted_path(&self) -> &Path {
+        &self.0
+    }
+
+    /// Get the underlying path without a leading `/` or `\`.
+    pub fn as_rootless_path(&self) -> &Path {
+        self.0.strip_prefix(Component::RootDir).unwrap_or(&self.0)
+    }
+
+    /// Resolve the virtual path relative to an actual file system root
+    /// (where the project or package resides).
+    ///
+    /// Returns `None` if the path lexically escapes the root. The path might
+    /// still escape through symlinks.
+    pub fn resolve(&self, root: &Path) -> Option<PathBuf> {
+        let root_len = root.as_os_str().len();
+        let mut out = root.to_path_buf();
+        for component in self.0.components() {
+            match component {
+                Component::Prefix(_) => {}
+                Component::RootDir => {}
+                Component::CurDir => {}
+                Component::ParentDir => {
+                    out.pop();
+                    if out.as_os_str().len() < root_len {
+                        return None;
+                    }
+                }
+                Component::Normal(_) => out.push(component),
+            }
+        }
+        Some(out)
+    }
+
+    /// Resolve a path relative to this virtual path.
+    pub fn join(&self, path: impl AsRef<Path>) -> Self {
+        if let Some(parent) = self.0.parent() {
+            Self::new(parent.join(path))
+        } else {
+            Self::new(path)
+        }
+    }
+}
+
+impl Debug for VirtualPath {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        Display::fmt(&self.0.display(), f)
     }
-    out
 }
 
 /// Identifies a package.
-#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+#[derive(Clone, Eq, PartialEq, Hash)]
 pub struct PackageSpec {
     /// The namespace the package lives in.
     pub namespace: EcoString,
@@ -217,6 +270,12 @@ impl FromStr for PackageSpec {
     }
 }
 
+impl Debug for PackageSpec {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        Display::fmt(self, f)
+    }
+}
+
 impl Display for PackageSpec {
     fn fmt(&self, f: &mut Formatter) -> fmt::Result {
         write!(f, "@{}/{}:{}", self.namespace, self.name, self.version)
@@ -224,7 +283,7 @@ impl Display for PackageSpec {
 }
 
 /// A package's version.
-#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
+#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
 pub struct PackageVersion {
     /// The package's major version.
     pub major: u32,
@@ -259,6 +318,12 @@ impl FromStr for PackageVersion {
     }
 }
 
+impl Debug for PackageVersion {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        Display::fmt(self, f)
+    }
+}
+
 impl Display for PackageVersion {
     fn fmt(&self, f: &mut Formatter) -> fmt::Result {
         write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
diff --git a/crates/typst-syntax/src/lib.rs b/crates/typst-syntax/src/lib.rs
index 8562bb19b..dec9a751e 100644
--- a/crates/typst-syntax/src/lib.rs
+++ b/crates/typst-syntax/src/lib.rs
@@ -11,7 +11,7 @@ mod reparser;
 mod source;
 mod span;
 
-pub use self::file::{FileId, PackageSpec, PackageVersion};
+pub use self::file::{FileId, PackageSpec, PackageVersion, VirtualPath};
 pub use self::kind::SyntaxKind;
 pub use self::lexer::{is_id_continue, is_id_start, is_ident, is_newline};
 pub use self::node::{LinkedChildren, LinkedNode, SyntaxError, SyntaxNode};
diff --git a/crates/typst-syntax/src/source.rs b/crates/typst-syntax/src/source.rs
index 25b3b86c6..036499aba 100644
--- a/crates/typst-syntax/src/source.rs
+++ b/crates/typst-syntax/src/source.rs
@@ -241,7 +241,7 @@ impl Source {
 
 impl Debug for Source {
     fn fmt(&self, f: &mut Formatter) -> fmt::Result {
-        write!(f, "Source({})", self.id().path().display())
+        write!(f, "Source({:?})", self.id().vpath())
     }
 }
 
diff --git a/crates/typst/src/eval/mod.rs b/crates/typst/src/eval/mod.rs
index 72945ab87..c446f7b1e 100644
--- a/crates/typst/src/eval/mod.rs
+++ b/crates/typst/src/eval/mod.rs
@@ -63,7 +63,6 @@ pub use self::value::{Dynamic, Type, Value};
 
 use std::collections::HashSet;
 use std::mem;
-use std::path::Path;
 
 use comemo::{Track, Tracked, TrackedMut, Validate};
 use ecow::{EcoString, EcoVec};
@@ -82,7 +81,7 @@ use crate::model::{
 use crate::syntax::ast::{self, AstNode};
 use crate::syntax::{
     parse, parse_code, parse_math, FileId, PackageSpec, PackageVersion, Source, Span,
-    Spanned, SyntaxKind, SyntaxNode,
+    Spanned, SyntaxKind, SyntaxNode, VirtualPath,
 };
 use crate::World;
 
@@ -101,7 +100,7 @@ pub fn eval(
     // Prevent cyclic evaluation.
     let id = source.id();
     if route.contains(id) {
-        panic!("Tried to cyclicly evaluate {}", id.path().display());
+        panic!("Tried to cyclicly evaluate {:?}", id.vpath());
     }
 
     // Hook up the lang items.
@@ -141,7 +140,13 @@ pub fn eval(
     }
 
     // Assemble the module.
-    let name = id.path().file_stem().unwrap_or_default().to_string_lossy();
+    let name = id
+        .vpath()
+        .as_rootless_path()
+        .file_stem()
+        .unwrap_or_default()
+        .to_string_lossy();
+
     Ok(Module::new(name).with_scope(vm.scopes.top).with_content(output))
 }
 
@@ -1798,7 +1803,7 @@ fn import(
 /// Import an external package.
 fn import_package(vm: &mut Vm, spec: PackageSpec, span: Span) -> SourceResult<Module> {
     // Evaluate the manifest.
-    let manifest_id = FileId::new(Some(spec.clone()), Path::new("/typst.toml"));
+    let manifest_id = FileId::new(Some(spec.clone()), VirtualPath::new("typst.toml"));
     let bytes = vm.world().file(manifest_id).at(span)?;
     let manifest = PackageManifest::parse(&bytes).at(span)?;
     manifest.validate(&spec).at(span)?;
diff --git a/crates/typst/src/util/mod.rs b/crates/typst/src/util/mod.rs
index a6e2d5ea7..1ba85bbb9 100644
--- a/crates/typst/src/util/mod.rs
+++ b/crates/typst/src/util/mod.rs
@@ -5,7 +5,6 @@ pub mod fat;
 use std::fmt::{self, Debug, Formatter};
 use std::hash::Hash;
 use std::num::NonZeroUsize;
-use std::path::{Component, Path, PathBuf};
 use std::sync::Arc;
 
 use siphasher::sip128::{Hasher128, SipHasher13};
@@ -104,40 +103,6 @@ where
     }
 }
 
-/// Extra methods for [`Path`].
-pub trait PathExt {
-    /// Treat `self` as a virtual root relative to which the `path` is resolved.
-    ///
-    /// Returns `None` if the path lexically escapes the root. The path
-    /// might still escape through symlinks.
-    fn join_rooted(&self, path: &Path) -> Option<PathBuf>;
-}
-
-impl PathExt for Path {
-    fn join_rooted(&self, path: &Path) -> Option<PathBuf> {
-        let mut parts: Vec<_> = self.components().collect();
-        let root = parts.len();
-        for component in path.components() {
-            match component {
-                Component::Prefix(_) => return None,
-                Component::RootDir => parts.truncate(root),
-                Component::CurDir => {}
-                Component::ParentDir => {
-                    if parts.len() <= root {
-                        return None;
-                    }
-                    parts.pop();
-                }
-                Component::Normal(_) => parts.push(component),
-            }
-        }
-        if parts.len() < root {
-            return None;
-        }
-        Some(parts.into_iter().collect())
-    }
-}
-
 /// Format pieces separated with commas and a final "and" or "or".
 pub fn separated_list(pieces: &[impl AsRef<str>], last: &str) -> String {
     let mut buf = String::new();
diff --git a/tests/src/tests.rs b/tests/src/tests.rs
index 16292ef2d..66c779008 100644
--- a/tests/src/tests.rs
+++ b/tests/src/tests.rs
@@ -25,8 +25,7 @@ use typst::doc::{Document, Frame, FrameItem, Meta};
 use typst::eval::{eco_format, func, Bytes, Datetime, Library, NoneValue, Tracer, Value};
 use typst::font::{Font, FontBook};
 use typst::geom::{Abs, Color, RgbaColor, Smart};
-use typst::syntax::{FileId, Source, Span, SyntaxNode};
-use typst::util::PathExt;
+use typst::syntax::{FileId, Source, Span, SyntaxNode, VirtualPath};
 use typst::World;
 use typst_library::layout::{Margin, PageElem};
 use typst_library::text::{TextElem, TextSize};
@@ -289,7 +288,7 @@ impl World for TestWorld {
 
 impl TestWorld {
     fn set(&mut self, path: &Path, text: String) -> Source {
-        self.main = FileId::new(None, &Path::new("/").join(path));
+        self.main = FileId::new(None, VirtualPath::new(path));
         let mut slot = self.slot(self.main).unwrap();
         let source = Source::new(self.main, text);
         slot.source = OnceCell::from(Ok(source.clone()));
@@ -302,7 +301,7 @@ impl TestWorld {
             None => PathBuf::new(),
         };
 
-        let system_path = root.join_rooted(id.path()).ok_or(FileError::AccessDenied)?;
+        let system_path = id.vpath().resolve(&root).ok_or(FileError::AccessDenied)?;
 
         Ok(RefMut::map(self.paths.borrow_mut(), |paths| {
             paths.entry(system_path.clone()).or_insert_with(|| PathSlot {