mirror of
https://github.com/typst/typst
synced 2025-08-16 07:58:32 +08:00
Compare commits
10 Commits
e28c5b2c25
...
53ddd84644
Author | SHA1 | Date | |
---|---|---|---|
|
53ddd84644 | ||
|
9a6268050f | ||
|
143732d2a8 | ||
|
efe81a1aae | ||
|
3b5ee3c488 | ||
|
f3d5cdc6a5 | ||
|
85e03abe45 | ||
|
c354369b05 | ||
|
91ec946283 | ||
|
a5ef75a9c2 |
@ -404,7 +404,9 @@ fn system_path(
|
||||
|
||||
// Join the path to the root. If it tries to escape, deny
|
||||
// access. Note: It can still escape via symlinks.
|
||||
id.vpath().resolve(root).ok_or(FileError::AccessDenied)
|
||||
id.vpath()
|
||||
.resolve(root)
|
||||
.ok_or_else(|| FileError::AccessDenied(id.vpath().as_rootless_path().into()))
|
||||
}
|
||||
|
||||
/// Reads a file from a `FileId`.
|
||||
@ -427,7 +429,7 @@ fn read(
|
||||
fn read_from_disk(path: &Path) -> FileResult<Vec<u8>> {
|
||||
let f = |e| FileError::from_io(e, path);
|
||||
if fs::metadata(path).map_err(f)?.is_dir() {
|
||||
Err(FileError::IsDirectory)
|
||||
Err(FileError::IsDirectory(path.into()))
|
||||
} else {
|
||||
fs::read(path).map_err(f)
|
||||
}
|
||||
|
@ -109,10 +109,7 @@ fn handle(
|
||||
styles.chain(&style),
|
||||
Region::new(Size::splat(Abs::inf()), Axes::splat(false)),
|
||||
)?;
|
||||
output.push(HtmlNode::Frame(HtmlFrame {
|
||||
inner: frame,
|
||||
text_size: styles.resolve(TextElem::size),
|
||||
}));
|
||||
output.push(HtmlNode::Frame(HtmlFrame::new(frame, styles)));
|
||||
} else {
|
||||
engine.sink.warn(warning!(
|
||||
child.span(),
|
||||
|
@ -2,10 +2,11 @@ use std::fmt::{self, Debug, Display, Formatter};
|
||||
|
||||
use ecow::{EcoString, EcoVec};
|
||||
use typst_library::diag::{bail, HintedStrResult, StrResult};
|
||||
use typst_library::foundations::{cast, Dict, Repr, Str};
|
||||
use typst_library::foundations::{cast, Dict, Repr, Str, StyleChain};
|
||||
use typst_library::introspection::{Introspector, Tag};
|
||||
use typst_library::layout::{Abs, Frame};
|
||||
use typst_library::model::DocumentInfo;
|
||||
use typst_library::text::TextElem;
|
||||
use typst_syntax::Span;
|
||||
use typst_utils::{PicoStr, ResolvedPicoStr};
|
||||
|
||||
@ -279,3 +280,10 @@ pub struct HtmlFrame {
|
||||
/// consistently.
|
||||
pub text_size: Abs,
|
||||
}
|
||||
|
||||
impl HtmlFrame {
|
||||
/// Wraps a laid-out frame.
|
||||
pub fn new(inner: Frame, styles: StyleChain) -> Self {
|
||||
Self { inner, text_size: styles.resolve(TextElem::size) }
|
||||
}
|
||||
}
|
||||
|
@ -121,6 +121,7 @@ fn write_children(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> {
|
||||
let pretty_inside = allows_pretty_inside(element.tag)
|
||||
&& element.children.iter().any(|node| match node {
|
||||
HtmlNode::Element(child) => wants_pretty_around(child.tag),
|
||||
HtmlNode::Frame(_) => true,
|
||||
_ => false,
|
||||
});
|
||||
|
||||
@ -305,14 +306,6 @@ fn write_escape(w: &mut Writer, c: char) -> StrResult<()> {
|
||||
|
||||
/// Encode a laid out frame into the writer.
|
||||
fn write_frame(w: &mut Writer, frame: &HtmlFrame) {
|
||||
// FIXME: This string replacement is obviously a hack.
|
||||
let svg = typst_svg::svg_frame(&frame.inner).replace(
|
||||
"<svg class",
|
||||
&format!(
|
||||
"<svg style=\"overflow: visible; width: {}em; height: {}em;\" class",
|
||||
frame.inner.width() / frame.text_size,
|
||||
frame.inner.height() / frame.text_size,
|
||||
),
|
||||
);
|
||||
let svg = typst_svg::svg_html_frame(&frame.inner, frame.text_size);
|
||||
w.buf.push_str(&svg);
|
||||
}
|
||||
|
@ -87,6 +87,7 @@ impl PackageStorage {
|
||||
) -> PackageResult<PathBuf> {
|
||||
let subdir = format!("{}/{}/{}", spec.namespace, spec.name, spec.version);
|
||||
|
||||
// By default, search for the package locally
|
||||
if let Some(packages_dir) = &self.package_path {
|
||||
let dir = packages_dir.join(&subdir);
|
||||
if dir.exists() {
|
||||
@ -94,6 +95,7 @@ impl PackageStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// As a fallback, look into the cache and possibly download from network.
|
||||
if let Some(cache_dir) = &self.package_cache_path {
|
||||
let dir = cache_dir.join(&subdir);
|
||||
if dir.exists() {
|
||||
@ -102,14 +104,42 @@ impl PackageStorage {
|
||||
|
||||
// Download from network if it doesn't exist yet.
|
||||
if spec.namespace == DEFAULT_NAMESPACE {
|
||||
self.download_package(spec, cache_dir, progress)?;
|
||||
if dir.exists() {
|
||||
return Ok(dir);
|
||||
}
|
||||
return self.download_package(spec, cache_dir, progress);
|
||||
}
|
||||
}
|
||||
|
||||
Err(PackageError::NotFound(spec.clone()))
|
||||
// None of the strategies above found the package, so all code paths
|
||||
// from now on fail. The rest of the function is only to determine the
|
||||
// cause of the failure.
|
||||
// We try `namespace/` then `namespace/name/` then `namespace/name/version/`
|
||||
// and see where the first error occurs.
|
||||
let not_found = |msg| Err(PackageError::NotFound(spec.clone(), msg));
|
||||
|
||||
let Some(packages_dir) = &self.package_path else {
|
||||
return not_found(eco_format!("cannot access local package storage"));
|
||||
};
|
||||
let namespace_dir = packages_dir.join(format!("{}", spec.namespace));
|
||||
if !namespace_dir.exists() {
|
||||
return not_found(eco_format!(
|
||||
"namespace @{} should be located at {}",
|
||||
spec.namespace,
|
||||
namespace_dir.display()
|
||||
));
|
||||
}
|
||||
let package_dir = namespace_dir.join(format!("{}", spec.name));
|
||||
if !package_dir.exists() {
|
||||
return not_found(eco_format!(
|
||||
"{} does not have package '{}'",
|
||||
namespace_dir.display(),
|
||||
spec.name
|
||||
));
|
||||
}
|
||||
let latest = self.determine_latest_version(&spec.versionless()).ok();
|
||||
Err(PackageError::VersionNotFound(
|
||||
spec.clone(),
|
||||
latest,
|
||||
eco_format!("{}", namespace_dir.display()),
|
||||
))
|
||||
}
|
||||
|
||||
/// Tries to determine the latest version of a package.
|
||||
@ -171,21 +201,29 @@ impl PackageStorage {
|
||||
spec: &PackageSpec,
|
||||
cache_dir: &Path,
|
||||
progress: &mut dyn Progress,
|
||||
) -> PackageResult<()> {
|
||||
) -> PackageResult<PathBuf> {
|
||||
assert_eq!(spec.namespace, DEFAULT_NAMESPACE);
|
||||
|
||||
let url = format!(
|
||||
"{DEFAULT_REGISTRY}/{DEFAULT_NAMESPACE}/{}-{}.tar.gz",
|
||||
spec.name, spec.version
|
||||
);
|
||||
let namespace_url = format!("{DEFAULT_REGISTRY}/{DEFAULT_NAMESPACE}");
|
||||
let url = format!("{namespace_url}/{}-{}.tar.gz", spec.name, spec.version);
|
||||
|
||||
let data = match self.downloader.download_with_progress(&url, progress) {
|
||||
Ok(data) => data,
|
||||
Err(ureq::Error::Status(404, _)) => {
|
||||
if let Ok(version) = self.determine_latest_version(&spec.versionless()) {
|
||||
return Err(PackageError::VersionNotFound(spec.clone(), version));
|
||||
return Err(PackageError::VersionNotFound(
|
||||
spec.clone(),
|
||||
Some(version),
|
||||
eco_format!("{namespace_url}"),
|
||||
));
|
||||
} else {
|
||||
return Err(PackageError::NotFound(spec.clone()));
|
||||
return Err(PackageError::NotFound(
|
||||
spec.clone(),
|
||||
eco_format!(
|
||||
"{namespace_url} does not have package '{}'",
|
||||
spec.name
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
@ -235,8 +273,8 @@ impl PackageStorage {
|
||||
// broken packages still occur even with the rename safeguard, we might
|
||||
// consider more complex solutions like file locking or checksums.
|
||||
match fs::rename(&tempdir, &package_dir) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == io::ErrorKind::DirectoryNotEmpty => Ok(()),
|
||||
Ok(()) => Ok(package_dir),
|
||||
Err(err) if err.kind() == io::ErrorKind::DirectoryNotEmpty => Ok(package_dir),
|
||||
Err(err) => Err(error("failed to move downloaded package directory", err)),
|
||||
}
|
||||
}
|
||||
|
@ -440,13 +440,13 @@ pub enum FileError {
|
||||
/// A file was not found at this path.
|
||||
NotFound(PathBuf),
|
||||
/// A file could not be accessed.
|
||||
AccessDenied,
|
||||
AccessDenied(PathBuf),
|
||||
/// A directory was found, but a file was expected.
|
||||
IsDirectory,
|
||||
IsDirectory(PathBuf),
|
||||
/// The file is not a Typst source file, but should have been.
|
||||
NotSource,
|
||||
NotSource(PathBuf),
|
||||
/// The file was not valid UTF-8, but should have been.
|
||||
InvalidUtf8,
|
||||
InvalidUtf8(Option<PathBuf>),
|
||||
/// The package the file is part of could not be loaded.
|
||||
Package(PackageError),
|
||||
/// Another error.
|
||||
@ -460,11 +460,11 @@ impl FileError {
|
||||
pub fn from_io(err: io::Error, path: &Path) -> Self {
|
||||
match err.kind() {
|
||||
io::ErrorKind::NotFound => Self::NotFound(path.into()),
|
||||
io::ErrorKind::PermissionDenied => Self::AccessDenied,
|
||||
io::ErrorKind::PermissionDenied => Self::AccessDenied(path.into()),
|
||||
io::ErrorKind::InvalidData
|
||||
if err.to_string().contains("stream did not contain valid UTF-8") =>
|
||||
{
|
||||
Self::InvalidUtf8
|
||||
Self::InvalidUtf8(Some(path.into()))
|
||||
}
|
||||
_ => Self::Other(Some(eco_format!("{err}"))),
|
||||
}
|
||||
@ -479,10 +479,19 @@ impl Display for FileError {
|
||||
Self::NotFound(path) => {
|
||||
write!(f, "file not found (searched at {})", path.display())
|
||||
}
|
||||
Self::AccessDenied => f.pad("failed to load file (access denied)"),
|
||||
Self::IsDirectory => f.pad("failed to load file (is a directory)"),
|
||||
Self::NotSource => f.pad("not a typst source file"),
|
||||
Self::InvalidUtf8 => f.pad("file is not valid utf-8"),
|
||||
Self::AccessDenied(path) => {
|
||||
write!(f, "failed to load file {} (access denied)", path.display())
|
||||
}
|
||||
Self::IsDirectory(path) => {
|
||||
write!(f, "failed to load file {} (is a directory)", path.display())
|
||||
}
|
||||
Self::NotSource(path) => {
|
||||
write!(f, "{} is not a typst source file", path.display())
|
||||
}
|
||||
Self::InvalidUtf8(Some(path)) => {
|
||||
write!(f, "file {} is not valid utf-8", path.display())
|
||||
}
|
||||
Self::InvalidUtf8(None) => f.pad("file is not valid utf-8"),
|
||||
Self::Package(error) => error.fmt(f),
|
||||
Self::Other(Some(err)) => write!(f, "failed to load file ({err})"),
|
||||
Self::Other(None) => f.pad("failed to load file"),
|
||||
@ -492,13 +501,13 @@ impl Display for FileError {
|
||||
|
||||
impl From<Utf8Error> for FileError {
|
||||
fn from(_: Utf8Error) -> Self {
|
||||
Self::InvalidUtf8
|
||||
Self::InvalidUtf8(None)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FromUtf8Error> for FileError {
|
||||
fn from(_: FromUtf8Error) -> Self {
|
||||
Self::InvalidUtf8
|
||||
Self::InvalidUtf8(None)
|
||||
}
|
||||
}
|
||||
|
||||
@ -519,13 +528,15 @@ pub type PackageResult<T> = Result<T, PackageError>;
|
||||
|
||||
/// An error that occurred while trying to load a package.
|
||||
///
|
||||
/// Some variants have an optional string can give more details, if available.
|
||||
/// Some variants have an optional string that can give more details, if available.
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
|
||||
pub enum PackageError {
|
||||
/// The specified package does not exist.
|
||||
NotFound(PackageSpec),
|
||||
/// Additionally provides information on where we tried to find the package.
|
||||
NotFound(PackageSpec, EcoString),
|
||||
/// The specified package found, but the version does not exist.
|
||||
VersionNotFound(PackageSpec, PackageVersion),
|
||||
/// TODO: make the registry part of the error better typed
|
||||
VersionNotFound(PackageSpec, Option<PackageVersion>, EcoString),
|
||||
/// Failed to retrieve the package through the network.
|
||||
NetworkFailed(Option<EcoString>),
|
||||
/// The package archive was malformed.
|
||||
@ -539,15 +550,20 @@ impl std::error::Error for PackageError {}
|
||||
impl Display for PackageError {
|
||||
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Self::NotFound(spec) => {
|
||||
write!(f, "package not found (searched for {spec})",)
|
||||
Self::NotFound(spec, detail) => {
|
||||
write!(f, "package not found: {detail} (searching for {spec})",)
|
||||
}
|
||||
Self::VersionNotFound(spec, latest) => {
|
||||
Self::VersionNotFound(spec, latest, registry) => {
|
||||
write!(
|
||||
f,
|
||||
"package found, but version {} does not exist (latest is {})",
|
||||
spec.version, latest,
|
||||
)
|
||||
"package '{}' found, but version {} does not exist",
|
||||
spec.name, spec.version
|
||||
)?;
|
||||
if let Some(version) = latest {
|
||||
write!(f, " (latest version provided by {registry} is {version})")
|
||||
} else {
|
||||
write!(f, " ({registry} contains no versions for this package)")
|
||||
}
|
||||
}
|
||||
Self::NetworkFailed(Some(err)) => {
|
||||
write!(f, "failed to download package ({err})")
|
||||
|
@ -45,6 +45,30 @@ pub fn svg_frame(frame: &Frame) -> String {
|
||||
renderer.finalize()
|
||||
}
|
||||
|
||||
/// Export a frame into an SVG suitable for embedding into HTML.
|
||||
#[typst_macros::time(name = "svg html frame")]
|
||||
pub fn svg_html_frame(frame: &Frame, text_size: Abs) -> String {
|
||||
let mut renderer = SVGRenderer::with_options(xmlwriter::Options {
|
||||
indent: xmlwriter::Indent::None,
|
||||
..Default::default()
|
||||
});
|
||||
renderer.write_header_with_custom_attrs(frame.size(), |xml| {
|
||||
xml.write_attribute("class", "typst-frame");
|
||||
xml.write_attribute_fmt(
|
||||
"style",
|
||||
format_args!(
|
||||
"overflow: visible; width: {}em; height: {}em;",
|
||||
frame.width() / text_size,
|
||||
frame.height() / text_size,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
let state = State::new(frame.size(), Transform::identity());
|
||||
renderer.render_frame(state, Transform::identity(), frame);
|
||||
renderer.finalize()
|
||||
}
|
||||
|
||||
/// Export a document with potentially multiple pages into a single SVG file.
|
||||
///
|
||||
/// The padding will be added around and between the individual frames.
|
||||
@ -158,8 +182,13 @@ impl State {
|
||||
impl SVGRenderer {
|
||||
/// Create a new SVG renderer with empty glyph and clip path.
|
||||
fn new() -> Self {
|
||||
Self::with_options(Default::default())
|
||||
}
|
||||
|
||||
/// Create a new SVG renderer with the given configuration.
|
||||
fn with_options(options: xmlwriter::Options) -> Self {
|
||||
SVGRenderer {
|
||||
xml: XmlWriter::new(xmlwriter::Options::default()),
|
||||
xml: XmlWriter::new(options),
|
||||
glyphs: Deduplicator::new('g'),
|
||||
clip_paths: Deduplicator::new('c'),
|
||||
gradient_refs: Deduplicator::new('g'),
|
||||
@ -170,11 +199,22 @@ impl SVGRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the SVG header, including the `viewBox` and `width` and `height`
|
||||
/// attributes.
|
||||
/// Write the default SVG header, including a `typst-doc` class, the
|
||||
/// `viewBox` and `width` and `height` attributes.
|
||||
fn write_header(&mut self, size: Size) {
|
||||
self.write_header_with_custom_attrs(size, |xml| {
|
||||
xml.write_attribute("class", "typst-doc");
|
||||
});
|
||||
}
|
||||
|
||||
/// Write the SVG header with additional attributes and standard attributes.
|
||||
fn write_header_with_custom_attrs(
|
||||
&mut self,
|
||||
size: Size,
|
||||
write_custom_attrs: impl FnOnce(&mut XmlWriter),
|
||||
) {
|
||||
self.xml.start_element("svg");
|
||||
self.xml.write_attribute("class", "typst-doc");
|
||||
write_custom_attrs(&mut self.xml);
|
||||
self.xml.write_attribute_fmt(
|
||||
"viewBox",
|
||||
format_args!("0 0 {} {}", size.x.to_pt(), size.y.to_pt()),
|
||||
|
@ -191,7 +191,7 @@ fn hint_invalid_main_file(
|
||||
file_error: FileError,
|
||||
input: FileId,
|
||||
) -> EcoVec<SourceDiagnostic> {
|
||||
let is_utf8_error = matches!(file_error, FileError::InvalidUtf8);
|
||||
let is_utf8_error = matches!(file_error, FileError::InvalidUtf8(_));
|
||||
let mut diagnostic =
|
||||
SourceDiagnostic::error(Span::detached(), EcoString::from(file_error));
|
||||
|
||||
|
11
tests/ref/html/html-frame.html
Normal file
11
tests/ref/html/html-frame.html
Normal file
@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body>
|
||||
<p>A rectangle:</p>
|
||||
<svg class="typst-frame" style="overflow: visible; width: 4.5em; height: 3em;" viewBox="0 0 45 30" width="45pt" height="30pt" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:h5="http://www.w3.org/1999/xhtml"><g><g transform="translate(-0 -0)"><path class="typst-shape" fill="none" stroke="#000000" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="4" d="M 0 0 L 0 30 L 45 30 L 45 0 Z "/></g></g></svg>
|
||||
</body>
|
||||
</html>
|
@ -172,7 +172,9 @@ pub(crate) fn system_path(id: FileId) -> FileResult<PathBuf> {
|
||||
None => PathBuf::new(),
|
||||
};
|
||||
|
||||
id.vpath().resolve(&root).ok_or(FileError::AccessDenied)
|
||||
id.vpath()
|
||||
.resolve(&root)
|
||||
.ok_or_else(|| FileError::AccessDenied(id.vpath().as_rootless_path().into()))
|
||||
}
|
||||
|
||||
/// Read a file.
|
||||
@ -186,7 +188,7 @@ pub(crate) fn read(path: &Path) -> FileResult<Cow<'static, [u8]>> {
|
||||
|
||||
let f = |e| FileError::from_io(e, path);
|
||||
if fs::metadata(path).map_err(f)?.is_dir() {
|
||||
Err(FileError::IsDirectory)
|
||||
Err(FileError::IsDirectory(path.into()))
|
||||
} else {
|
||||
fs::read(path).map(Cow::Owned).map_err(f)
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
// No proper HTML tests here yet because we don't want to test SVG export just
|
||||
// yet. We'll definitely add tests at some point.
|
||||
--- html-frame html ---
|
||||
A rectangle:
|
||||
#html.frame(rect())
|
||||
|
||||
--- html-frame-in-layout ---
|
||||
// Ensure that HTML frames are transparent in layout. This is less important for
|
||||
|
@ -302,11 +302,11 @@
|
||||
#import 5 as x
|
||||
|
||||
--- import-from-string-invalid ---
|
||||
// Error: 9-11 failed to load file (is a directory)
|
||||
// Error: 9-11 failed to load file tests/suite/scripting (is a directory)
|
||||
#import "": name
|
||||
|
||||
--- import-from-string-renamed-invalid ---
|
||||
// Error: 9-11 failed to load file (is a directory)
|
||||
// Error: 9-11 failed to load file tests/suite/scripting (is a directory)
|
||||
#import "" as x
|
||||
|
||||
--- import-file-not-found-invalid ---
|
||||
@ -483,3 +483,4 @@ This is never reached.
|
||||
--- import-from-file-package-lookalike ---
|
||||
// Error: 9-28 file not found (searched at tests/suite/scripting/#test/mypkg:1.0.0)
|
||||
#import "#test/mypkg:1.0.0": *
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user