diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ddb309bb..01b3e8c3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.82.0 + - uses: dtolnay/rust-toolchain@1.83.0 - uses: Swatinem/rust-cache@v2 - run: cargo test --workspace --no-run - run: cargo test --workspace --no-fail-fast @@ -59,11 +59,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.82.0 + - uses: dtolnay/rust-toolchain@1.83.0 with: components: clippy, rustfmt - uses: Swatinem/rust-cache@v2 - run: cargo clippy --workspace --all-targets --all-features + - run: cargo clippy --workspace --all-targets --no-default-features - run: cargo fmt --check --all - run: cargo doc --workspace --no-deps diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84c70c2d2..5be6bfa2c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.82.0 + - uses: dtolnay/rust-toolchain@1.83.0 with: target: ${{ matrix.target }} diff --git a/Cargo.lock b/Cargo.lock index 7ccce83cc..33e2cb1b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,6 +123,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "autocfg" version = "1.4.0" @@ -296,6 +302,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "ciborium" version = "0.2.2" @@ -409,6 +421,11 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "codex" +version = "0.1.0" +source = "git+https://github.com/typst/codex?rev=343a9b1#343a9b199430681ba3ca0e2242097c6419492d55" + [[package]] name = "color-print" version = "0.3.6" @@ -919,9 +936,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.0" +version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" [[package]] name = "hayagriva" @@ -950,6 +967,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hypher" version = "0.1.5" @@ -1192,7 +1215,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown 0.15.0", + "hashbrown 0.15.2", "rayon", "serde", ] @@ -1368,13 +1391,12 @@ dependencies = [ [[package]] name = "libfuzzer-sys" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96cfd5557eb82f2b83fed4955246c988d331975a002961b07c81584d107e7f7" +checksum = "9b9569d2f74e257076d8c6bfa73fb505b46b851e51ddaecc825944aa3bed17fa" dependencies = [ "arbitrary", "cc", - "once_cell", ] [[package]] @@ -2355,6 +2377,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "sigpipe" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5584bfb3e0d348139d8210285e39f6d2f8a1902ac06de343e06357d1d763d8e6" +dependencies = [ + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -2697,6 +2728,18 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.7.6" @@ -2790,6 +2833,7 @@ dependencies = [ "comemo", "ecow", "typst-eval", + "typst-html", "typst-layout", "typst-library", "typst-macros", @@ -2802,7 +2846,7 @@ dependencies = [ [[package]] name = "typst-assets" version = "0.12.0" -source = "git+https://github.com/typst/typst-assets?rev=5c0dcc0#5c0dcc062eddd9c1b1a8994fe4948ab604a1690f" +source = "git+https://github.com/typst/typst-assets?rev=8cccef9#8cccef93b5da73a1c80389722cf2b655b624f577" [[package]] name = "typst-cli" @@ -2830,11 +2874,14 @@ dependencies = [ "serde_json", "serde_yaml 0.9.34+deprecated", "shell-escape", + "sigpipe", "tar", "tempfile", + "tiny_http", "toml", "typst", "typst-eval", + "typst-html", "typst-kit", "typst-macros", "typst-pdf", @@ -2902,6 +2949,20 @@ dependencies = [ "typst-syntax", ] +[[package]] +name = "typst-html" +version = "0.12.0" +dependencies = [ + "comemo", + "ecow", + "typst-library", + "typst-macros", + "typst-svg", + "typst-syntax", + "typst-timing", + "typst-utils", +] + [[package]] name = "typst-ide" version = "0.12.0" @@ -2979,6 +3040,7 @@ dependencies = [ "bumpalo", "chinese-number", "ciborium", + "codex", "comemo", "csv", "ecow", diff --git a/Cargo.toml b/Cargo.toml index c69a64d0b..6ef76afcb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ readme = "README.md" typst = { path = "crates/typst", version = "0.12.0" } typst-cli = { path = "crates/typst-cli", version = "0.12.0" } typst-eval = { path = "crates/typst-eval", version = "0.12.0" } +typst-html = { path = "crates/typst-html", version = "0.12.0" } typst-ide = { path = "crates/typst-ide", version = "0.12.0" } typst-kit = { path = "crates/typst-kit", version = "0.12.0" } typst-layout = { path = "crates/typst-layout", version = "0.12.0" } @@ -31,7 +32,7 @@ typst-svg = { path = "crates/typst-svg", version = "0.12.0" } typst-syntax = { path = "crates/typst-syntax", version = "0.12.0" } typst-timing = { path = "crates/typst-timing", version = "0.12.0" } typst-utils = { path = "crates/typst-utils", version = "0.12.0" } -typst-assets = { git = "https://github.com/typst/typst-assets", rev = "5c0dcc0" } +typst-assets = { git = "https://github.com/typst/typst-assets", rev = "8cccef9" } typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "b07d156" } arrayvec = "0.7.4" az = "1.2" @@ -46,6 +47,7 @@ clap = { version = "4.4", features = ["derive", "env", "wrap_help"] } clap_complete = "4.2.1" clap_mangen = "0.2.10" codespan-reporting = "0.11" +codex = { git = "https://github.com/typst/codex", rev = "343a9b1" } color-print = "0.3.6" comemo = "0.4" csv = "1" @@ -104,6 +106,7 @@ serde = { version = "1.0.184", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" shell-escape = "0.1.5" +sigpipe = "0.1" siphasher = "1" smallvec = { version = "1.11.1", features = ["union", "const_generics", "const_new"] } stacker = "0.1.15" @@ -115,6 +118,7 @@ tar = "0.4" tempfile = "3.7.0" thin-vec = "0.2.13" time = { version = "0.3.20", features = ["formatting", "macros", "parsing"] } +tiny_http = "0.12" tiny-skia = "0.11" toml = { version = "0.8", default-features = false, features = ["parse", "display"] } ttf-parser = "0.24.1" diff --git a/crates/typst-cli/Cargo.toml b/crates/typst-cli/Cargo.toml index 8f1407d6e..7e9b93f93 100644 --- a/crates/typst-cli/Cargo.toml +++ b/crates/typst-cli/Cargo.toml @@ -20,6 +20,7 @@ doc = false [dependencies] typst = { workspace = true } typst-eval = { workspace = true } +typst-html = { workspace = true } typst-kit = { workspace = true } typst-macros = { workspace = true } typst-pdf = { workspace = true } @@ -46,8 +47,10 @@ serde = { workspace = true } serde_json = { workspace = true } serde_yaml = { workspace = true } shell-escape = { workspace = true } +sigpipe = { workspace = true } tar = { workspace = true } tempfile = { workspace = true } +tiny_http = { workspace = true, optional = true } toml = { workspace = true } ureq = { workspace = true } xz2 = { workspace = true, optional = true } @@ -62,11 +65,14 @@ color-print = { workspace = true } semver = { workspace = true } [features] -default = ["embed-fonts"] +default = ["embed-fonts", "http-server"] # Embeds some fonts into the binary, see typst-kit embed-fonts = ["typst-kit/embed-fonts"] +# Enables the built-in HTTP server for `typst watch` and HTML export. +http-server = ["dep:tiny_http"] + # Permits the CLI to update itself without a package manager. self-update = ["dep:self-replace", "dep:xz2", "dep:zip"] diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index 9437f848c..83c4c8f9e 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -33,7 +33,7 @@ const AFTER_HELP: &str = color_print::cstr!("\ Forum for questions: https://forum.typst.app/ "); -/// The Typst compiler +/// The Typst compiler. #[derive(Debug, Clone, Parser)] #[clap( name = "typst", @@ -44,24 +44,16 @@ const AFTER_HELP: &str = color_print::cstr!("\ max_term_width = 80, )] pub struct CliArguments { - /// The command to run + /// The command to run. #[command(subcommand)] pub command: Command, - /// Set when to use color. - /// auto = use color if a capable terminal is detected - #[clap( - long, - value_name = "WHEN", - require_equals = true, - num_args = 0..=1, - default_value = "auto", - default_missing_value = "always", - )] + /// Whether to use color. When set to `auto` if the terminal to supports it. + #[clap(long, default_value_t = ColorChoice::Auto, default_missing_value = "always")] pub color: ColorChoice, /// Path to a custom CA certificate to use when making network requests. - #[clap(long = "cert", env = "TYPST_CERT")] + #[clap(long, env = "TYPST_CERT")] pub cert: Option, } @@ -69,109 +61,53 @@ pub struct CliArguments { #[derive(Debug, Clone, Subcommand)] #[command()] pub enum Command { - /// Compiles an input file into a supported output format + /// Compiles an input file into a supported output format. #[command(visible_alias = "c")] Compile(CompileCommand), - /// Watches an input file and recompiles on changes + /// Watches an input file and recompiles on changes. #[command(visible_alias = "w")] - Watch(CompileCommand), + Watch(WatchCommand), - /// Initializes a new project from a template + /// Initializes a new project from a template. Init(InitCommand), - /// Processes an input file to extract provided metadata + /// Processes an input file to extract provided metadata. Query(QueryCommand), - /// Lists all discovered fonts in system and custom font paths + /// Lists all discovered fonts in system and custom font paths. Fonts(FontsCommand), - /// Self update the Typst CLI + /// Self update the Typst CLI. #[cfg_attr(not(feature = "self-update"), clap(hide = true))] Update(UpdateCommand), } -/// Compiles an input file into a supported output format +/// Compiles an input file into a supported output format. #[derive(Debug, Clone, Parser)] pub struct CompileCommand { - /// Shared arguments + /// Arguments for compilation. #[clap(flatten)] - pub common: SharedArgs, - - /// Path to output file (PDF, PNG or SVG). Use `-` to write output to stdout. - /// - /// For output formats emitting one file per page (PNG & SVG), a page number template - /// must be present if the source document renders to multiple pages. Use `{p}` for page - /// numbers, `{0p}` for zero padded page numbers and `{t}` for page count. For example, - /// `page-{0p}-of-{t}.png` creates `page-01-of-10.png`, `page-02-of-10.png` and so on. - #[clap( - required_if_eq("input", "-"), - value_parser = make_output_value_parser(), - value_hint = ValueHint::FilePath, - )] - pub output: Option, - - /// Which pages to export. When unspecified, all document pages are exported. - /// - /// Pages to export are separated by commas, and can be either simple page - /// numbers (e.g. '2,5' to export only pages 2 and 5) or page ranges - /// (e.g. '2,3-6,8-' to export page 2, pages 3 to 6 (inclusive), page 8 and - /// any pages after it). - /// - /// Page numbers are one-indexed and correspond to real page numbers in the - /// document (therefore not being affected by the document's page counter). - #[arg(long = "pages", value_delimiter = ',')] - pub pages: Option>, - - /// Output a Makefile rule describing the current compilation - #[clap(long = "make-deps", value_name = "PATH")] - pub make_deps: Option, - - /// The format of the output file, inferred from the extension by default - #[arg(long = "format", short = 'f')] - pub format: Option, - - /// Opens the output file with the default viewer or a specific program after - /// compilation - /// - /// Ignored if output is stdout. - #[arg(long = "open", value_name = "VIEWER")] - pub open: Option>, - - /// The PPI (pixels per inch) to use for PNG export - #[arg(long = "ppi", default_value_t = 144.0)] - pub ppi: f32, - - /// Produces performance timings of the compilation process (experimental) - /// - /// The resulting JSON file can be loaded into a tracing tool such as - /// https://ui.perfetto.dev. It does not contain any sensitive information - /// apart from file names and line numbers. - #[arg(long = "timings", value_name = "OUTPUT_JSON")] - pub timings: Option>, - - /// One (or multiple comma-separated) PDF standards that Typst will enforce - /// conformance with. - #[arg(long = "pdf-standard", value_delimiter = ',')] - pub pdf_standard: Vec, + pub args: CompileArgs, } -/// A PDF standard that Typst can enforce conformance with. -#[derive(Debug, Copy, Clone, Eq, PartialEq, ValueEnum)] -#[allow(non_camel_case_types)] -pub enum PdfStandard { - /// PDF 1.7. - #[value(name = "1.7")] - V_1_7, - /// PDF/A-2b. - #[value(name = "a-2b")] - A_2b, +/// Compiles an input file into a supported output format. +#[derive(Debug, Clone, Parser)] +pub struct WatchCommand { + /// Arguments for compilation. + #[clap(flatten)] + pub args: CompileArgs, + + /// Arguments for the HTTP server. + #[cfg(feature = "http-server")] + #[clap(flatten)] + pub server: ServerArgs, } -/// Initializes a new project from a template +/// Initializes a new project from a template. #[derive(Debug, Clone, Parser)] pub struct InitCommand { - /// The template to use, e.g. `@preview/charged-ieee` + /// The template to use, e.g. `@preview/charged-ieee`. /// /// You can specify the version by appending e.g. `:0.1.0`. If no version is /// specified, Typst will default to the latest version. @@ -179,34 +115,34 @@ pub struct InitCommand { /// Supports both local and published templates. pub template: String, - /// The project directory, defaults to the template's name + /// The project directory, defaults to the template's name. pub dir: Option, - /// Arguments related to storage of packages in the system + /// Arguments related to storage of packages in the system. #[clap(flatten)] - pub package_storage_args: PackageStorageArgs, + pub package: PackageArgs, } -/// Processes an input file to extract provided metadata +/// Processes an input file to extract provided metadata. #[derive(Debug, Clone, Parser)] pub struct QueryCommand { - /// Shared arguments - #[clap(flatten)] - pub common: SharedArgs, + /// Path to input Typst file. Use `-` to read input from stdin. + #[clap(value_parser = input_value_parser(), value_hint = ValueHint::FilePath)] + pub input: Input, - /// Defines which elements to retrieve + /// Defines which elements to retrieve. pub selector: String, - /// Extracts just one field from all retrieved elements + /// Extracts just one field from all retrieved elements. #[clap(long = "field")] pub field: Option, - /// Expects and retrieves exactly one element + /// Expects and retrieves exactly one element. #[clap(long = "one", default_value = "false")] pub one: bool, - /// The format to serialize in - #[clap(long = "format", default_value = "json")] + /// The format to serialize in. + #[clap(long = "format", default_value_t)] pub format: SerializationFormat, /// Whether to pretty-print the serialized output. @@ -214,38 +150,153 @@ pub struct QueryCommand { /// Only applies to JSON format. #[clap(long)] pub pretty: bool, + + /// World arguments. + #[clap(flatten)] + pub world: WorldArgs, + + /// Processing arguments. + #[clap(flatten)] + pub process: ProcessArgs, } -// Output file format for query command -#[derive(Debug, Copy, Clone, Eq, PartialEq, ValueEnum)] -pub enum SerializationFormat { - Json, - Yaml, +/// Lists all discovered fonts in system and custom font paths. +#[derive(Debug, Clone, Parser)] +pub struct FontsCommand { + /// Common font arguments. + #[clap(flatten)] + pub font: FontArgs, + + /// Also lists style variants of each font family. + #[arg(long)] + pub variants: bool, } -/// Common arguments of compile, watch, and query. +/// Update the CLI using a pre-compiled binary from a Typst GitHub release. +#[derive(Debug, Clone, Parser)] +pub struct UpdateCommand { + /// Which version to update to (defaults to latest). + pub version: Option, + + /// Forces a downgrade to an older version (required for downgrading). + #[clap(long, default_value_t = false)] + pub force: bool, + + /// Reverts to the version from before the last update (only possible if + /// `typst update` has previously ran). + #[clap( + long, + default_value_t = false, + conflicts_with = "version", + conflicts_with = "force" + )] + pub revert: bool, + + /// Custom path to the backup file created on update and used by `--revert`, + /// defaults to system-dependent location + #[clap(long = "backup-path", env = "TYPST_UPDATE_BACKUP_PATH", value_name = "FILE")] + pub backup_path: Option, +} + +/// Arguments for compilation and watching. #[derive(Debug, Clone, Args)] -pub struct SharedArgs { - /// Path to input Typst file. Use `-` to read input from stdin - #[clap(value_parser = make_input_value_parser(), value_hint = ValueHint::FilePath)] +pub struct CompileArgs { + /// Path to input Typst file. Use `-` to read input from stdin. + #[clap(value_parser = input_value_parser(), value_hint = ValueHint::FilePath)] pub input: Input, - /// Configures the project root (for absolute paths) + /// Path to output file (PDF, PNG, SVG, or HTML). Use `-` to write output to + /// stdout. + /// + /// For output formats emitting one file per page (PNG & SVG), a page number + /// template must be present if the source document renders to multiple + /// pages. Use `{p}` for page numbers, `{0p}` for zero padded page numbers + /// and `{t}` for page count. For example, `page-{0p}-of-{t}.png` creates + /// `page-01-of-10.png`, `page-02-of-10.png`, and so on. + #[clap( + required_if_eq("input", "-"), + value_parser = output_value_parser(), + value_hint = ValueHint::FilePath, + )] + pub output: Option, + + /// The format of the output file, inferred from the extension by default. + #[arg(long = "format", short = 'f')] + pub format: Option, + + /// World arguments. + #[clap(flatten)] + pub world: WorldArgs, + + /// Which pages to export. When unspecified, all pages are exported. + /// + /// Pages to export are separated by commas, and can be either simple page + /// numbers (e.g. '2,5' to export only pages 2 and 5) or page ranges (e.g. + /// '2,3-6,8-' to export page 2, pages 3 to 6 (inclusive), page 8 and any + /// pages after it). + /// + /// Page numbers are one-indexed and correspond to physical page numbers in + /// the document (therefore not being affected by the document's page + /// counter). + #[arg(long = "pages", value_delimiter = ',')] + pub pages: Option>, + + /// One (or multiple comma-separated) PDF standards that Typst will enforce + /// conformance with. + #[arg(long = "pdf-standard", value_delimiter = ',')] + pub pdf_standard: Vec, + + /// The PPI (pixels per inch) to use for PNG export. + #[arg(long = "ppi", default_value_t = 144.0)] + pub ppi: f32, + + /// File path to which a Makefile with the current compilation's + /// dependencies will be written. + #[clap(long = "make-deps", value_name = "PATH")] + pub make_deps: Option, + + /// Processing arguments. + #[clap(flatten)] + pub process: ProcessArgs, + + /// Opens the output file with the default viewer or a specific program + /// after compilation. Ignored if output is stdout. + #[arg(long = "open", value_name = "VIEWER")] + pub open: Option>, + + /// Produces performance timings of the compilation process. (experimental) + /// + /// The resulting JSON file can be loaded into a tracing tool such as + /// https://ui.perfetto.dev. It does not contain any sensitive information + /// apart from file names and line numbers. + #[arg(long = "timings", value_name = "OUTPUT_JSON")] + pub timings: Option>, +} + +/// Arguments for the construction of a world. Shared by compile, watch, and +/// query. +#[derive(Debug, Clone, Args)] +pub struct WorldArgs { + /// Configures the project root (for absolute paths). #[clap(long = "root", env = "TYPST_ROOT", value_name = "DIR")] pub root: Option, - /// Add a string key-value pair visible through `sys.inputs` + /// Add a string key-value pair visible through `sys.inputs`. #[clap( long = "input", value_name = "key=value", action = ArgAction::Append, - value_parser = ValueParser::new(parse_input_pair), + value_parser = ValueParser::new(parse_sys_input_pair), )] pub inputs: Vec<(String, String)>, - /// Common font arguments + /// Common font arguments. #[clap(flatten)] - pub font_args: FontArgs, + pub font: FontArgs, + + /// Arguments related to storage of packages in the system. + #[clap(flatten)] + pub package: PackageArgs, /// The document's creation date formatted as a UNIX timestamp. /// @@ -257,42 +308,34 @@ pub struct SharedArgs { value_parser = parse_source_date_epoch, )] pub creation_timestamp: Option>, +} - /// The format to emit diagnostics in - #[clap( - long, - default_value_t = DiagnosticFormat::Human, - value_parser = clap::value_parser!(DiagnosticFormat) - )] - pub diagnostic_format: DiagnosticFormat, - - /// Arguments related to storage of packages in the system - #[clap(flatten)] - pub package_storage_args: PackageStorageArgs, - - /// Number of parallel jobs spawned during compilation, - /// defaults to number of CPUs. Setting it to 1 disables parallelism. +/// Arguments for configuration the process of compilation itself. +#[derive(Debug, Clone, Args)] +pub struct ProcessArgs { + /// Number of parallel jobs spawned during compilation. Defaults to number + /// of CPUs. Setting it to 1 disables parallelism. #[clap(long, short)] pub jobs: Option, /// Enables in-development features that may be changed or removed at any /// time. - #[arg(long = "feature", value_delimiter = ',')] - pub feature: Vec, -} + #[arg(long = "features", value_delimiter = ',', env = "TYPST_FEATURES")] + pub features: Vec, -/// An in-development feature that may be changed or removed at any time. -#[derive(Debug, Copy, Clone, Eq, PartialEq, ValueEnum)] -pub enum Feature {} + /// The format to emit diagnostics in. + #[clap(long, default_value_t)] + pub diagnostic_format: DiagnosticFormat, +} /// Arguments related to where packages are stored in the system. #[derive(Debug, Clone, Args)] -pub struct PackageStorageArgs { - /// Custom path to local packages, defaults to system-dependent location +pub struct PackageArgs { + /// Custom path to local packages, defaults to system-dependent location. #[clap(long = "package-path", env = "TYPST_PACKAGE_PATH", value_name = "DIR")] pub package_path: Option, - /// Custom path to package cache, defaults to system-dependent location + /// Custom path to package cache, defaults to system-dependent location. #[clap( long = "package-cache-path", env = "TYPST_PACKAGE_CACHE_PATH", @@ -301,13 +344,58 @@ pub struct PackageStorageArgs { pub package_cache_path: Option, } -/// Parses a UNIX timestamp according to -fn parse_source_date_epoch(raw: &str) -> Result, String> { - let timestamp: i64 = raw - .parse() - .map_err(|err| format!("timestamp must be decimal integer ({err})"))?; - DateTime::from_timestamp(timestamp, 0) - .ok_or_else(|| "timestamp out of range".to_string()) +/// Common arguments to customize available fonts. +#[derive(Debug, Clone, Parser)] +pub struct FontArgs { + /// Adds additional directories that are recursively searched for fonts. + /// + /// If multiple paths are specified, they are separated by the system's path + /// separator (`:` on Unix-like systems and `;` on Windows). + #[clap( + long = "font-path", + env = "TYPST_FONT_PATHS", + value_name = "DIR", + value_delimiter = ENV_PATH_SEP, + )] + pub font_paths: Vec, + + /// Ensures system fonts won't be searched, unless explicitly included via + /// `--font-path`. + #[arg(long)] + pub ignore_system_fonts: bool, +} + +/// Arguments for the HTTP server. +#[cfg(feature = "http-server")] +#[derive(Debug, Clone, Parser)] +pub struct ServerArgs { + /// Disables the built-in HTTP server for HTML export. + #[clap(long)] + pub no_serve: bool, + + /// Disables the injected live reload script for HTML export. The HTML that + /// is written to disk isn't affected either way. + #[clap(long)] + pub no_reload: bool, + + /// The port where HTML is served. + /// + /// Defaults to the first free port in the range 3000-3005. + #[clap(long)] + pub port: Option, +} + +macro_rules! display_possible_values { + ($ty:ty) => { + impl Display for $ty { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + self.to_possible_value() + .expect("no values are skipped") + .get_name() + .fmt(f) + } + } + }; } /// An input that is either stdin or a real path. @@ -319,6 +407,15 @@ pub enum Input { Path(PathBuf), } +impl Display for Input { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Input::Stdin => f.pad("stdin"), + Input::Path(path) => path.display().fmt(f), + } + } +} + /// An output that is either stdout or a real path. #[derive(Debug, Clone)] pub enum Output { @@ -337,8 +434,105 @@ impl Display for Output { } } +/// Which format to use for the generated output file. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, ValueEnum)] +pub enum OutputFormat { + Pdf, + Png, + Svg, + Html, +} + +display_possible_values!(OutputFormat); + +/// Which format to use for diagnostics. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, ValueEnum)] +pub enum DiagnosticFormat { + #[default] + Human, + Short, +} + +display_possible_values!(DiagnosticFormat); + +/// An in-development feature that may be changed or removed at any time. +#[derive(Debug, Copy, Clone, Eq, PartialEq, ValueEnum)] +pub enum Feature { + Html, +} + +display_possible_values!(Feature); + +/// A PDF standard that Typst can enforce conformance with. +#[derive(Debug, Copy, Clone, Eq, PartialEq, ValueEnum)] +#[allow(non_camel_case_types)] +pub enum PdfStandard { + /// PDF 1.7. + #[value(name = "1.7")] + V_1_7, + /// PDF/A-2b. + #[value(name = "a-2b")] + A_2b, +} + +display_possible_values!(PdfStandard); + +// Output file format for query command +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, ValueEnum)] +pub enum SerializationFormat { + #[default] + Json, + Yaml, +} + +display_possible_values!(SerializationFormat); + +/// Implements parsing of page ranges (`1-3`, `4`, `5-`, `-2`), used by the +/// `CompileCommand.pages` argument, through the `FromStr` trait instead of a +/// value parser, in order to generate better errors. +/// +/// See also: https://github.com/clap-rs/clap/issues/5065 +#[derive(Debug, Clone)] +pub struct Pages(pub RangeInclusive>); + +impl FromStr for Pages { + type Err = &'static str; + + fn from_str(value: &str) -> Result { + match value.split('-').map(str::trim).collect::>().as_slice() { + [] | [""] => Err("page export range must not be empty"), + [single_page] => { + let page_number = parse_page_number(single_page)?; + Ok(Pages(Some(page_number)..=Some(page_number))) + } + ["", ""] => Err("page export range must have start or end"), + [start, ""] => Ok(Pages(Some(parse_page_number(start)?)..=None)), + ["", end] => Ok(Pages(None..=Some(parse_page_number(end)?))), + [start, end] => { + let start = parse_page_number(start)?; + let end = parse_page_number(end)?; + if start > end { + Err("page export range must end at a page after the start") + } else { + Ok(Pages(Some(start)..=Some(end))) + } + } + [_, _, _, ..] => Err("page export range must have a single hyphen"), + } + } +} + +/// Parses a single page number. +fn parse_page_number(value: &str) -> Result { + if value == "0" { + Err("page numbers start at one") + } else { + NonZeroUsize::from_str(value).map_err(|_| "not a valid page number") + } +} + /// The clap value parser used by `SharedArgs.input` -fn make_input_value_parser() -> impl TypedValueParser { +fn input_value_parser() -> impl TypedValueParser { clap::builder::OsStringValueParser::new().try_map(|value| { if value.is_empty() { Err(clap::Error::new(clap::error::ErrorKind::InvalidValue)) @@ -351,7 +545,7 @@ fn make_input_value_parser() -> impl TypedValueParser { } /// The clap value parser used by `CompileCommand.output` -fn make_output_value_parser() -> impl TypedValueParser { +fn output_value_parser() -> impl TypedValueParser { clap::builder::OsStringValueParser::new().try_map(|value| { // Empty value also handled by clap for `Option` if value.is_empty() { @@ -368,7 +562,7 @@ fn make_output_value_parser() -> impl TypedValueParser { /// /// This function will return an error if the argument contains no equals sign /// or contains the key (before the equals sign) is empty. -fn parse_input_pair(raw: &str) -> Result<(String, String), String> { +fn parse_sys_input_pair(raw: &str) -> Result<(String, String), String> { let (key, val) = raw .split_once('=') .ok_or("input must be a key and a value separated by an equal sign")?; @@ -380,143 +574,11 @@ fn parse_input_pair(raw: &str) -> Result<(String, String), String> { Ok((key, val)) } -/// Implements parsing of page ranges (`1-3`, `4`, `5-`, `-2`), used by the -/// `CompileCommand.pages` argument, through the `FromStr` trait instead of -/// a value parser, in order to generate better errors. -/// -/// See also: https://github.com/clap-rs/clap/issues/5065 -#[derive(Debug, Clone)] -pub struct PageRangeArgument(RangeInclusive>); - -impl PageRangeArgument { - pub fn to_range(&self) -> RangeInclusive> { - self.0.clone() - } -} - -impl FromStr for PageRangeArgument { - type Err = &'static str; - - fn from_str(value: &str) -> Result { - match value.split('-').map(str::trim).collect::>().as_slice() { - [] | [""] => Err("page export range must not be empty"), - [single_page] => { - let page_number = parse_page_number(single_page)?; - Ok(PageRangeArgument(Some(page_number)..=Some(page_number))) - } - ["", ""] => Err("page export range must have start or end"), - [start, ""] => Ok(PageRangeArgument(Some(parse_page_number(start)?)..=None)), - ["", end] => Ok(PageRangeArgument(None..=Some(parse_page_number(end)?))), - [start, end] => { - let start = parse_page_number(start)?; - let end = parse_page_number(end)?; - if start > end { - Err("page export range must end at a page after the start") - } else { - Ok(PageRangeArgument(Some(start)..=Some(end))) - } - } - [_, _, _, ..] => Err("page export range must have a single hyphen"), - } - } -} - -fn parse_page_number(value: &str) -> Result { - if value == "0" { - Err("page numbers start at one") - } else { - NonZeroUsize::from_str(value).map_err(|_| "not a valid page number") - } -} - -/// Lists all discovered fonts in system and custom font paths -#[derive(Debug, Clone, Parser)] -pub struct FontsCommand { - /// Common font arguments - #[clap(flatten)] - pub font_args: FontArgs, - - /// Also lists style variants of each font family - #[arg(long)] - pub variants: bool, -} - -/// Common arguments to customize available fonts -#[derive(Debug, Clone, Parser)] -pub struct FontArgs { - /// Adds additional directories that are recursively searched for fonts - /// - /// If multiple paths are specified, they are separated by the system's path - /// separator (`:` on Unix-like systems and `;` on Windows). - #[clap( - long = "font-path", - env = "TYPST_FONT_PATHS", - value_name = "DIR", - value_delimiter = ENV_PATH_SEP, - )] - pub font_paths: Vec, - - /// Ensures system fonts won't be searched, unless explicitly included via - /// `--font-path` - #[arg(long)] - pub ignore_system_fonts: bool, -} - -/// Which format to use for diagnostics. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, ValueEnum)] -pub enum DiagnosticFormat { - Human, - Short, -} - -impl Display for DiagnosticFormat { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - self.to_possible_value() - .expect("no values are skipped") - .get_name() - .fmt(f) - } -} - -/// Update the CLI using a pre-compiled binary from a Typst GitHub release. -#[derive(Debug, Clone, Parser)] -pub struct UpdateCommand { - /// Which version to update to (defaults to latest) - pub version: Option, - - /// Forces a downgrade to an older version (required for downgrading) - #[clap(long, default_value_t = false)] - pub force: bool, - - /// Reverts to the version from before the last update (only possible if - /// `typst update` has previously ran) - #[clap( - long, - default_value_t = false, - conflicts_with = "version", - conflicts_with = "force" - )] - pub revert: bool, - - /// Custom path to the backup file created on update and used by `--revert`, - /// defaults to system-dependent location - #[clap(long = "backup-path", env = "TYPST_UPDATE_BACKUP_PATH", value_name = "FILE")] - pub backup_path: Option, -} - -/// Which format to use for the generated output file. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, ValueEnum)] -pub enum OutputFormat { - Pdf, - Png, - Svg, -} - -impl Display for OutputFormat { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - self.to_possible_value() - .expect("no values are skipped") - .get_name() - .fmt(f) - } +/// Parses a UNIX timestamp according to +fn parse_source_date_epoch(raw: &str) -> Result, String> { + let timestamp: i64 = raw + .parse() + .map_err(|err| format!("timestamp must be decimal integer ({err})"))?; + DateTime::from_timestamp(timestamp, 0) + .ok_or_else(|| "timestamp out of range".to_string()) } diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index 74d818a54..3aa3aa3b9 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -1,8 +1,9 @@ +use std::ffi::OsStr; use std::fs::{self, File}; use std::io::{self, Write}; use std::path::{Path, PathBuf}; -use chrono::{Datelike, Timelike}; +use chrono::{DateTime, Datelike, Timelike, Utc}; use codespan_reporting::diagnostic::{Diagnostic, Label}; use codespan_reporting::term; use ecow::{eco_format, EcoString}; @@ -12,17 +13,20 @@ use typst::diag::{ bail, At, Severity, SourceDiagnostic, SourceResult, StrResult, Warned, }; use typst::foundations::{Datetime, Smart}; -use typst::layout::{Frame, Page, PageRanges}; -use typst::model::Document; +use typst::html::HtmlDocument; +use typst::layout::{Frame, Page, PageRanges, PagedDocument}; use typst::syntax::{FileId, Source, Span}; use typst::WorldExt; use typst_pdf::{PdfOptions, PdfStandards}; use crate::args::{ - CompileCommand, DiagnosticFormat, Input, Output, OutputFormat, PageRangeArgument, - PdfStandard, + CompileArgs, CompileCommand, DiagnosticFormat, Input, Output, OutputFormat, + PdfStandard, WatchCommand, }; +#[cfg(feature = "http-server")] +use crate::server::HtmlServer; use crate::timings::Timer; + use crate::watch::Status; use crate::world::SystemWorld; use crate::{set_failed, terminal}; @@ -30,34 +34,73 @@ use crate::{set_failed, terminal}; type CodespanResult = Result; type CodespanError = codespan_reporting::files::Error; -impl CompileCommand { - /// The output path. - pub fn output(&self) -> Output { - self.output.clone().unwrap_or_else(|| { - let Input::Path(path) = &self.common.input else { - panic!("output must be specified when input is from stdin, as guarded by the CLI"); - }; - Output::Path(path.with_extension( - match self.output_format().unwrap_or(OutputFormat::Pdf) { - OutputFormat::Pdf => "pdf", - OutputFormat::Png => "png", - OutputFormat::Svg => "svg", - }, - )) - }) +/// Execute a compilation command. +pub fn compile(timer: &mut Timer, command: &CompileCommand) -> StrResult<()> { + let mut config = CompileConfig::new(command)?; + let mut world = + SystemWorld::new(&command.args.input, &command.args.world, &command.args.process) + .map_err(|err| eco_format!("{err}"))?; + timer.record(&mut world, |world| compile_once(world, &mut config))? +} + +/// A preprocessed `CompileCommand`. +pub struct CompileConfig { + /// Whether we are watching. + pub watching: bool, + /// Path to input Typst file or stdin. + pub input: Input, + /// Path to output file (PDF, PNG, SVG, or HTML). + pub output: Output, + /// The format of the output file. + pub output_format: OutputFormat, + /// Which pages to export. + pub pages: Option, + /// The document's creation date formatted as a UNIX timestamp. + pub creation_timestamp: Option>, + /// The format to emit diagnostics in. + pub diagnostic_format: DiagnosticFormat, + /// Opens the output file with the default viewer or a specific program after + /// compilation. + pub open: Option>, + /// One (or multiple comma-separated) PDF standards that Typst will enforce + /// conformance with. + pub pdf_standards: PdfStandards, + /// A path to write a Makefile rule describing the current compilation. + pub make_deps: Option, + /// The PPI (pixels per inch) to use for PNG export. + pub ppi: f32, + /// The export cache for images, used for caching output files in `typst + /// watch` sessions with images. + pub export_cache: ExportCache, + /// Server for `typst watch` to HTML. + #[cfg(feature = "http-server")] + pub server: Option, +} + +impl CompileConfig { + /// Preprocess a `CompileCommand`, producing a compilation config. + pub fn new(command: &CompileCommand) -> StrResult { + Self::new_impl(&command.args, None) } - /// The format to use for generated output, either specified by the user or inferred from the extension. - /// - /// Will return `Err` if the format was not specified and could not be inferred. - pub fn output_format(&self) -> StrResult { - Ok(if let Some(specified) = self.format { + /// Preprocess a `WatchCommand`, producing a compilation config. + pub fn watching(command: &WatchCommand) -> StrResult { + Self::new_impl(&command.args, Some(command)) + } + + /// The shared implementation of [`CompileConfig::new`] and + /// [`CompileConfig::watching`]. + fn new_impl(args: &CompileArgs, watch: Option<&WatchCommand>) -> StrResult { + let input = args.input.clone(); + + let output_format = if let Some(specified) = args.format { specified - } else if let Some(Output::Path(output)) = &self.output { + } else if let Some(Output::Path(output)) = &args.output { match output.extension() { Some(ext) if ext.eq_ignore_ascii_case("pdf") => OutputFormat::Pdf, Some(ext) if ext.eq_ignore_ascii_case("png") => OutputFormat::Png, Some(ext) if ext.eq_ignore_ascii_case("svg") => OutputFormat::Svg, + Some(ext) if ext.eq_ignore_ascii_case("html") => OutputFormat::Html, _ => bail!( "could not infer output format for path {}.\n\ consider providing the format manually with `--format/-f`", @@ -66,43 +109,65 @@ impl CompileCommand { } } else { OutputFormat::Pdf + }; + + let output = args.output.clone().unwrap_or_else(|| { + let Input::Path(path) = &input else { + panic!("output must be specified when input is from stdin, as guarded by the CLI"); + }; + Output::Path(path.with_extension( + match output_format { + OutputFormat::Pdf => "pdf", + OutputFormat::Png => "png", + OutputFormat::Svg => "svg", + OutputFormat::Html => "html", + }, + )) + }); + + let pages = args.pages.as_ref().map(|export_ranges| { + PageRanges::new(export_ranges.iter().map(|r| r.0.clone()).collect()) + }); + + let pdf_standards = { + let list = args + .pdf_standard + .iter() + .map(|standard| match standard { + PdfStandard::V_1_7 => typst_pdf::PdfStandard::V_1_7, + PdfStandard::A_2b => typst_pdf::PdfStandard::A_2b, + }) + .collect::>(); + PdfStandards::new(&list)? + }; + + #[cfg(feature = "http-server")] + let server = match watch { + Some(command) + if output_format == OutputFormat::Html && !command.server.no_serve => + { + Some(HtmlServer::new(&input, &command.server)?) + } + _ => None, + }; + + Ok(Self { + watching: watch.is_some(), + input, + output, + output_format, + pages, + pdf_standards, + creation_timestamp: args.world.creation_timestamp, + make_deps: args.make_deps.clone(), + ppi: args.ppi, + diagnostic_format: args.process.diagnostic_format, + open: args.open.clone(), + export_cache: ExportCache::new(), + #[cfg(feature = "http-server")] + server, }) } - - /// The ranges of the pages to be exported as specified by the user. - /// - /// This returns `None` if all pages should be exported. - pub fn exported_page_ranges(&self) -> Option { - self.pages.as_ref().map(|export_ranges| { - PageRanges::new( - export_ranges.iter().map(PageRangeArgument::to_range).collect(), - ) - }) - } - - /// The PDF standards to try to conform with. - pub fn pdf_standards(&self) -> StrResult { - let list = self - .pdf_standard - .iter() - .map(|standard| match standard { - PdfStandard::V_1_7 => typst_pdf::PdfStandard::V_1_7, - PdfStandard::A_2b => typst_pdf::PdfStandard::A_2b, - }) - .collect::>(); - PdfStandards::new(&list) - } -} - -/// Execute a compilation command. -pub fn compile(mut timer: Timer, mut command: CompileCommand) -> StrResult<()> { - // Only meant for input validation - _ = command.output_format()?; - - let mut world = - SystemWorld::new(&command.common).map_err(|err| eco_format!("{err}"))?; - timer.record(&mut world, |world| compile_once(world, &mut command, false))??; - Ok(()) } /// Compile a single time. @@ -111,96 +176,112 @@ pub fn compile(mut timer: Timer, mut command: CompileCommand) -> StrResult<()> { #[typst_macros::time(name = "compile once")] pub fn compile_once( world: &mut SystemWorld, - command: &mut CompileCommand, - watching: bool, + config: &mut CompileConfig, ) -> StrResult<()> { let start = std::time::Instant::now(); - if watching { - Status::Compiling.print(command).unwrap(); + if config.watching { + Status::Compiling.print(config).unwrap(); } - let Warned { output, warnings } = typst::compile(world); - let result = output.and_then(|document| export(world, &document, command, watching)); + let Warned { output, warnings } = compile_and_export(world, config); - match result { + match output { // Export the PDF / PNG. Ok(()) => { let duration = start.elapsed(); - if watching { + if config.watching { if warnings.is_empty() { - Status::Success(duration).print(command).unwrap(); + Status::Success(duration).print(config).unwrap(); } else { - Status::PartialSuccess(duration).print(command).unwrap(); + Status::PartialSuccess(duration).print(config).unwrap(); } } - print_diagnostics(world, &[], &warnings, command.common.diagnostic_format) + print_diagnostics(world, &[], &warnings, config.diagnostic_format) .map_err(|err| eco_format!("failed to print diagnostics ({err})"))?; - write_make_deps(world, command)?; - - if let Some(open) = command.open.take() { - if let Output::Path(file) = command.output() { - open_file(open.as_deref(), &file)?; - } - } + write_make_deps(world, config)?; + open_output(config)?; } // Print diagnostics. Err(errors) => { set_failed(); - if watching { - Status::Error.print(command).unwrap(); + if config.watching { + Status::Error.print(config).unwrap(); } - print_diagnostics( - world, - &errors, - &warnings, - command.common.diagnostic_format, - ) - .map_err(|err| eco_format!("failed to print diagnostics ({err})"))?; + print_diagnostics(world, &errors, &warnings, config.diagnostic_format) + .map_err(|err| eco_format!("failed to print diagnostics ({err})"))?; } } Ok(()) } -/// Export into the target format. -fn export( +/// Compile and then export the document. +fn compile_and_export( world: &mut SystemWorld, - document: &Document, - command: &CompileCommand, - watching: bool, -) -> SourceResult<()> { - match command.output_format().at(Span::detached())? { + config: &mut CompileConfig, +) -> Warned> { + match config.output_format { + OutputFormat::Html => { + let Warned { output, warnings } = typst::compile::(world); + let result = output.and_then(|document| export_html(&document, config)); + Warned { output: result, warnings } + } + _ => { + let Warned { output, warnings } = typst::compile::(world); + let result = output.and_then(|document| export_paged(&document, config)); + Warned { output: result, warnings } + } + } +} + +/// Export to HTML. +fn export_html(document: &HtmlDocument, config: &CompileConfig) -> SourceResult<()> { + let html = typst_html::html(document)?; + let result = config.output.write(html.as_bytes()); + + #[cfg(feature = "http-server")] + if let Some(server) = &config.server { + server.update(html); + } + + result + .map_err(|err| eco_format!("failed to write HTML file ({err})")) + .at(Span::detached()) +} + +/// Export to a paged target format. +fn export_paged(document: &PagedDocument, config: &CompileConfig) -> SourceResult<()> { + match config.output_format { + OutputFormat::Pdf => export_pdf(document, config), OutputFormat::Png => { - export_image(world, document, command, watching, ImageExportFormat::Png) - .at(Span::detached()) + export_image(document, config, ImageExportFormat::Png).at(Span::detached()) } OutputFormat::Svg => { - export_image(world, document, command, watching, ImageExportFormat::Svg) - .at(Span::detached()) + export_image(document, config, ImageExportFormat::Svg).at(Span::detached()) } - OutputFormat::Pdf => export_pdf(document, command), + OutputFormat::Html => unreachable!(), } } /// Export to a PDF. -fn export_pdf(document: &Document, command: &CompileCommand) -> SourceResult<()> { +fn export_pdf(document: &PagedDocument, config: &CompileConfig) -> SourceResult<()> { let options = PdfOptions { ident: Smart::Auto, timestamp: convert_datetime( - command.common.creation_timestamp.unwrap_or_else(chrono::Utc::now), + config.creation_timestamp.unwrap_or_else(chrono::Utc::now), ), - page_ranges: command.exported_page_ranges(), - standards: command.pdf_standards().at(Span::detached())?, + page_ranges: config.pages.clone(), + standards: config.pdf_standards.clone(), }; let buffer = typst_pdf::pdf(document, &options)?; - command - .output() + config + .output .write(&buffer) .map_err(|err| eco_format!("failed to write PDF file ({err})")) .at(Span::detached())?; @@ -228,36 +309,31 @@ enum ImageExportFormat { /// Export to one or multiple images. fn export_image( - world: &mut SystemWorld, - document: &Document, - command: &CompileCommand, - watching: bool, + document: &PagedDocument, + config: &CompileConfig, fmt: ImageExportFormat, ) -> StrResult<()> { - let output = command.output(); // Determine whether we have indexable templates in output - let can_handle_multiple = match output { + let can_handle_multiple = match config.output { Output::Stdout => false, Output::Path(ref output) => { output_template::has_indexable_template(output.to_str().unwrap_or_default()) } }; - let exported_page_ranges = command.exported_page_ranges(); - let exported_pages = document .pages .iter() .enumerate() .filter(|(i, _)| { - exported_page_ranges.as_ref().map_or(true, |exported_page_ranges| { + config.pages.as_ref().map_or(true, |exported_page_ranges| { exported_page_ranges.includes_page_index(*i) }) }) .collect::>(); if !can_handle_multiple && exported_pages.len() > 1 { - let err = match output { + let err = match config.output { Output::Stdout => "to stdout", Output::Path(_) => { "without a page number template ({p}, {0p}) in the output path" @@ -266,15 +342,13 @@ fn export_image( bail!("cannot export multiple images {err}"); } - let cache = world.export_cache(); - // The results are collected in a `Vec<()>` which does not allocate. exported_pages .par_iter() .map(|(i, page)| { // Use output with converted path. - let output = match output { - Output::Path(ref path) => { + let output = match &config.output { + Output::Path(path) => { let storage; let path = if can_handle_multiple { storage = output_template::format( @@ -290,7 +364,10 @@ fn export_image( // If we are not watching, don't use the cache. // If the frame is in the cache, skip it. // If the file does not exist, always create it. - if watching && cache.is_cached(*i, &page.frame) && path.exists() { + if config.watching + && config.export_cache.is_cached(*i, &page.frame) + && path.exists() + { return Ok(()); } @@ -299,7 +376,7 @@ fn export_image( Output::Stdout => Output::Stdout, }; - export_image_page(command, page, &output, fmt)?; + export_image_page(config, page, &output, fmt)?; Ok(()) }) .collect::, EcoString>>()?; @@ -338,14 +415,14 @@ mod output_template { /// Export single image. fn export_image_page( - command: &CompileCommand, + config: &CompileConfig, page: &Page, output: &Output, fmt: ImageExportFormat, ) -> StrResult<()> { match fmt { ImageExportFormat::Png => { - let pixmap = typst_render::render(page, command.ppi / 72.0); + let pixmap = typst_render::render(page, config.ppi / 72.0); let buf = pixmap .encode_png() .map_err(|err| eco_format!("failed to encode PNG file ({err})"))?; @@ -409,12 +486,12 @@ impl ExportCache { /// Writes a Makefile rule describing the relationship between the output and /// its dependencies to the path specified by the --make-deps argument, if it /// was provided. -fn write_make_deps(world: &mut SystemWorld, command: &CompileCommand) -> StrResult<()> { - let Some(ref make_deps_path) = command.make_deps else { return Ok(()) }; - let Output::Path(output_path) = command.output() else { +fn write_make_deps(world: &mut SystemWorld, config: &CompileConfig) -> StrResult<()> { + let Some(ref make_deps_path) = config.make_deps else { return Ok(()) }; + let Output::Path(output_path) = &config.output else { bail!("failed to create make dependencies file because output was stdout") }; - let Ok(output_path) = output_path.into_os_string().into_string() else { + let Some(output_path) = output_path.as_os_str().to_str() else { bail!("failed to create make dependencies file because output path was not valid unicode") }; @@ -452,13 +529,13 @@ fn write_make_deps(world: &mut SystemWorld, command: &CompileCommand) -> StrResu fn write( make_deps_path: &Path, - output_path: String, + output_path: &str, root: PathBuf, dependencies: impl Iterator, ) -> io::Result<()> { let mut file = File::create(make_deps_path)?; - file.write_all(munge(&output_path).as_bytes())?; + file.write_all(munge(output_path).as_bytes())?; file.write_all(b":")?; for dependency in dependencies { let Some(dependency) = @@ -484,21 +561,37 @@ fn write_make_deps(world: &mut SystemWorld, command: &CompileCommand) -> StrResu }) } -/// Opens the given file using: -/// - The default file viewer if `open` is `None`. -/// - The given viewer provided by `open` if it is `Some`. -/// -/// If the file could not be opened, an error is returned. -fn open_file(open: Option<&str>, path: &Path) -> StrResult<()> { +/// Opens the output if desired. +fn open_output(config: &mut CompileConfig) -> StrResult<()> { + let Some(viewer) = config.open.take() else { return Ok(()) }; + + #[cfg(feature = "http-server")] + if let Some(server) = &config.server { + let url = format!("http://{}", server.addr()); + return open_path(OsStr::new(&url), viewer.as_deref()); + } + + // Can't open stdout. + let Output::Path(path) = &config.output else { return Ok(()) }; + // Some resource openers require the path to be canonicalized. let path = path .canonicalize() .map_err(|err| eco_format!("failed to canonicalize path ({err})"))?; - if let Some(app) = open { - open::with_detached(&path, app) - .map_err(|err| eco_format!("failed to open file with {} ({})", app, err)) + + open_path(path.as_os_str(), viewer.as_deref()) +} + +/// Opens the given file using: +/// +/// - The default file viewer if `app` is `None`. +/// - The given viewer provided by `app` if it is `Some`. +fn open_path(path: &OsStr, viewer: Option<&str>) -> StrResult<()> { + if let Some(viewer) = viewer { + open::with_detached(path, viewer) + .map_err(|err| eco_format!("failed to open file with {} ({})", viewer, err)) } else { - open::that_detached(&path).map_err(|err| { + open::that_detached(path).map_err(|err| { let openers = open::commands(path) .iter() .map(|command| command.get_program().to_string_lossy()) diff --git a/crates/typst-cli/src/fonts.rs b/crates/typst-cli/src/fonts.rs index 01b0d9f75..dbe28d6f5 100644 --- a/crates/typst-cli/src/fonts.rs +++ b/crates/typst-cli/src/fonts.rs @@ -6,8 +6,8 @@ use crate::args::FontsCommand; /// Execute a font listing command. pub fn fonts(command: &FontsCommand) { let fonts = Fonts::searcher() - .include_system_fonts(!command.font_args.ignore_system_fonts) - .search_with(&command.font_args.font_paths); + .include_system_fonts(!command.font.ignore_system_fonts) + .search_with(&command.font.font_paths); for (name, infos) in fonts.book.families() { println!("{name}"); diff --git a/crates/typst-cli/src/init.rs b/crates/typst-cli/src/init.rs index 842419fc1..9a77fb470 100644 --- a/crates/typst-cli/src/init.rs +++ b/crates/typst-cli/src/init.rs @@ -15,7 +15,7 @@ use crate::package; /// Execute an initialization command. pub fn init(command: &InitCommand) -> StrResult<()> { - let package_storage = package::storage(&command.package_storage_args); + let package_storage = package::storage(&command.package); // Parse the package specification. If the user didn't specify the version, // we try to figure it out automatically by downloading the package index diff --git a/crates/typst-cli/src/main.rs b/crates/typst-cli/src/main.rs index 631befe56..14f8a665d 100644 --- a/crates/typst-cli/src/main.rs +++ b/crates/typst-cli/src/main.rs @@ -6,6 +6,8 @@ mod greet; mod init; mod package; mod query; +#[cfg(feature = "http-server")] +mod server; mod terminal; mod timings; #[cfg(feature = "self-update")] @@ -44,6 +46,10 @@ static ARGS: LazyLock = LazyLock::new(|| { /// Entry point. fn main() -> ExitCode { + // Handle SIGPIPE + // https://stackoverflow.com/questions/65755853/simple-word-count-rust-program-outputs-valid-stdout-but-panicks-when-piped-to-he/65760807 + sigpipe::reset(); + let res = dispatch(); if let Err(msg) = res { @@ -56,11 +62,11 @@ fn main() -> ExitCode { /// Execute the requested command. fn dispatch() -> HintedStrResult<()> { - let timer = Timer::new(&ARGS); + let mut timer = Timer::new(&ARGS); match &ARGS.command { - Command::Compile(command) => crate::compile::compile(timer, command.clone())?, - Command::Watch(command) => crate::watch::watch(timer, command.clone())?, + Command::Compile(command) => crate::compile::compile(&mut timer, command)?, + Command::Watch(command) => crate::watch::watch(&mut timer, command)?, Command::Init(command) => crate::init::init(command)?, Command::Query(command) => crate::query::query(command)?, Command::Fonts(command) => crate::fonts::fonts(command), diff --git a/crates/typst-cli/src/package.rs b/crates/typst-cli/src/package.rs index b4965f89d..6099ecaa9 100644 --- a/crates/typst-cli/src/package.rs +++ b/crates/typst-cli/src/package.rs @@ -1,10 +1,10 @@ use typst_kit::package::PackageStorage; -use crate::args::PackageStorageArgs; +use crate::args::PackageArgs; use crate::download; /// Returns a new package storage for the given args. -pub fn storage(args: &PackageStorageArgs) -> PackageStorage { +pub fn storage(args: &PackageArgs) -> PackageStorage { PackageStorage::new( args.package_cache_path.clone(), args.package_path.clone(), diff --git a/crates/typst-cli/src/query.rs b/crates/typst-cli/src/query.rs index 90d99a5a2..610f23cd4 100644 --- a/crates/typst-cli/src/query.rs +++ b/crates/typst-cli/src/query.rs @@ -3,7 +3,7 @@ use ecow::{eco_format, EcoString}; use serde::Serialize; use typst::diag::{bail, HintedStrResult, StrResult, Warned}; use typst::foundations::{Content, IntoValue, LocatableSelector, Scope}; -use typst::model::Document; +use typst::layout::PagedDocument; use typst::syntax::Span; use typst::World; use typst_eval::{eval_string, EvalMode}; @@ -15,7 +15,7 @@ use crate::world::SystemWorld; /// Execute a query command. pub fn query(command: &QueryCommand) -> HintedStrResult<()> { - let mut world = SystemWorld::new(&command.common)?; + let mut world = SystemWorld::new(&command.input, &command.world, &command.process)?; // Reset everything and ensure that the main file is present. world.reset(); @@ -29,7 +29,7 @@ pub fn query(command: &QueryCommand) -> HintedStrResult<()> { let data = retrieve(&world, command, &document)?; let serialized = format(data, command)?; println!("{serialized}"); - print_diagnostics(&world, &[], &warnings, command.common.diagnostic_format) + print_diagnostics(&world, &[], &warnings, command.process.diagnostic_format) .map_err(|err| eco_format!("failed to print diagnostics ({err})"))?; } @@ -40,7 +40,7 @@ pub fn query(command: &QueryCommand) -> HintedStrResult<()> { &world, &errors, &warnings, - command.common.diagnostic_format, + command.process.diagnostic_format, ) .map_err(|err| eco_format!("failed to print diagnostics ({err})"))?; } @@ -53,7 +53,7 @@ pub fn query(command: &QueryCommand) -> HintedStrResult<()> { fn retrieve( world: &dyn World, command: &QueryCommand, - document: &Document, + document: &PagedDocument, ) -> HintedStrResult> { let selector = eval_string( &typst::ROUTINES, diff --git a/crates/typst-cli/src/server.rs b/crates/typst-cli/src/server.rs new file mode 100644 index 000000000..8910e0323 --- /dev/null +++ b/crates/typst-cli/src/server.rs @@ -0,0 +1,218 @@ +use std::io::{self, Write}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}; +use std::sync::Arc; + +use ecow::eco_format; +use parking_lot::{Condvar, Mutex, MutexGuard}; +use tiny_http::{Header, Request, Response, StatusCode}; +use typst::diag::{bail, StrResult}; + +use crate::args::{Input, ServerArgs}; + +/// Serves HTML with live reload. +pub struct HtmlServer { + addr: SocketAddr, + bucket: Arc>, +} + +impl HtmlServer { + /// Create a new HTTP server that serves live HTML. + pub fn new(input: &Input, args: &ServerArgs) -> StrResult { + let reload = !args.no_reload; + let (addr, server) = start_server(args.port)?; + + let placeholder = PLACEHOLDER_HTML.replace("{INPUT}", &input.to_string()); + let bucket = Arc::new(Bucket::new(placeholder)); + let bucket2 = bucket.clone(); + + std::thread::spawn(move || { + for req in server.incoming_requests() { + let _ = handle(req, reload, &bucket2); + } + }); + + Ok(Self { addr, bucket }) + } + + /// The address that we serve the HTML on. + pub fn addr(&self) -> SocketAddr { + self.addr + } + + /// Updates the HTML, triggering a reload all connected browsers. + pub fn update(&self, html: String) { + self.bucket.put(html); + } +} + +/// Starts a local HTTP server. +/// +/// Uses the specified port or tries to find a free port in the range +/// `3000..=3005`. +fn start_server(port: Option) -> StrResult<(SocketAddr, tiny_http::Server)> { + const BASE_PORT: u16 = 3000; + + let mut addr; + let mut retries = 0; + + let listener = loop { + addr = SocketAddr::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + port.unwrap_or(BASE_PORT + retries), + ); + + match TcpListener::bind(addr) { + Ok(listener) => break listener, + Err(err) if err.kind() == io::ErrorKind::AddrInUse => { + if let Some(port) = port { + bail!("port {port} is already in use") + } else if retries < 5 { + // If the port is in use, try the next one. + retries += 1; + } else { + bail!("could not find free port for HTTP server"); + } + } + Err(err) => bail!("failed to start TCP server: {err}"), + } + }; + + let server = tiny_http::Server::from_listener(listener, None) + .map_err(|err| eco_format!("failed to start HTTP server: {err}"))?; + + Ok((addr, server)) +} + +/// Handles a request. +fn handle(req: Request, reload: bool, bucket: &Arc>) -> io::Result<()> { + let path = req.url(); + match path { + "/" => handle_root(req, reload, bucket), + "/events" => handle_events(req, bucket.clone()), + _ => req.respond(Response::new_empty(StatusCode(404))), + } +} + +/// Handles for the `/` route. Serves the compiled HTML. +fn handle_root(req: Request, reload: bool, bucket: &Bucket) -> io::Result<()> { + let mut html = bucket.get().clone(); + if reload { + inject_live_reload_script(&mut html); + } + req.respond(Response::new( + StatusCode(200), + vec![Header::from_bytes("Content-Type", "text/html").unwrap()], + html.as_bytes(), + Some(html.len()), + None, + )) +} + +/// Handler for the `/events` route. +fn handle_events(req: Request, bucket: Arc>) -> io::Result<()> { + std::thread::spawn(move || { + // When this returns an error, the client is disconnected and we can + // terminate the thread. + let _ = handle_events_blocking(req, &bucket); + }); + Ok(()) +} + +/// Event stream for the `/events` route. +fn handle_events_blocking(req: Request, bucket: &Bucket) -> io::Result<()> { + let mut writer = req.into_writer(); + let writer: &mut dyn Write = &mut *writer; + + // We need to write the header manually because `tiny-http` defaults to + // `Transfer-Encoding: chunked` when no `Content-Length` is provided, which + // Chrome & Safari dislike for `Content-Type: text/event-stream`. + write!(writer, "HTTP/1.1 200 OK\r\n")?; + write!(writer, "Content-Type: text/event-stream\r\n")?; + write!(writer, "Cache-Control: no-cache\r\n")?; + write!(writer, "\r\n")?; + writer.flush()?; + + // If the user closes the browser tab, this loop will terminate once it + // tries to write to the dead socket for the first time. + loop { + bucket.wait(); + // Trigger a server-sent event. The browser is listening to it via + // an `EventSource` listener` (see `inject_script`). + write!(writer, "event: reload\ndata:\n\n")?; + writer.flush()?; + } +} + +/// Injects the live reload script into a string of HTML. +fn inject_live_reload_script(html: &mut String) { + let pos = html.rfind("").unwrap_or(html.len()); + html.insert_str(pos, LIVE_RELOAD_SCRIPT); +} + +/// Holds data and notifies consumers when it's updated. +struct Bucket { + mutex: Mutex, + condvar: Condvar, +} + +impl Bucket { + /// Creates a new bucket with initial data. + fn new(init: T) -> Self { + Self { mutex: Mutex::new(init), condvar: Condvar::new() } + } + + /// Retrieves the current data in the bucket. + fn get(&self) -> MutexGuard { + self.mutex.lock() + } + + /// Puts new data into the bucket and notifies everyone who's currently + /// [waiting](Self::wait). + fn put(&self, data: T) { + *self.mutex.lock() = data; + self.condvar.notify_all(); + } + + /// Waits for new data in the bucket. + fn wait(&self) { + self.condvar.wait(&mut self.mutex.lock()); + } +} + +/// The initial HTML before compilation is finished. +const PLACEHOLDER_HTML: &str = "\ + + + Waiting for {INPUT} + + + +
+
Waiting for output ...
+
typst watch {INPUT}
+
+ + +"; + +/// Reloads the page whenever it receives a "reload" server-sent event +/// on the `/events` route. +const LIVE_RELOAD_SCRIPT: &str = "\ +\ +"; diff --git a/crates/typst-cli/src/timings.rs b/crates/typst-cli/src/timings.rs index 4446bbf98..9f017dc12 100644 --- a/crates/typst-cli/src/timings.rs +++ b/crates/typst-cli/src/timings.rs @@ -22,8 +22,8 @@ impl Timer { /// record timings for a specific function invocation. pub fn new(args: &CliArguments) -> Timer { let record = match &args.command { - Command::Compile(command) => command.timings.clone(), - Command::Watch(command) => command.timings.clone(), + Command::Compile(command) => command.args.timings.clone(), + Command::Watch(command) => command.args.timings.clone(), _ => None, }; diff --git a/crates/typst-cli/src/watch.rs b/crates/typst-cli/src/watch.rs index f3ca329b8..91132fc30 100644 --- a/crates/typst-cli/src/watch.rs +++ b/crates/typst-cli/src/watch.rs @@ -13,32 +13,38 @@ use same_file::is_same_file; use typst::diag::{bail, StrResult}; use typst::utils::format_duration; -use crate::args::{CompileCommand, Input, Output}; -use crate::compile::compile_once; +use crate::args::{Input, Output, WatchCommand}; +use crate::compile::{compile_once, CompileConfig}; use crate::timings::Timer; use crate::world::{SystemWorld, WorldCreationError}; use crate::{print_error, terminal}; /// Execute a watching compilation command. -pub fn watch(mut timer: Timer, mut command: CompileCommand) -> StrResult<()> { - let Output::Path(output) = command.output() else { +pub fn watch(timer: &mut Timer, command: &WatchCommand) -> StrResult<()> { + let mut config = CompileConfig::watching(command)?; + + let Output::Path(output) = &config.output else { bail!("cannot write document to stdout in watch mode"); }; // Create a file system watcher. - let mut watcher = Watcher::new(output)?; + let mut watcher = Watcher::new(output.clone())?; // Create the world that serves sources, files, and fonts. // Additionally, if any files do not exist, wait until they do. let mut world = loop { - match SystemWorld::new(&command.common) { + match SystemWorld::new( + &command.args.input, + &command.args.world, + &command.args.process, + ) { Ok(world) => break world, Err( ref err @ (WorldCreationError::InputNotFound(ref path) | WorldCreationError::RootNotFound(ref path)), ) => { watcher.update([path.clone()])?; - Status::Error.print(&command).unwrap(); + Status::Error.print(&config).unwrap(); print_error(&err.to_string()).unwrap(); watcher.wait()?; } @@ -47,7 +53,7 @@ pub fn watch(mut timer: Timer, mut command: CompileCommand) -> StrResult<()> { }; // Perform initial compilation. - timer.record(&mut world, |world| compile_once(world, &mut command, true))??; + timer.record(&mut world, |world| compile_once(world, &mut config))??; // Watch all dependencies of the initial compilation. watcher.update(world.dependencies())?; @@ -61,7 +67,7 @@ pub fn watch(mut timer: Timer, mut command: CompileCommand) -> StrResult<()> { world.reset(); // Recompile. - timer.record(&mut world, |world| compile_once(world, &mut command, true))??; + timer.record(&mut world, |world| compile_once(world, &mut config))??; // Evict the cache. comemo::evict(10); @@ -267,8 +273,7 @@ pub enum Status { impl Status { /// Clear the terminal and render the status message. - pub fn print(&self, command: &CompileCommand) -> io::Result<()> { - let output = command.output(); + pub fn print(&self, config: &CompileConfig) -> io::Result<()> { let timestamp = chrono::offset::Local::now().format("%H:%M:%S"); let color = self.color(); @@ -278,7 +283,7 @@ impl Status { out.set_color(&color)?; write!(out, "watching")?; out.reset()?; - match &command.common.input { + match &config.input { Input::Stdin => writeln!(out, " "), Input::Path(path) => writeln!(out, " {}", path.display()), }?; @@ -286,7 +291,15 @@ impl Status { out.set_color(&color)?; write!(out, "writing to")?; out.reset()?; - writeln!(out, " {output}")?; + writeln!(out, " {}", config.output)?; + + #[cfg(feature = "http-server")] + if let Some(server) = &config.server { + out.set_color(&color)?; + write!(out, "serving at")?; + out.reset()?; + writeln!(out, " http://{}", server.addr())?; + } writeln!(out)?; writeln!(out, "[{timestamp}] {}", self.message())?; diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs index a04de2c05..af6cf228f 100644 --- a/crates/typst-cli/src/world.rs +++ b/crates/typst-cli/src/world.rs @@ -17,8 +17,7 @@ use typst_kit::fonts::{FontSlot, Fonts}; use typst_kit::package::PackageStorage; use typst_timing::timed; -use crate::args::{Input, SharedArgs}; -use crate::compile::ExportCache; +use crate::args::{Feature, Input, ProcessArgs, WorldArgs}; use crate::download::PrintDownload; use crate::package; @@ -49,16 +48,17 @@ pub struct SystemWorld { /// always the same within one compilation. /// Reset between compilations if not [`Now::Fixed`]. now: Now, - /// The export cache, used for caching output files in `typst watch` - /// sessions. - export_cache: ExportCache, } impl SystemWorld { /// Create a new system world. - pub fn new(command: &SharedArgs) -> Result { + pub fn new( + input: &Input, + world_args: &WorldArgs, + process_args: &ProcessArgs, + ) -> Result { // Set up the thread pool. - if let Some(jobs) = command.jobs { + if let Some(jobs) = process_args.jobs { rayon::ThreadPoolBuilder::new() .num_threads(jobs) .use_current_thread() @@ -67,7 +67,7 @@ impl SystemWorld { } // Resolve the system-global input path. - let input = match &command.input { + let input = match input { Input::Stdin => None, Input::Path(path) => { Some(path.canonicalize().map_err(|err| match err.kind() { @@ -81,7 +81,7 @@ impl SystemWorld { // Resolve the system-global root directory. let root = { - let path = command + let path = world_args .root .as_deref() .or_else(|| input.as_deref().and_then(|i| i.parent())) @@ -106,23 +106,28 @@ impl SystemWorld { let library = { // Convert the input pairs to a dictionary. - let inputs: Dict = command + let inputs: Dict = world_args .inputs .iter() .map(|(k, v)| (k.as_str().into(), v.as_str().into_value())) .collect(); - let features = - command.feature.iter().map(|&feature| match feature {}).collect(); + let features = process_args + .features + .iter() + .map(|&feature| match feature { + Feature::Html => typst::Feature::Html, + }) + .collect(); Library::builder().with_inputs(inputs).with_features(features).build() }; let fonts = Fonts::searcher() - .include_system_fonts(!command.font_args.ignore_system_fonts) - .search_with(&command.font_args.font_paths); + .include_system_fonts(!world_args.font.ignore_system_fonts) + .search_with(&world_args.font.font_paths); - let now = match command.creation_timestamp { + let now = match world_args.creation_timestamp { Some(time) => Now::Fixed(time), None => Now::System(OnceLock::new()), }; @@ -135,9 +140,8 @@ impl SystemWorld { book: LazyHash::new(fonts.book), fonts: fonts.fonts, slots: Mutex::new(HashMap::new()), - package_storage: package::storage(&command.package_storage_args), + package_storage: package::storage(&world_args.package), now, - export_cache: ExportCache::new(), }) } @@ -182,11 +186,6 @@ impl SystemWorld { pub fn lookup(&self, id: FileId) -> Source { self.source(id).expect("file id does not point to any source file") } - - /// Gets access to the export cache. - pub fn export_cache(&self) -> &ExportCache { - &self.export_cache - } } impl World for SystemWorld { diff --git a/crates/typst-eval/src/call.rs b/crates/typst-eval/src/call.rs index 9dfb7693c..fc934cef5 100644 --- a/crates/typst-eval/src/call.rs +++ b/crates/typst-eval/src/call.rs @@ -253,8 +253,8 @@ pub fn eval_closure( // Handle control flow. let output = body.eval(&mut vm)?; match vm.flow { - Some(FlowEvent::Return(_, Some(explicit))) => return Ok(explicit), - Some(FlowEvent::Return(_, None)) => {} + Some(FlowEvent::Return(_, Some(explicit), _)) => return Ok(explicit), + Some(FlowEvent::Return(_, None, _)) => {} Some(flow) => bail!(flow.forbidden()), None => {} } @@ -391,7 +391,9 @@ fn wrap_args_in_math( } Ok(Value::Content( callee.display().spanned(callee_span) - + LrElem::new(TextElem::packed('(') + body + TextElem::packed(')')).pack(), + + LrElem::new(TextElem::packed('(') + body + TextElem::packed(')')) + .pack() + .spanned(args.span), )) } @@ -591,14 +593,8 @@ mod tests { use super::*; #[track_caller] - fn test(text: &str, result: &[&str]) { - let mut scopes = Scopes::new(None); - scopes.top.define("f", 0); - scopes.top.define("x", 0); - scopes.top.define("y", 0); - scopes.top.define("z", 0); - - let mut visitor = CapturesVisitor::new(Some(&scopes), Capturer::Function); + fn test(scopes: &Scopes, text: &str, result: &[&str]) { + let mut visitor = CapturesVisitor::new(Some(scopes), Capturer::Function); let root = parse(text); visitor.visit(&root); @@ -611,44 +607,95 @@ mod tests { #[test] fn test_captures() { + let mut scopes = Scopes::new(None); + scopes.top.define("f", 0); + scopes.top.define("x", 0); + scopes.top.define("y", 0); + scopes.top.define("z", 0); + let s = &scopes; + // Let binding and function definition. - test("#let x = x", &["x"]); - test("#let x; #(x + y)", &["y"]); - test("#let f(x, y) = x + y", &[]); - test("#let f(x, y) = f", &[]); - test("#let f = (x, y) => f", &["f"]); + test(s, "#let x = x", &["x"]); + test(s, "#let x; #(x + y)", &["y"]); + test(s, "#let f(x, y) = x + y", &[]); + test(s, "#let f(x, y) = f", &[]); + test(s, "#let f = (x, y) => f", &["f"]); // Closure with different kinds of params. - test("#((x, y) => x + z)", &["z"]); - test("#((x: y, z) => x + z)", &["y"]); - test("#((..x) => x + y)", &["y"]); - test("#((x, y: x + z) => x + y)", &["x", "z"]); - test("#{x => x; x}", &["x"]); + test(s, "#((x, y) => x + z)", &["z"]); + test(s, "#((x: y, z) => x + z)", &["y"]); + test(s, "#((..x) => x + y)", &["y"]); + test(s, "#((x, y: x + z) => x + y)", &["x", "z"]); + test(s, "#{x => x; x}", &["x"]); // Show rule. - test("#show y: x => x", &["y"]); - test("#show y: x => x + z", &["y", "z"]); - test("#show x: x => x", &["x"]); + test(s, "#show y: x => x", &["y"]); + test(s, "#show y: x => x + z", &["y", "z"]); + test(s, "#show x: x => x", &["x"]); // For loop. - test("#for x in y { x + z }", &["y", "z"]); - test("#for (x, y) in y { x + y }", &["y"]); - test("#for x in y {} #x", &["x", "y"]); + test(s, "#for x in y { x + z }", &["y", "z"]); + test(s, "#for (x, y) in y { x + y }", &["y"]); + test(s, "#for x in y {} #x", &["x", "y"]); // Import. - test("#import z: x, y", &["z"]); - test("#import x + y: x, y, z", &["x", "y"]); + test(s, "#import z: x, y", &["z"]); + test(s, "#import x + y: x, y, z", &["x", "y"]); // Blocks. - test("#{ let x = 1; { let y = 2; y }; x + y }", &["y"]); - test("#[#let x = 1]#x", &["x"]); + test(s, "#{ let x = 1; { let y = 2; y }; x + y }", &["y"]); + test(s, "#[#let x = 1]#x", &["x"]); // Field access. - test("#foo(body: 1)", &[]); - test("#(body: 1)", &[]); - test("#(body = 1)", &[]); - test("#(body += y)", &["y"]); - test("#{ (body, a) = (y, 1) }", &["y"]); - test("#(x.at(y) = 5)", &["x", "y"]) + test(s, "#x.y.f(z)", &["x", "z"]); + + // Parenthesized expressions. + test(s, "#f(x: 1)", &["f"]); + test(s, "#(x: 1)", &[]); + test(s, "#(x = 1)", &["x"]); + test(s, "#(x += y)", &["x", "y"]); + test(s, "#{ (x, z) = (y, 1) }", &["x", "y", "z"]); + test(s, "#(x.at(y) = 5)", &["x", "y"]); + } + + #[test] + fn test_captures_in_math() { + let mut scopes = Scopes::new(None); + scopes.top.define("f", 0); + scopes.top.define("x", 0); + scopes.top.define("y", 0); + scopes.top.define("z", 0); + // Multi-letter variables are required for math. + scopes.top.define("foo", 0); + scopes.top.define("bar", 0); + scopes.top.define("x-bar", 0); + scopes.top.define("x_bar", 0); + let s = &scopes; + + // Basic math identifier differences. + test(s, "$ x f(z) $", &[]); // single letters not captured. + test(s, "$ #x #f(z) $", &["f", "x", "z"]); + test(s, "$ foo f(bar) $", &["bar", "foo"]); + test(s, "$ #foo[#$bar$] $", &["bar", "foo"]); + test(s, "$ #let foo = x; foo $", &["x"]); + + // Math idents don't have dashes/underscores + test(s, "$ x-y x_y foo-x x_bar $", &["bar", "foo"]); + test(s, "$ #x-bar #x_bar $", &["x-bar", "x_bar"]); + + // Named-params. + test(s, "$ foo(bar: y) $", &["foo"]); + // This should be updated when we improve named-param parsing: + test(s, "$ foo(x-y: 1, bar-z: 2) $", &["bar", "foo"]); + + // Field access in math. + test(s, "$ foo.bar $", &["foo"]); + test(s, "$ foo.x $", &["foo"]); + test(s, "$ x.foo $", &["foo"]); + test(s, "$ foo . bar $", &["bar", "foo"]); + test(s, "$ foo.x.y.bar(z) $", &["foo"]); + test(s, "$ foo.x-bar $", &["bar", "foo"]); + test(s, "$ foo.x_bar $", &["bar", "foo"]); + test(s, "$ #x_bar.x-bar $", &["x_bar"]); } } diff --git a/crates/typst-eval/src/code.rs b/crates/typst-eval/src/code.rs index 918d9d2a4..34373fd4a 100644 --- a/crates/typst-eval/src/code.rs +++ b/crates/typst-eval/src/code.rs @@ -1,12 +1,15 @@ use ecow::{eco_vec, EcoVec}; -use typst_library::diag::{bail, error, At, SourceResult}; +use typst_library::diag::{bail, error, warning, At, SourceResult}; +use typst_library::engine::Engine; use typst_library::foundations::{ - ops, Array, Capturer, Closure, Content, ContextElem, Dict, Func, NativeElement, Str, - Value, + ops, Array, Capturer, Closure, Content, ContextElem, Dict, Func, NativeElement, + Selector, Str, Value, }; +use typst_library::introspection::{Counter, State}; use typst_syntax::ast::{self, AstNode}; +use typst_utils::singleton; -use crate::{CapturesVisitor, Eval, Vm}; +use crate::{CapturesVisitor, Eval, FlowEvent, Vm}; impl Eval for ast::Code<'_> { type Output = Value; @@ -54,7 +57,8 @@ fn eval_code<'a>( output = ops::join(output, value).at(span)?; - if vm.flow.is_some() { + if let Some(event) = &vm.flow { + warn_for_discarded_content(&mut vm.engine, event, &output); break; } } @@ -355,6 +359,29 @@ impl Eval for ast::Contextual<'_> { }; let func = Func::from(closure).spanned(body.span()); - Ok(ContextElem::new(func).pack()) + Ok(ContextElem::new(func).pack().spanned(body.span())) } } + +/// Emits a warning when we discard content while returning unconditionally. +fn warn_for_discarded_content(engine: &mut Engine, event: &FlowEvent, joined: &Value) { + let FlowEvent::Return(span, Some(_), false) = event else { return }; + let Value::Content(tree) = &joined else { return }; + + let selector = singleton!( + Selector, + Selector::Or(eco_vec![State::select_any(), Counter::select_any()]) + ); + + let mut warning = warning!( + *span, + "this return unconditionally discards the content before it"; + hint: "try omitting the `return` to automatically join all values" + ); + + if tree.query_first(selector).is_some() { + warning.hint("state/counter updates are content that must end up in the document to have an effect"); + } + + engine.sink.warn(warning); +} diff --git a/crates/typst-eval/src/flow.rs b/crates/typst-eval/src/flow.rs index 231e68998..b5ba487f5 100644 --- a/crates/typst-eval/src/flow.rs +++ b/crates/typst-eval/src/flow.rs @@ -17,8 +17,8 @@ pub enum FlowEvent { /// Skip the remainder of the current iteration in a loop. Continue(Span), /// Stop execution of a function early, optionally returning an explicit - /// value. - Return(Span, Option), + /// value. The final boolean indicates whether the return was conditional. + Return(Span, Option, bool), } impl FlowEvent { @@ -31,7 +31,7 @@ impl FlowEvent { Self::Continue(span) => { error!(span, "cannot continue outside of loop") } - Self::Return(span, _) => { + Self::Return(span, _, _) => { error!(span, "cannot return outside of function") } } @@ -43,13 +43,20 @@ impl Eval for ast::Conditional<'_> { fn eval(self, vm: &mut Vm) -> SourceResult { let condition = self.condition(); - if condition.eval(vm)?.cast::().at(condition.span())? { - self.if_body().eval(vm) + let output = if condition.eval(vm)?.cast::().at(condition.span())? { + self.if_body().eval(vm)? } else if let Some(else_body) = self.else_body() { - else_body.eval(vm) + else_body.eval(vm)? } else { - Ok(Value::None) + Value::None + }; + + // Mark the return as conditional. + if let Some(FlowEvent::Return(_, _, conditional)) = &mut vm.flow { + *conditional = true; } + + Ok(output) } } @@ -95,6 +102,11 @@ impl Eval for ast::WhileLoop<'_> { vm.flow = flow; } + // Mark the return as conditional. + if let Some(FlowEvent::Return(_, _, conditional)) = &mut vm.flow { + *conditional = true; + } + Ok(output) } } @@ -168,6 +180,11 @@ impl Eval for ast::ForLoop<'_> { vm.flow = flow; } + // Mark the return as conditional. + if let Some(FlowEvent::Return(_, _, conditional)) = &mut vm.flow { + *conditional = true; + } + Ok(output) } } @@ -200,7 +217,7 @@ impl Eval for ast::FuncReturn<'_> { fn eval(self, vm: &mut Vm) -> SourceResult { let value = self.body().map(|body| body.eval(vm)).transpose()?; if vm.flow.is_none() { - vm.flow = Some(FlowEvent::Return(self.span(), value)); + vm.flow = Some(FlowEvent::Return(self.span(), value, false)); } Ok(Value::None) } diff --git a/crates/typst-eval/src/lib.rs b/crates/typst-eval/src/lib.rs index 69c20e8cc..5eae7c1df 100644 --- a/crates/typst-eval/src/lib.rs +++ b/crates/typst-eval/src/lib.rs @@ -148,7 +148,8 @@ pub fn eval_string( EvalMode::Math => Value::Content( EquationElem::new(root.cast::().unwrap().eval(&mut vm)?) .with_block(false) - .pack(), + .pack() + .spanned(span), ), }; diff --git a/crates/typst-eval/src/markup.rs b/crates/typst-eval/src/markup.rs index e28eb9ddb..3a5ebe1fc 100644 --- a/crates/typst-eval/src/markup.rs +++ b/crates/typst-eval/src/markup.rs @@ -11,6 +11,7 @@ use typst_library::text::{ LinebreakElem, RawContent, RawElem, SmartQuoteElem, SpaceElem, TextElem, }; use typst_syntax::ast::{self, AstNode}; +use typst_utils::PicoStr; use crate::{Eval, Vm}; @@ -122,7 +123,7 @@ impl Eval for ast::Escape<'_> { type Output = Value; fn eval(self, _: &mut Vm) -> SourceResult { - Ok(Value::Symbol(Symbol::single(self.get().into()))) + Ok(Value::Symbol(Symbol::single(self.get()))) } } @@ -130,7 +131,7 @@ impl Eval for ast::Shorthand<'_> { type Output = Value; fn eval(self, _: &mut Vm) -> SourceResult { - Ok(Value::Symbol(Symbol::single(self.get().into()))) + Ok(Value::Symbol(Symbol::single(self.get()))) } } @@ -204,7 +205,7 @@ impl Eval for ast::Label<'_> { type Output = Value; fn eval(self, _: &mut Vm) -> SourceResult { - Ok(Value::Label(Label::new(self.get()))) + Ok(Value::Label(Label::new(PicoStr::intern(self.get())))) } } @@ -212,7 +213,7 @@ impl Eval for ast::Ref<'_> { type Output = Content; fn eval(self, vm: &mut Vm) -> SourceResult { - let target = Label::new(self.target()); + let target = Label::new(PicoStr::intern(self.target())); let mut elem = RefElem::new(target); if let Some(supplement) = self.supplement() { elem.push_supplement(Smart::Custom(Some(Supplement::Content( diff --git a/crates/typst-eval/src/math.rs b/crates/typst-eval/src/math.rs index c61a32514..51dc0a3d5 100644 --- a/crates/typst-eval/src/math.rs +++ b/crates/typst-eval/src/math.rs @@ -32,7 +32,7 @@ impl Eval for ast::MathShorthand<'_> { type Output = Value; fn eval(self, _: &mut Vm) -> SourceResult { - Ok(Value::Symbol(Symbol::single(self.get().into()))) + Ok(Value::Symbol(Symbol::single(self.get()))) } } diff --git a/crates/typst-html/Cargo.toml b/crates/typst-html/Cargo.toml new file mode 100644 index 000000000..534848f96 --- /dev/null +++ b/crates/typst-html/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "typst-html" +description = "Typst's HTML exporter." +version = { workspace = true } +rust-version = { workspace = true } +authors = { workspace = true } +edition = { workspace = true } +homepage = { workspace = true } +repository = { workspace = true } +license = { workspace = true } +categories = { workspace = true } +keywords = { workspace = true } +readme = { workspace = true } + +[dependencies] +typst-library = { workspace = true } +typst-macros = { workspace = true } +typst-syntax = { workspace = true } +typst-timing = { workspace = true } +typst-utils = { workspace = true } +typst-svg = { workspace = true } +comemo = { workspace = true } +ecow = { workspace = true } + +[lints] +workspace = true diff --git a/crates/typst-html/src/encode.rs b/crates/typst-html/src/encode.rs new file mode 100644 index 000000000..b87b0e1d6 --- /dev/null +++ b/crates/typst-html/src/encode.rs @@ -0,0 +1,146 @@ +use std::fmt::Write; + +use typst_library::diag::{bail, At, SourceResult, StrResult}; +use typst_library::foundations::Repr; +use typst_library::html::{charsets, tag, HtmlDocument, HtmlElement, HtmlNode}; +use typst_library::layout::Frame; +use typst_syntax::Span; + +/// Encodes an HTML document into a string. +pub fn html(document: &HtmlDocument) -> SourceResult { + let mut w = Writer { pretty: true, ..Writer::default() }; + w.buf.push_str(""); + write_indent(&mut w); + write_element(&mut w, &document.root)?; + Ok(w.buf) +} + +#[derive(Default)] +struct Writer { + buf: String, + /// current indentation level + level: usize, + /// pretty printing enabled? + pretty: bool, +} + +/// Write a newline and indent, if pretty printing is enabled. +fn write_indent(w: &mut Writer) { + if w.pretty { + w.buf.push('\n'); + for _ in 0..w.level { + w.buf.push_str(" "); + } + } +} + +/// Encode an HTML node into the writer. +fn write_node(w: &mut Writer, node: &HtmlNode) -> SourceResult<()> { + match node { + HtmlNode::Tag(_) => {} + HtmlNode::Text(text, span) => write_text(w, text, *span)?, + HtmlNode::Element(element) => write_element(w, element)?, + HtmlNode::Frame(frame) => write_frame(w, frame), + } + Ok(()) +} + +/// Encode plain text into the writer. +fn write_text(w: &mut Writer, text: &str, span: Span) -> SourceResult<()> { + for c in text.chars() { + if charsets::is_valid_in_normal_element_text(c) { + w.buf.push(c); + } else { + write_escape(w, c).at(span)?; + } + } + Ok(()) +} + +/// Encode one element into the write. +fn write_element(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> { + w.buf.push('<'); + w.buf.push_str(&element.tag.resolve()); + + for (attr, value) in &element.attrs.0 { + w.buf.push(' '); + w.buf.push_str(&attr.resolve()); + w.buf.push('='); + w.buf.push('"'); + for c in value.chars() { + if charsets::is_valid_in_attribute_value(c) { + w.buf.push(c); + } else { + write_escape(w, c).at(element.span)?; + } + } + w.buf.push('"'); + } + + w.buf.push('>'); + + if tag::is_void(element.tag) { + return Ok(()); + } + + let pretty = w.pretty; + if !element.children.is_empty() { + w.pretty &= is_pretty(element); + let mut indent = w.pretty; + + w.level += 1; + for c in &element.children { + let pretty_child = match c { + HtmlNode::Tag(_) => continue, + HtmlNode::Element(element) => is_pretty(element), + HtmlNode::Text(..) | HtmlNode::Frame(_) => false, + }; + + if core::mem::take(&mut indent) || pretty_child { + write_indent(w); + } + write_node(w, c)?; + indent = pretty_child; + } + w.level -= 1; + + write_indent(w) + } + w.pretty = pretty; + + w.buf.push_str("'); + + Ok(()) +} + +/// Whether the element should be pretty-printed. +fn is_pretty(element: &HtmlElement) -> bool { + tag::is_block_by_default(element.tag) || matches!(element.tag, tag::meta) +} + +/// Escape a character. +fn write_escape(w: &mut Writer, c: char) -> StrResult<()> { + // See + match c { + '&' => w.buf.push_str("&"), + '<' => w.buf.push_str("<"), + '>' => w.buf.push_str(">"), + '"' => w.buf.push_str("""), + '\'' => w.buf.push_str("'"), + c if charsets::is_w3c_text_char(c) && c != '\r' => { + write!(w.buf, "&#x{:x};", c as u32).unwrap() + } + _ => bail!("the character {} cannot be encoded in HTML", c.repr()), + } + Ok(()) +} + +/// Encode a laid out frame into the writer. +fn write_frame(w: &mut Writer, frame: &Frame) { + // FIXME: This string replacement is obviously a hack. + let svg = typst_svg::svg_frame(frame) + .replace(" SourceResult { + html_document_impl( + engine.routines, + engine.world, + engine.introspector, + engine.traced, + TrackedMut::reborrow_mut(&mut engine.sink), + engine.route.track(), + content, + styles, + ) +} + +/// The internal implementation of `html_document`. +#[comemo::memoize] +#[allow(clippy::too_many_arguments)] +fn html_document_impl( + routines: &Routines, + world: Tracked, + introspector: Tracked, + traced: Tracked, + sink: TrackedMut, + route: Tracked, + content: &Content, + styles: StyleChain, +) -> SourceResult { + let mut locator = Locator::root().split(); + let mut engine = Engine { + routines, + world, + introspector, + traced, + sink, + route: Route::extend(route).unnested(), + }; + + // Mark the external styles as "outside" so that they are valid at the page + // level. + let styles = styles.to_map().outside(); + let styles = StyleChain::new(&styles); + + let arenas = Arenas::default(); + let mut info = DocumentInfo::default(); + let children = (engine.routines.realize)( + RealizationKind::HtmlDocument(&mut info), + &mut engine, + &mut locator, + &arenas, + content, + styles, + )?; + + let output = handle_list(&mut engine, &mut locator, children.iter().copied())?; + let root = root_element(output, &info)?; + let introspector = Introspector::html(&root); + + Ok(HtmlDocument { info, root, introspector }) +} + +/// Produce HTML nodes from content. +#[typst_macros::time(name = "html fragment")] +pub fn html_fragment( + engine: &mut Engine, + content: &Content, + locator: Locator, + styles: StyleChain, +) -> SourceResult> { + html_fragment_impl( + engine.routines, + engine.world, + engine.introspector, + engine.traced, + TrackedMut::reborrow_mut(&mut engine.sink), + engine.route.track(), + content, + locator.track(), + styles, + ) +} + +/// The cached, internal implementation of [`html_fragment`]. +#[comemo::memoize] +#[allow(clippy::too_many_arguments)] +fn html_fragment_impl( + routines: &Routines, + world: Tracked, + introspector: Tracked, + traced: Tracked, + sink: TrackedMut, + route: Tracked, + content: &Content, + locator: Tracked, + styles: StyleChain, +) -> SourceResult> { + let link = LocatorLink::new(locator); + let mut locator = Locator::link(&link).split(); + let mut engine = Engine { + routines, + world, + introspector, + traced, + sink, + route: Route::extend(route), + }; + + engine.route.check_html_depth().at(content.span())?; + + let arenas = Arenas::default(); + let children = (engine.routines.realize)( + RealizationKind::HtmlFragment, + &mut engine, + &mut locator, + &arenas, + content, + styles, + )?; + + handle_list(&mut engine, &mut locator, children.iter().copied()) +} + +/// Convert children into HTML nodes. +fn handle_list<'a>( + engine: &mut Engine, + locator: &mut SplitLocator, + children: impl IntoIterator>, +) -> SourceResult> { + let mut output = Vec::new(); + for (child, styles) in children { + handle(engine, child, locator, styles, &mut output)?; + } + Ok(output) +} + +/// Convert a child into HTML node(s). +fn handle( + engine: &mut Engine, + child: &Content, + locator: &mut SplitLocator, + styles: StyleChain, + output: &mut Vec, +) -> SourceResult<()> { + if let Some(elem) = child.to_packed::() { + output.push(HtmlNode::Tag(elem.tag.clone())); + } else if let Some(elem) = child.to_packed::() { + let mut children = vec![]; + if let Some(body) = elem.body(styles) { + children = html_fragment(engine, body, locator.next(&elem.span()), styles)?; + } + if tag::is_void(elem.tag) && !children.is_empty() { + bail!(elem.span(), "HTML void elements may not have children"); + } + let element = HtmlElement { + tag: elem.tag, + attrs: elem.attrs(styles).clone(), + children, + span: elem.span(), + }; + output.push(element.into()); + } else if let Some(elem) = child.to_packed::() { + let children = handle_list(engine, locator, elem.children.iter(&styles))?; + output.push( + HtmlElement::new(tag::p) + .with_children(children) + .spanned(elem.span()) + .into(), + ); + } else if let Some(elem) = child.to_packed::() { + // FIXME: Very incomplete and hacky, but makes boxes kind fulfill their + // purpose for now. + if let Some(body) = elem.body(styles) { + let children = + html_fragment(engine, body, locator.next(&elem.span()), styles)?; + output.extend(children); + } + } else if child.is::() { + output.push(HtmlNode::text(' ', child.span())); + } else if let Some(elem) = child.to_packed::() { + output.push(HtmlNode::text(elem.text.clone(), elem.span())); + } else if let Some(elem) = child.to_packed::() { + output.push(HtmlElement::new(tag::br).spanned(elem.span()).into()); + } else if let Some(elem) = child.to_packed::() { + output.push(HtmlNode::text( + if elem.double(styles) { '"' } else { '\'' }, + child.span(), + )); + } else if let Some(elem) = child.to_packed::() { + let locator = locator.next(&elem.span()); + let style = TargetElem::set_target(Target::Paged).wrap(); + let frame = (engine.routines.layout_frame)( + engine, + &elem.body, + locator, + styles.chain(&style), + Region::new(Size::splat(Abs::inf()), Axes::splat(false)), + )?; + output.push(HtmlNode::Frame(frame)); + } else { + engine.sink.warn(warning!( + child.span(), + "{} was ignored during HTML export", + child.elem().name() + )); + } + Ok(()) +} + +/// Wrap the nodes in `` and `` if they are not yet rooted, +/// supplying a suitable ``. +fn root_element(output: Vec, info: &DocumentInfo) -> SourceResult { + let body = match classify_output(output)? { + OutputKind::Html(element) => return Ok(element), + OutputKind::Body(body) => body, + OutputKind::Leafs(leafs) => HtmlElement::new(tag::body).with_children(leafs), + }; + Ok(HtmlElement::new(tag::html) + .with_children(vec![head_element(info).into(), body.into()])) +} + +/// Generate a `` element. +fn head_element(info: &DocumentInfo) -> HtmlElement { + let mut children = vec![]; + + children.push(HtmlElement::new(tag::meta).with_attr(attr::charset, "utf-8").into()); + + children.push( + HtmlElement::new(tag::meta) + .with_attr(attr::name, "viewport") + .with_attr(attr::content, "width=device-width, initial-scale=1") + .into(), + ); + + if let Some(title) = &info.title { + children.push( + HtmlElement::new(tag::title) + .with_children(vec![HtmlNode::Text(title.clone(), Span::detached())]) + .into(), + ); + } + + if let Some(description) = &info.description { + children.push( + HtmlElement::new(tag::meta) + .with_attr(attr::name, "description") + .with_attr(attr::content, description.clone()) + .into(), + ); + } + + HtmlElement::new(tag::head).with_children(children) +} + +/// Determine which kind of output the user generated. +fn classify_output(mut output: Vec) -> SourceResult { + let len = output.len(); + for node in &mut output { + let HtmlNode::Element(elem) = node else { continue }; + let tag = elem.tag; + let mut take = || std::mem::replace(elem, HtmlElement::new(tag::html)); + match (tag, len) { + (tag::html, 1) => return Ok(OutputKind::Html(take())), + (tag::body, 1) => return Ok(OutputKind::Body(take())), + (tag::html | tag::body, _) => bail!( + elem.span, + "`{}` element must be the only element in the document", + elem.tag + ), + _ => {} + } + } + Ok(OutputKind::Leafs(output)) +} + +/// What kinds of output the user generated. +enum OutputKind { + /// The user generated their own `` element. We do not need to supply + /// one. + Html(HtmlElement), + /// The user generate their own `` element. We do not need to supply + /// one, but need supply the `` element. + Body(HtmlElement), + /// The user generated leafs which we wrap in a `` and ``. + Leafs(Vec), +} diff --git a/crates/typst-ide/src/analyze.rs b/crates/typst-ide/src/analyze.rs index 5e3dfd700..7ee83e709 100644 --- a/crates/typst-ide/src/analyze.rs +++ b/crates/typst-ide/src/analyze.rs @@ -1,7 +1,8 @@ use comemo::Track; use ecow::{eco_vec, EcoString, EcoVec}; use typst::foundations::{Label, Styles, Value}; -use typst::model::{BibliographyElem, Document}; +use typst::layout::PagedDocument; +use typst::model::BibliographyElem; use typst::syntax::{ast, LinkedNode, SyntaxKind}; use crate::IdeWorld; @@ -36,7 +37,7 @@ pub fn analyze_expr( } } - return typst::trace(world.upcast(), node.span()); + return typst::trace::(world.upcast(), node.span()); } }; @@ -65,7 +66,9 @@ pub fn analyze_import(world: &dyn IdeWorld, source: &LinkedNode) -> Option (Vec<(Label, Option)>, usize) { +pub fn analyze_labels( + document: &PagedDocument, +) -> (Vec<(Label, Option)>, usize) { let mut output = vec![]; // Labels in the document. @@ -88,9 +91,7 @@ pub fn analyze_labels(document: &Document) -> (Vec<(Label, Option)>, let split = output.len(); // Bibliography keys. - for (key, detail) in BibliographyElem::keys(document.introspector.track()) { - output.push((Label::new(key.as_str()), detail)); - } + output.extend(BibliographyElem::keys(document.introspector.track())); (output, split) } diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index a2791e071..5c2b500a0 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -9,8 +9,7 @@ use typst::foundations::{ fields_on, repr, AutoValue, CastInfo, Func, Label, NoneValue, ParamInfo, Repr, StyleChain, Styles, Type, Value, }; -use typst::layout::{Alignment, Dir}; -use typst::model::Document; +use typst::layout::{Alignment, Dir, PagedDocument}; use typst::syntax::ast::AstNode; use typst::syntax::{ ast, is_id_continue, is_id_start, is_ident, FileId, LinkedNode, Side, Source, @@ -38,7 +37,7 @@ use crate::{analyze_expr, analyze_import, analyze_labels, named_items, IdeWorld} /// when the document is available. pub fn autocomplete( world: &dyn IdeWorld, - document: Option<&Document>, + document: Option<&PagedDocument>, source: &Source, cursor: usize, explicit: bool, @@ -1063,7 +1062,7 @@ fn code_completions(ctx: &mut CompletionContext, hash: bool) { /// Context for autocompletion. struct CompletionContext<'a> { world: &'a (dyn IdeWorld + 'a), - document: Option<&'a Document>, + document: Option<&'a PagedDocument>, text: &'a str, before: &'a str, after: &'a str, @@ -1079,7 +1078,7 @@ impl<'a> CompletionContext<'a> { /// Create a new autocompletion context. fn new( world: &'a (dyn IdeWorld + 'a), - document: Option<&'a Document>, + document: Option<&'a PagedDocument>, source: &'a Source, leaf: &'a LinkedNode<'a>, cursor: usize, @@ -1254,11 +1253,11 @@ impl<'a> CompletionContext<'a> { eco_format!( "{}{}{}", if open { "<" } else { "" }, - label.as_str(), + label.resolve(), if close { ">" } else { "" } ) }), - label: label.as_str().into(), + label: label.resolve().as_str().into(), detail, }); } @@ -1507,7 +1506,7 @@ impl BracketMode { mod tests { use std::collections::BTreeSet; - use typst::model::Document; + use typst::layout::PagedDocument; use typst::syntax::{FileId, Source, VirtualPath}; use typst::World; @@ -1607,7 +1606,7 @@ mod tests { fn test_full( world: &TestWorld, source: &Source, - doc: Option<&Document>, + doc: Option<&PagedDocument>, cursor: isize, ) -> Response { autocomplete(world, doc, source, source.cursor(cursor), true) diff --git a/crates/typst-ide/src/definition.rs b/crates/typst-ide/src/definition.rs index 94def1c18..c789430a2 100644 --- a/crates/typst-ide/src/definition.rs +++ b/crates/typst-ide/src/definition.rs @@ -1,6 +1,7 @@ use typst::foundations::{Label, Selector, Value}; -use typst::model::Document; +use typst::layout::PagedDocument; use typst::syntax::{ast, LinkedNode, Side, Source, Span}; +use typst::utils::PicoStr; use crate::utils::globals; use crate::{ @@ -24,7 +25,7 @@ pub enum Definition { /// when the document is available. pub fn definition( world: &dyn IdeWorld, - document: Option<&Document>, + document: Option<&PagedDocument>, source: &Source, cursor: usize, side: Side, @@ -71,7 +72,7 @@ pub fn definition( // Try to jump to the referenced content. DerefTarget::Ref(node) => { - let label = Label::new(node.cast::()?.target()); + let label = Label::new(PicoStr::intern(node.cast::()?.target())); let selector = Selector::Label(label); let elem = document?.introspector.query_first(&selector)?; return Some(Definition::Span(elem.span())); diff --git a/crates/typst-ide/src/jump.rs b/crates/typst-ide/src/jump.rs index 2dd5cf610..ba62b0ab9 100644 --- a/crates/typst-ide/src/jump.rs +++ b/crates/typst-ide/src/jump.rs @@ -1,7 +1,7 @@ use std::num::NonZeroUsize; -use typst::layout::{Frame, FrameItem, Point, Position, Size}; -use typst::model::{Destination, Document, Url}; +use typst::layout::{Frame, FrameItem, PagedDocument, Point, Position, Size}; +use typst::model::{Destination, Url}; use typst::syntax::{FileId, LinkedNode, Side, Source, Span, SyntaxKind}; use typst::visualize::Geometry; use typst::WorldExt; @@ -30,7 +30,7 @@ impl Jump { /// Determine where to jump to based on a click in a frame. pub fn jump_from_click( world: &dyn IdeWorld, - document: &Document, + document: &PagedDocument, frame: &Frame, click: Point, ) -> Option { @@ -110,7 +110,7 @@ pub fn jump_from_click( /// Find the output location in the document for a cursor position. pub fn jump_from_cursor( - document: &Document, + document: &PagedDocument, source: &Source, cursor: usize, ) -> Vec { diff --git a/crates/typst-ide/src/tests.rs b/crates/typst-ide/src/tests.rs index 52b189fa0..5a73fa375 100644 --- a/crates/typst-ide/src/tests.rs +++ b/crates/typst-ide/src/tests.rs @@ -119,6 +119,10 @@ impl IdeWorld for TestWorld { fn packages(&self) -> &[(PackageSpec, Option)] { const LIST: &[(PackageSpec, Option)] = &[( PackageSpec { + // NOTE: This literal, `"preview"`, should match the const, `DEFAULT_NAMESPACE`, + // defined in `crates/typst-kit/src/package.rs`. However, we should always use the + // literal here, not `DEFAULT_NAMESPACE`, so that this test fails if its value + // changes in an unexpected way. namespace: EcoString::inline("preview"), name: EcoString::inline("example"), version: PackageVersion { major: 0, minor: 1, patch: 0 }, diff --git a/crates/typst-ide/src/tooltip.rs b/crates/typst-ide/src/tooltip.rs index d62826522..4eaaeda1f 100644 --- a/crates/typst-ide/src/tooltip.rs +++ b/crates/typst-ide/src/tooltip.rs @@ -4,8 +4,7 @@ use ecow::{eco_format, EcoString}; use if_chain::if_chain; use typst::engine::Sink; use typst::foundations::{repr, Capturer, CastInfo, Repr, Value}; -use typst::layout::Length; -use typst::model::Document; +use typst::layout::{Length, PagedDocument}; use typst::syntax::ast::AstNode; use typst::syntax::{ast, LinkedNode, Side, Source, SyntaxKind}; use typst::utils::{round_with_precision, Numeric}; @@ -21,7 +20,7 @@ use crate::{analyze_expr, analyze_import, analyze_labels, IdeWorld}; /// document is available. pub fn tooltip( world: &dyn IdeWorld, - document: Option<&Document>, + document: Option<&PagedDocument>, source: &Source, cursor: usize, side: Side, @@ -173,7 +172,7 @@ fn length_tooltip(length: Length) -> Option { } /// Tooltip for a hovered reference or label. -fn label_tooltip(document: &Document, leaf: &LinkedNode) -> Option { +fn label_tooltip(document: &PagedDocument, leaf: &LinkedNode) -> Option { let target = match leaf.kind() { SyntaxKind::RefMarker => leaf.text().trim_start_matches('@'), SyntaxKind::Label => leaf.text().trim_start_matches('<').trim_end_matches('>'), @@ -181,7 +180,7 @@ fn label_tooltip(document: &Document, leaf: &LinkedNode) -> Option { }; for (label, detail) in analyze_labels(document).0 { - if label.as_str() == target { + if label.resolve().as_str() == target { return Some(Tooltip::Text(detail?)); } } @@ -338,6 +337,21 @@ mod tests { fn test_tooltip_closure() { test("#let f(x) = x + y", 11, Side::Before) .must_be_text("This closure captures `y`"); + // Same tooltip if `y` is defined first. + test("#let y = 10; #let f(x) = x + y", 24, Side::Before) + .must_be_text("This closure captures `y`"); + // Names are sorted. + test("#let f(x) = x + y + z + a", 11, Side::Before) + .must_be_text("This closure captures `a`, `y`, and `z`"); + // Names are de-duplicated. + test("#let f(x) = x + y + z + y", 11, Side::Before) + .must_be_text("This closure captures `y` and `z`"); + // With arrow syntax. + test("#let f = (x) => x + y", 15, Side::Before) + .must_be_text("This closure captures `y`"); + // No recursion with arrow syntax. + test("#let f = (x) => x + y + f", 13, Side::After) + .must_be_text("This closure captures `f` and `y`"); } #[test] diff --git a/crates/typst-kit/src/package.rs b/crates/typst-kit/src/package.rs index 412c7982f..e7eb71ee4 100644 --- a/crates/typst-kit/src/package.rs +++ b/crates/typst-kit/src/package.rs @@ -15,6 +15,9 @@ use crate::download::{Downloader, Progress}; /// The default Typst registry. pub const DEFAULT_REGISTRY: &str = "https://packages.typst.org"; +/// The public namespace in the default Typst registry. +pub const DEFAULT_NAMESPACE: &str = "preview"; + /// The default packages sub directory within the package and package cache paths. pub const DEFAULT_PACKAGES_SUBDIR: &str = "typst/packages"; @@ -28,7 +31,7 @@ pub struct PackageStorage { package_path: Option, /// The downloader used for fetching the index and packages. downloader: Downloader, - /// The cached index of the preview namespace. + /// The cached index of the default namespace. index: OnceCell>, } @@ -85,7 +88,7 @@ impl PackageStorage { } // Download from network if it doesn't exist yet. - if spec.namespace == "preview" { + if spec.namespace == DEFAULT_NAMESPACE { self.download_package(spec, &dir, progress)?; if dir.exists() { return Ok(dir); @@ -101,8 +104,8 @@ impl PackageStorage { &self, spec: &VersionlessPackageSpec, ) -> StrResult { - if spec.namespace == "preview" { - // For `@preview`, download the package index and find the latest + if spec.namespace == DEFAULT_NAMESPACE { + // For `DEFAULT_NAMESPACE`, download the package index and find the latest // version. self.download_index()? .iter() @@ -131,7 +134,7 @@ impl PackageStorage { pub fn download_index(&self) -> StrResult<&[PackageInfo]> { self.index .get_or_try_init(|| { - let url = format!("{DEFAULT_REGISTRY}/preview/index.json"); + let url = format!("{DEFAULT_REGISTRY}/{DEFAULT_NAMESPACE}/index.json"); match self.downloader.download(&url) { Ok(response) => response.into_json().map_err(|err| { eco_format!("failed to parse package index: {err}") @@ -148,17 +151,19 @@ impl PackageStorage { /// Download a package over the network. /// /// # Panics - /// Panics if the package spec namespace isn't `preview`. + /// Panics if the package spec namespace isn't `DEFAULT_NAMESPACE`. pub fn download_package( &self, spec: &PackageSpec, package_dir: &Path, progress: &mut dyn Progress, ) -> PackageResult<()> { - assert_eq!(spec.namespace, "preview"); + assert_eq!(spec.namespace, DEFAULT_NAMESPACE); - let url = - format!("{DEFAULT_REGISTRY}/preview/{}-{}.tar.gz", spec.name, spec.version); + let url = format!( + "{DEFAULT_REGISTRY}/{DEFAULT_NAMESPACE}/{}-{}.tar.gz", + spec.name, spec.version + ); let data = match self.downloader.download_with_progress(&url, progress) { Ok(data) => data, diff --git a/crates/typst-layout/src/flow/collect.rs b/crates/typst-layout/src/flow/collect.rs index 49461e809..12cfa152e 100644 --- a/crates/typst-layout/src/flow/collect.rs +++ b/crates/typst-layout/src/flow/collect.rs @@ -5,7 +5,7 @@ use std::hash::Hash; use bumpalo::boxed::Box as BumpBox; use bumpalo::Bump; use comemo::{Track, Tracked, TrackedMut}; -use typst_library::diag::{bail, SourceResult}; +use typst_library::diag::{bail, warning, SourceResult}; use typst_library::engine::{Engine, Route, Sink, Traced}; use typst_library::foundations::{Packed, Resolve, Smart, StyleChain}; use typst_library::introspection::{ @@ -83,7 +83,11 @@ impl<'a> Collector<'a, '_, '_> { hint: "try using a `#colbreak()` instead", ); } else { - bail!(child.span(), "{} is not allowed here", child.func().name()); + self.engine.sink.warn(warning!( + child.span(), + "{} was ignored during paged export", + child.func().name() + )); } } diff --git a/crates/typst-layout/src/flow/compose.rs b/crates/typst-layout/src/flow/compose.rs index 343b47833..326456752 100644 --- a/crates/typst-layout/src/flow/compose.rs +++ b/crates/typst-layout/src/flow/compose.rs @@ -214,6 +214,13 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { } /// Lay out the inner contents of a column. + /// + /// Pending floats and footnotes are also laid out at this step. For those, + /// however, we forbid footnote migration (moving the frame containing the + /// footnote reference if the corresponding entry doesn't fit), allowing + /// the footnote invariant to be broken, as it would require handling a + /// [`Stop::Finish`] at this point, but that is exclusively handled by the + /// distributor. fn column_contents(&mut self, regions: Regions) -> FlowResult { // Process pending footnotes. for note in std::mem::take(&mut self.work.footnotes) { @@ -222,7 +229,7 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { // Process pending floats. for placed in std::mem::take(&mut self.work.floats) { - self.float(placed, ®ions, false)?; + self.float(placed, ®ions, false, false)?; } distribute(self, regions) @@ -236,13 +243,21 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { /// (depending on `placed.scope`). /// /// When the float does not fit, it is queued into `work.floats`. The - /// value of `clearance` that between the float and flow content is needed - /// --- it is set if there are already distributed items. + /// value of `clearance` indicates that between the float and flow content + /// is needed --- it is set if there are already distributed items. + /// + /// The value of `migratable` determines whether footnotes within the float + /// should be allowed to prompt its migration if they don't fit in order to + /// respect the footnote invariant (entries in the same page as the + /// references), triggering [`Stop::Finish`]. This is usually `true` within + /// the distributor, as it can handle that particular flow event, and + /// `false` elsewhere. pub fn float( &mut self, placed: &'b PlacedChild<'a>, regions: &Regions, clearance: bool, + migratable: bool, ) -> FlowResult<()> { // If the float is already processed, skip it. let loc = placed.location(); @@ -291,7 +306,7 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { } // Handle footnotes in the float. - self.footnotes(regions, &frame, need, false)?; + self.footnotes(regions, &frame, need, false, migratable)?; // Determine the float's vertical alignment. We can unwrap the inner // `Option` because `Custom(None)` is checked for during collection. @@ -326,12 +341,19 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { /// Lays out footnotes in the `frame` if this is the root flow and there are /// any. The value of `breakable` indicates whether the element that /// produced the frame is breakable. If not, the frame is treated as atomic. + /// + /// The value of `migratable` indicates whether footnote migration should be + /// possible (at least for the first footnote found in the frame, as it is + /// forbidden for the second footnote onwards). It is usually `true` within + /// the distributor and `false` elsewhere, as the distributor can handle + /// [`Stop::Finish`] which is returned when migration is requested. pub fn footnotes( &mut self, regions: &Regions, frame: &Frame, flow_need: Abs, breakable: bool, + migratable: bool, ) -> FlowResult<()> { // Footnotes are only supported at the root level. if !self.config.root { @@ -352,7 +374,7 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { let mut relayout = false; let mut regions = *regions; - let mut migratable = !breakable && regions.may_progress(); + let mut migratable = migratable && !breakable && regions.may_progress(); for (y, elem) in notes { // The amount of space used by the in-flow content that contains the @@ -470,7 +492,20 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { // Lay out nested footnotes. for (_, note) in nested { - self.footnote(note, regions, flow_need, migratable)?; + match self.footnote(note, regions, flow_need, migratable) { + // This footnote was already processed or queued. + Ok(_) => {} + // Footnotes always request a relayout when processed for the + // first time, so we ignore a relayout request since we're + // about to do so afterwards. Without this check, the first + // inner footnote interrupts processing of the following ones. + Err(Stop::Relayout(_)) => {} + // Either of + // - A `Stop::Finish` indicating that the frame's origin element + // should migrate to uphold the footnote invariant. + // - A fatal error. + err => return err, + } } // Since we laid out a footnote, we need a relayout. diff --git a/crates/typst-layout/src/flow/distribute.rs b/crates/typst-layout/src/flow/distribute.rs index 5b293d352..7a1cf4264 100644 --- a/crates/typst-layout/src/flow/distribute.rs +++ b/crates/typst-layout/src/flow/distribute.rs @@ -240,7 +240,8 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { // Handle fractionally sized blocks. if let Some(fr) = single.fr { - self.composer.footnotes(&self.regions, &frame, Abs::zero(), false)?; + self.composer + .footnotes(&self.regions, &frame, Abs::zero(), false, true)?; self.flush_tags(); self.items.push(Item::Fr(fr, Some(single))); return Ok(()); @@ -323,8 +324,13 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { } // Handle footnotes. - self.composer - .footnotes(&self.regions, &frame, frame.height(), breakable)?; + self.composer.footnotes( + &self.regions, + &frame, + frame.height(), + breakable, + true, + )?; // Push an item for the frame. self.regions.size.y -= frame.height(); @@ -347,11 +353,13 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { placed, &self.regions, self.items.iter().any(|item| matches!(item, Item::Frame(..))), + true, )?; self.regions.size.y -= weak_spacing; } else { let frame = placed.layout(self.composer.engine, self.regions.base())?; - self.composer.footnotes(&self.regions, &frame, Abs::zero(), true)?; + self.composer + .footnotes(&self.regions, &frame, Abs::zero(), true, true)?; self.flush_tags(); self.items.push(Item::Placed(frame, placed)); } diff --git a/crates/typst-layout/src/flow/mod.rs b/crates/typst-layout/src/flow/mod.rs index 7cbec59af..df716b338 100644 --- a/crates/typst-layout/src/flow/mod.rs +++ b/crates/typst-layout/src/flow/mod.rs @@ -142,7 +142,7 @@ fn layout_fragment_impl( let arenas = Arenas::default(); let children = (engine.routines.realize)( - RealizationKind::Container, + RealizationKind::LayoutFragment, &mut engine, &mut locator, &arenas, diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 972179da8..8d08d56db 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -45,7 +45,7 @@ impl Repeatable { } } -impl<'a> GridLayouter<'a> { +impl GridLayouter<'_> { /// Layouts the header's rows. /// Skips regions as necessary. pub fn layout_header( diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs index 03b4103fd..93d4c960d 100644 --- a/crates/typst-layout/src/grid/rowspans.rs +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -85,7 +85,7 @@ pub struct CellMeasurementData<'layouter> { pub frames_in_previous_regions: usize, } -impl<'a> GridLayouter<'a> { +impl GridLayouter<'_> { /// Layout a rowspan over the already finished regions, plus the current /// region's frame and resolved rows, if it wasn't finished yet (because /// we're being called from `finish_region`, but note that this function is diff --git a/crates/typst-layout/src/image.rs b/crates/typst-layout/src/image.rs index 84a602823..628fe10d6 100644 --- a/crates/typst-layout/src/image.rs +++ b/crates/typst-layout/src/image.rs @@ -55,6 +55,7 @@ pub fn layout_image( elem.alt(styles), engine.world, &families(styles).collect::>(), + elem.flatten_text(styles), ) .at(span)?; diff --git a/crates/typst-layout/src/inline/collect.rs b/crates/typst-layout/src/inline/collect.rs index fbcddee5c..23e82c417 100644 --- a/crates/typst-layout/src/inline/collect.rs +++ b/crates/typst-layout/src/inline/collect.rs @@ -256,8 +256,7 @@ impl<'a> Collector<'a> { } fn push_text(&mut self, text: &str, styles: StyleChain<'a>) { - self.full.push_str(text); - self.push_segment(Segment::Text(text.len(), styles)); + self.build_text(styles, |full| full.push_str(text)); } fn build_text(&mut self, styles: StyleChain<'a>, f: F) @@ -266,33 +265,33 @@ impl<'a> Collector<'a> { { let prev = self.full.len(); f(&mut self.full); - let len = self.full.len() - prev; - self.push_segment(Segment::Text(len, styles)); + let segment_len = self.full.len() - prev; + + // Merge adjacent text segments with the same styles. + if let Some(Segment::Text(last_len, last_styles)) = self.segments.last_mut() { + if *last_styles == styles { + *last_len += segment_len; + return; + } + } + + self.segments.push(Segment::Text(segment_len, styles)); } fn push_item(&mut self, item: Item<'a>) { - self.full.push_str(item.textual()); - self.push_segment(Segment::Item(item)); - } - - fn push_segment(&mut self, segment: Segment<'a>) { - match (self.segments.last_mut(), &segment) { - // Merge adjacent text segments with the same styles. - (Some(Segment::Text(last_len, last_styles)), Segment::Text(len, styles)) - if *last_styles == *styles => - { - *last_len += *len; - } - + match (self.segments.last_mut(), &item) { // Merge adjacent weak spacing by taking the maximum. ( Some(Segment::Item(Item::Absolute(prev_amount, true))), - Segment::Item(Item::Absolute(amount, true)), + Item::Absolute(amount, true), ) => { *prev_amount = (*prev_amount).max(*amount); } - _ => self.segments.push(segment), + _ => { + self.full.push_str(item.textual()); + self.segments.push(Segment::Item(item)); + } } } } diff --git a/crates/typst-layout/src/inline/line.rs b/crates/typst-layout/src/inline/line.rs index 6dca95a9d..ef7e26c3c 100644 --- a/crates/typst-layout/src/inline/line.rs +++ b/crates/typst-layout/src/inline/line.rs @@ -38,7 +38,7 @@ pub struct Line<'a> { pub dash: Option, } -impl<'a> Line<'a> { +impl Line<'_> { /// Create an empty line. pub fn empty() -> Self { Self { @@ -685,7 +685,7 @@ impl<'a> Deref for Items<'a> { } } -impl<'a> DerefMut for Items<'a> { +impl DerefMut for Items<'_> { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } diff --git a/crates/typst-layout/src/inline/linebreak.rs b/crates/typst-layout/src/inline/linebreak.rs index 26621cd7b..7b66fcdb4 100644 --- a/crates/typst-layout/src/inline/linebreak.rs +++ b/crates/typst-layout/src/inline/linebreak.rs @@ -543,7 +543,12 @@ fn raw_ratio( ) -> f64 { // Determine how much the line's spaces would need to be stretched // to make it the desired width. - let delta = available_width - line_width; + let mut delta = available_width - line_width; + + // Avoid possible floating point errors in previous calculation. + if delta.approx_eq(Abs::zero()) { + delta = Abs::zero(); + } // Determine how much stretch or shrink is natural. let adjustability = if delta >= Abs::zero() { stretchability } else { shrinkability }; @@ -966,11 +971,13 @@ where } /// Estimates the metrics for the line spanned by the range. + #[track_caller] fn estimate(&self, range: Range) -> T { self.get(range.end) - self.get(range.start) } /// Get the metric at the given byte position. + #[track_caller] fn get(&self, index: usize) -> T { match index.checked_sub(1) { None => T::default(), diff --git a/crates/typst-layout/src/math/fragment.rs b/crates/typst-layout/src/math/fragment.rs index 691a97f70..ac8946681 100644 --- a/crates/typst-layout/src/math/fragment.rs +++ b/crates/typst-layout/src/math/fragment.rs @@ -148,7 +148,8 @@ impl MathFragment { pub fn is_text_like(&self) -> bool { match self { - Self::Glyph(_) | Self::Variant(_) => self.class() != MathClass::Large, + Self::Glyph(glyph) => !glyph.extended_shape, + Self::Variant(variant) => !variant.extended_shape, MathFragment::Frame(frame) => frame.text_like, _ => false, } @@ -247,6 +248,7 @@ pub struct GlyphFragment { pub dests: SmallVec<[Destination; 1]>, pub hidden: bool, pub limits: Limits, + pub extended_shape: bool, } impl GlyphFragment { @@ -302,6 +304,7 @@ impl GlyphFragment { span, dests: LinkElem::dests_in(styles), hidden: HideElem::hidden_in(styles), + extended_shape: false, }; fragment.set_id(ctx, id); fragment @@ -332,7 +335,8 @@ impl GlyphFragment { let accent_attach = accent_attach(ctx, id, self.font_size).unwrap_or((width + italics) / 2.0); - if !is_extended_shape(ctx, id) { + let extended_shape = is_extended_shape(ctx, id); + if !extended_shape { width += italics; } @@ -342,6 +346,7 @@ impl GlyphFragment { self.descent = -bbox.y_min.scaled(ctx, self.font_size); self.italics_correction = italics; self.accent_attach = accent_attach; + self.extended_shape = extended_shape; } pub fn height(&self) -> Abs { @@ -358,6 +363,7 @@ impl GlyphFragment { math_size: self.math_size, span: self.span, limits: self.limits, + extended_shape: self.extended_shape, frame: self.into_frame(), mid_stretched: None, } @@ -465,6 +471,7 @@ pub struct VariantFragment { pub span: Span, pub limits: Limits, pub mid_stretched: Option, + pub extended_shape: bool, } impl VariantFragment { diff --git a/crates/typst-layout/src/math/lr.rs b/crates/typst-layout/src/math/lr.rs index aba9012f2..01a7f4ccd 100644 --- a/crates/typst-layout/src/math/lr.rs +++ b/crates/typst-layout/src/math/lr.rs @@ -28,8 +28,21 @@ pub fn layout_lr( } let mut fragments = ctx.layout_into_fragments(body, styles)?; + + // Ignore leading and trailing ignorant fragments. + let start_idx = fragments + .iter() + .position(|f| !f.is_ignorant()) + .unwrap_or(fragments.len()); + let end_idx = fragments + .iter() + .skip(start_idx) + .rposition(|f| !f.is_ignorant()) + .map_or(start_idx, |i| start_idx + i + 1); + let inner_fragments = &mut fragments[start_idx..end_idx]; + let axis = scaled!(ctx, styles, axis_height); - let max_extent = fragments + let max_extent = inner_fragments .iter() .map(|fragment| (fragment.ascent() - axis).max(fragment.descent() + axis)) .max() @@ -39,7 +52,7 @@ pub fn layout_lr( let height = elem.size(styles); // Scale up fragments at both ends. - match fragments.as_mut_slice() { + match inner_fragments { [one] => scale(ctx, styles, one, relative_to, height, None), [first, .., last] => { scale(ctx, styles, first, relative_to, height, Some(MathClass::Opening)); @@ -49,7 +62,7 @@ pub fn layout_lr( } // Handle MathFragment::Variant fragments that should be scaled up. - for fragment in &mut fragments { + for fragment in inner_fragments.iter_mut() { if let MathFragment::Variant(ref mut variant) = fragment { if variant.mid_stretched == Some(false) { variant.mid_stretched = Some(true); @@ -60,12 +73,19 @@ pub fn layout_lr( // Remove weak SpacingFragment immediately after the opening or immediately // before the closing. - let original_len = fragments.len(); let mut index = 0; + let opening_exists = inner_fragments + .first() + .is_some_and(|f| f.class() == MathClass::Opening); + let closing_exists = inner_fragments + .last() + .is_some_and(|f| f.class() == MathClass::Closing); fragments.retain(|fragment| { + let discard = (index == start_idx + 1 && opening_exists + || index + 2 == end_idx && closing_exists) + && matches!(fragment, MathFragment::Spacing(_, true)); index += 1; - (index != 2 && index + 1 != original_len) - || !matches!(fragment, MathFragment::Spacing(_, true)) + !discard }); ctx.extend(fragments); diff --git a/crates/typst-layout/src/math/mat.rs b/crates/typst-layout/src/math/mat.rs index 6c8b04553..24104f4ee 100644 --- a/crates/typst-layout/src/math/mat.rs +++ b/crates/typst-layout/src/math/mat.rs @@ -127,7 +127,9 @@ fn layout_vec_body( let denom_style = style_for_denominator(styles); let mut flat = vec![]; for child in column { - flat.push(ctx.layout_into_run(child, styles.chain(&denom_style))?); + // We allow linebreaks in cases and vectors, which are functionally + // identical to commas. + flat.extend(ctx.layout_into_run(child, styles.chain(&denom_style))?.rows()); } // We pad ascent and descent with the ascent and descent of the paren // to ensure that normal vectors are aligned with others unless they are diff --git a/crates/typst-layout/src/math/run.rs b/crates/typst-layout/src/math/run.rs index 8f12c5098..b07f5893a 100644 --- a/crates/typst-layout/src/math/run.rs +++ b/crates/typst-layout/src/math/run.rs @@ -419,7 +419,10 @@ impl MathRunFrameBuilder { } fn affects_row_height(fragment: &MathFragment) -> bool { - !matches!(fragment, MathFragment::Align | MathFragment::Linebreak) + !matches!( + fragment, + MathFragment::Align | MathFragment::Linebreak | MathFragment::Tag(_) + ) } /// Create the spacing between two fragments in a given style. diff --git a/crates/typst-layout/src/math/shared.rs b/crates/typst-layout/src/math/shared.rs index 13477c10b..74e62e8f0 100644 --- a/crates/typst-layout/src/math/shared.rs +++ b/crates/typst-layout/src/math/shared.rs @@ -121,7 +121,6 @@ pub fn stack( alternator: LeftRightAlternator, minimum_ascent_descent: Option<(Abs, Abs)>, ) -> Frame { - let rows: Vec<_> = rows.into_iter().flat_map(|r| r.rows()).collect(); let AlignmentResult { points, width } = alignments(&rows); let rows: Vec<_> = rows .into_iter() diff --git a/crates/typst-layout/src/math/stretch.rs b/crates/typst-layout/src/math/stretch.rs index 9b5cd47a9..3d7c88cfc 100644 --- a/crates/typst-layout/src/math/stretch.rs +++ b/crates/typst-layout/src/math/stretch.rs @@ -295,6 +295,7 @@ fn assemble( span: base.span, limits: base.limits, mid_stretched: None, + extended_shape: true, } } diff --git a/crates/typst-layout/src/math/text.rs b/crates/typst-layout/src/math/text.rs index 3b923c5b0..05d22d12d 100644 --- a/crates/typst-layout/src/math/text.rs +++ b/crates/typst-layout/src/math/text.rs @@ -150,8 +150,8 @@ fn styled_char(styles: StyleChain, c: char, auto_italic: bool) -> char { auto_italic && matches!( c, - 'a'..='z' | 'ı' | 'ȷ' | 'A'..='Z' | 'α'..='ω' | - '∂' | 'ϵ' | 'ϑ' | 'ϰ' | 'ϕ' | 'ϱ' | 'ϖ' + 'a'..='z' | 'ħ' | 'ı' | 'ȷ' | 'A'..='Z' | + 'α'..='ω' | '∂' | 'ϵ' | 'ϑ' | 'ϰ' | 'ϕ' | 'ϱ' | 'ϖ' ) && matches!(variant, Sans | Serif), ); @@ -306,6 +306,7 @@ fn latin_exception( ('e', Cal, false, _) => 'ℯ', ('g', Cal, false, _) => 'ℊ', ('o', Cal, false, _) => 'ℴ', + ('ħ', Serif, .., true) => 'ℏ', ('ı', Serif, .., true) => '𝚤', ('ȷ', Serif, .., true) => '𝚥', _ => return None, diff --git a/crates/typst-layout/src/math/underover.rs b/crates/typst-layout/src/math/underover.rs index b1d4825b6..1a2c8db66 100644 --- a/crates/typst-layout/src/math/underover.rs +++ b/crates/typst-layout/src/math/underover.rs @@ -297,7 +297,7 @@ fn layout_underoverspreader( if let Some(annotation) = annotation { let under_style = style_for_subscript(styles); let annotation_styles = styles.chain(&under_style); - rows.push(ctx.layout_into_run(annotation, annotation_styles)?); + rows.extend(ctx.layout_into_run(annotation, annotation_styles)?.rows()); } 0 } @@ -305,7 +305,7 @@ fn layout_underoverspreader( if let Some(annotation) = annotation { let over_style = style_for_superscript(styles); let annotation_styles = styles.chain(&over_style); - rows.push(ctx.layout_into_run(annotation, annotation_styles)?); + rows.extend(ctx.layout_into_run(annotation, annotation_styles)?.rows()); } rows.push(stretched.into()); rows.push(MathRun::new(vec![body])); diff --git a/crates/typst-layout/src/pages/collect.rs b/crates/typst-layout/src/pages/collect.rs index 1903d6ac5..0bbae9f4c 100644 --- a/crates/typst-layout/src/pages/collect.rs +++ b/crates/typst-layout/src/pages/collect.rs @@ -53,7 +53,7 @@ pub fn collect<'a>( // The initial styles for the next page are ours unless this is a // "boundary" pagebreak. Such a pagebreak is generated at the end of - // the scope of a page set rule to ensure a page boundary. It's + // the scope of a page set rule to ensure a page boundary. Its // styles correspond to the styles _before_ the page set rule, so we // don't want to apply it to a potential empty page. if !pagebreak.boundary(styles) { diff --git a/crates/typst-layout/src/pages/mod.rs b/crates/typst-layout/src/pages/mod.rs index b969749a1..27002a6c9 100644 --- a/crates/typst-layout/src/pages/mod.rs +++ b/crates/typst-layout/src/pages/mod.rs @@ -11,8 +11,8 @@ use typst_library::foundations::{Content, StyleChain}; use typst_library::introspection::{ Introspector, Locator, ManualPageCounter, SplitLocator, TagElem, }; -use typst_library::layout::{FrameItem, Page, Point}; -use typst_library::model::{Document, DocumentInfo}; +use typst_library::layout::{FrameItem, Page, PagedDocument, Point}; +use typst_library::model::DocumentInfo; use typst_library::routines::{Arenas, Pair, RealizationKind, Routines}; use typst_library::World; @@ -26,12 +26,12 @@ use self::run::{layout_blank_page, layout_page_run, LayoutedPage}; /// elements. In contrast to [`layout_fragment`](crate::layout_fragment), /// this does not take regions since the regions are defined by the page /// configuration in the content and style chain. -#[typst_macros::time(name = "document")] +#[typst_macros::time(name = "layout document")] pub fn layout_document( engine: &mut Engine, content: &Content, styles: StyleChain, -) -> SourceResult { +) -> SourceResult { layout_document_impl( engine.routines, engine.world, @@ -56,7 +56,7 @@ fn layout_document_impl( route: Tracked, content: &Content, styles: StyleChain, -) -> SourceResult { +) -> SourceResult { let mut locator = Locator::root().split(); let mut engine = Engine { routines, @@ -75,7 +75,7 @@ fn layout_document_impl( let arenas = Arenas::default(); let mut info = DocumentInfo::default(); let mut children = (engine.routines.realize)( - RealizationKind::Root(&mut info), + RealizationKind::LayoutDocument(&mut info), &mut engine, &mut locator, &arenas, @@ -84,9 +84,9 @@ fn layout_document_impl( )?; let pages = layout_pages(&mut engine, &mut children, locator, styles)?; - let introspector = Introspector::new(&pages); + let introspector = Introspector::paged(&pages); - Ok(Document { pages, info, introspector }) + Ok(PagedDocument { pages, info, introspector }) } /// Layouts the document's pages. diff --git a/crates/typst-layout/src/shapes.rs b/crates/typst-layout/src/shapes.rs index 81be12190..db9acece3 100644 --- a/crates/typst-layout/src/shapes.rs +++ b/crates/typst-layout/src/shapes.rs @@ -114,6 +114,10 @@ pub fn layout_path( path.close_path(); } + if !size.is_finite() { + bail!(elem.span(), "cannot create path with infinite length"); + } + // Prepare fill and stroke. let fill = elem.fill(styles); let fill_rule = elem.fill_rule(styles); @@ -344,29 +348,48 @@ fn layout_shape( pod.size = crate::pad::shrink(region.size, &inset); } - // Layout the child. - frame = crate::layout_frame(engine, child, locator.relayout(), styles, pod)?; - - // If the child is a square or circle, relayout with full expansion into - // square region to make sure the result is really quadratic. + // If the shape is quadratic, we first measure it to determine its size + // and then layout with full expansion to force the aspect ratio and + // make sure it's really quadratic. if kind.is_quadratic() { - let length = frame.size().max_by_side().min(pod.size.min_by_side()); - let quad_pod = Region::new(Size::splat(length), Axes::splat(true)); - frame = crate::layout_frame(engine, child, locator, styles, quad_pod)?; + let length = match quadratic_size(pod) { + Some(length) => length, + None => { + // Take as much as the child wants, but without overflowing. + crate::layout_frame(engine, child, locator.relayout(), styles, pod)? + .size() + .max_by_side() + .min(pod.size.min_by_side()) + } + }; + + pod = Region::new(Size::splat(length), Axes::splat(true)); } + // Layout the child. + frame = crate::layout_frame(engine, child, locator, styles, pod)?; + // Apply the inset. if has_inset { crate::pad::grow(&mut frame, &inset); } } else { - // The default size that a shape takes on if it has no child and - // enough space. - let default = Size::new(Abs::pt(45.0), Abs::pt(30.0)); - let mut size = region.expand.select(region.size, default.min(region.size)); - if kind.is_quadratic() { - size = Size::splat(size.min_by_side()); - } + // The default size that a shape takes on if it has no child and no + // forced sizes. + let default = Size::new(Abs::pt(45.0), Abs::pt(30.0)).min(region.size); + + let size = if kind.is_quadratic() { + Size::splat(match quadratic_size(region) { + Some(length) => length, + None => default.min_by_side(), + }) + } else { + // For each dimension, pick the region size if forced, otherwise + // use the default size (or the region size if the default + // is too large for the region). + region.expand.select(region.size, default) + }; + frame = Frame::soft(size); } @@ -407,6 +430,24 @@ fn layout_shape( Ok(frame) } +/// Determines the forced size of a quadratic shape based on the region, if any. +/// +/// The size is forced if at least one axis is expanded because `expand` is +/// `true` for axes whose size was manually specified by the user. +fn quadratic_size(region: Region) -> Option { + if region.expand.x && region.expand.y { + // If both `width` and `height` are specified, we choose the + // smaller one. + Some(region.size.x.min(region.size.y)) + } else if region.expand.x { + Some(region.size.x) + } else if region.expand.y { + Some(region.size.y) + } else { + None + } +} + /// Creates a new rectangle as a path. pub fn clip_rect( size: Size, diff --git a/crates/typst-library/Cargo.toml b/crates/typst-library/Cargo.toml index a1fb1033f..d854e4d53 100644 --- a/crates/typst-library/Cargo.toml +++ b/crates/typst-library/Cargo.toml @@ -23,6 +23,7 @@ bitflags = { workspace = true } bumpalo = { workspace = true } chinese-number = { workspace = true } ciborium = { workspace = true } +codex = { workspace = true } comemo = { workspace = true } csv = { workspace = true } ecow = { workspace = true } diff --git a/crates/typst-library/src/engine.rs b/crates/typst-library/src/engine.rs index e532172e5..80aaef224 100644 --- a/crates/typst-library/src/engine.rs +++ b/crates/typst-library/src/engine.rs @@ -301,6 +301,9 @@ impl Route<'_> { /// The maximum layout nesting depth. const MAX_LAYOUT_DEPTH: usize = 72; + /// The maximum HTML nesting depth. + const MAX_HTML_DEPTH: usize = 72; + /// The maximum function call nesting depth. const MAX_CALL_DEPTH: usize = 80; @@ -326,6 +329,17 @@ impl Route<'_> { Ok(()) } + /// Ensures that we are within the maximum HTML depth. + pub fn check_html_depth(&self) -> HintedStrResult<()> { + if !self.within(Route::MAX_HTML_DEPTH) { + bail!( + "maximum HTML depth exceeded"; + hint: "try to reduce the amount of nesting of your HTML", + ); + } + Ok(()) + } + /// Ensures that we are within the maximum function call depth. pub fn check_call_depth(&self) -> StrResult<()> { if !self.within(Route::MAX_CALL_DEPTH) { @@ -336,6 +350,7 @@ impl Route<'_> { } #[comemo::track] +#[allow(clippy::needless_lifetimes)] impl<'a> Route<'a> { /// Whether the given id is part of the route. pub fn contains(&self, id: FileId) -> bool { diff --git a/crates/typst-library/src/foundations/args.rs b/crates/typst-library/src/foundations/args.rs index ee282a874..a60e6d7f2 100644 --- a/crates/typst-library/src/foundations/args.rs +++ b/crates/typst-library/src/foundations/args.rs @@ -187,7 +187,7 @@ impl Args { self.items.retain(|item| { if item.name.is_some() { return true; - }; + } let span = item.value.span; let spanned = Spanned::new(std::mem::take(&mut item.value.v), span); match T::from_value(spanned).at(span) { diff --git a/crates/typst-library/src/foundations/array.rs b/crates/typst-library/src/foundations/array.rs index 9c8aecac2..4667ee765 100644 --- a/crates/typst-library/src/foundations/array.rs +++ b/crates/typst-library/src/foundations/array.rs @@ -815,6 +815,19 @@ impl Array { /// /// Returns an error if two values could not be compared or if the key /// function (if given) yields an error. + /// + /// To sort according to multiple criteria at once, e.g. in case of equality + /// between some criteria, the key function can return an array. The results + /// are in lexicographic order. + /// + /// ```example + /// #let array = ( + /// (a: 2, b: 4), + /// (a: 1, b: 5), + /// (a: 2, b: 3), + /// ) + /// #array.sorted(key: it => (it.a, it.b)) + /// ``` #[func] pub fn sorted( self, diff --git a/crates/typst-library/src/foundations/content.rs b/crates/typst-library/src/foundations/content.rs index a274b8bfa..bfafbc486 100644 --- a/crates/typst-library/src/foundations/content.rs +++ b/crates/typst-library/src/foundations/content.rs @@ -426,7 +426,7 @@ impl Content { /// selector. /// /// Elements produced in `show` rules will not be included in the results. - pub fn query_first(&self, selector: Selector) -> Option { + pub fn query_first(&self, selector: &Selector) -> Option { let mut result = None; self.traverse(&mut |element| { if result.is_none() && selector.matches(&element, None) { @@ -481,17 +481,20 @@ impl Content { impl Content { /// Strongly emphasize this content. pub fn strong(self) -> Self { - StrongElem::new(self).pack() + let span = self.span(); + StrongElem::new(self).pack().spanned(span) } /// Emphasize this content. pub fn emph(self) -> Self { - EmphElem::new(self).pack() + let span = self.span(); + EmphElem::new(self).pack().spanned(span) } /// Underline this content. pub fn underlined(self) -> Self { - UnderlineElem::new(self).pack() + let span = self.span(); + UnderlineElem::new(self).pack().spanned(span) } /// Link the content somewhere. @@ -506,17 +509,24 @@ impl Content { /// Pad this content at the sides. pub fn padded(self, padding: Sides>) -> Self { + let span = self.span(); PadElem::new(self) .with_left(padding.left) .with_top(padding.top) .with_right(padding.right) .with_bottom(padding.bottom) .pack() + .spanned(span) } /// Transform this content's contents without affecting layout. pub fn moved(self, delta: Axes>) -> Self { - MoveElem::new(self).with_dx(delta.x).with_dy(delta.y).pack() + let span = self.span(); + MoveElem::new(self) + .with_dx(delta.x) + .with_dy(delta.y) + .pack() + .spanned(span) } } diff --git a/crates/typst-library/src/foundations/decimal.rs b/crates/typst-library/src/foundations/decimal.rs index cf11e1ddd..d363a6a41 100644 --- a/crates/typst-library/src/foundations/decimal.rs +++ b/crates/typst-library/src/foundations/decimal.rs @@ -317,6 +317,7 @@ impl Decimal { }) .at(value.span) } + ToDecimal::Decimal(decimal) => Ok(decimal), } } } @@ -429,6 +430,8 @@ impl Hash for Decimal { /// A value that can be cast to a decimal. pub enum ToDecimal { + /// A decimal to be converted to itself. + Decimal(Decimal), /// A string with the decimal's representation. Str(EcoString), /// An integer to be converted to the equivalent decimal. @@ -439,7 +442,9 @@ pub enum ToDecimal { cast! { ToDecimal, + v: Decimal => Self::Decimal(v), v: i64 => Self::Int(v), + v: bool => Self::Int(v as i64), v: f64 => Self::Float(v), v: Str => Self::Str(EcoString::from(v)), } diff --git a/crates/typst-library/src/foundations/float.rs b/crates/typst-library/src/foundations/float.rs index bb3232ee8..c3d4e0e73 100644 --- a/crates/typst-library/src/foundations/float.rs +++ b/crates/typst-library/src/foundations/float.rs @@ -128,16 +128,21 @@ impl f64 { #[default(Endianness::Little)] endian: Endianness, ) -> StrResult { - // Convert slice to an array of length 8. - let buf: [u8; 8] = match bytes.as_ref().try_into() { - Ok(buffer) => buffer, - Err(_) => bail!("bytes must have a length of exactly 8"), + // Convert slice to an array of length 4 or 8. + if let Ok(buffer) = <[u8; 8]>::try_from(bytes.as_ref()) { + return Ok(match endian { + Endianness::Little => f64::from_le_bytes(buffer), + Endianness::Big => f64::from_be_bytes(buffer), + }); + }; + if let Ok(buffer) = <[u8; 4]>::try_from(bytes.as_ref()) { + return Ok(match endian { + Endianness::Little => f32::from_le_bytes(buffer), + Endianness::Big => f32::from_be_bytes(buffer), + } as f64); }; - Ok(match endian { - Endianness::Little => f64::from_le_bytes(buf), - Endianness::Big => f64::from_be_bytes(buf), - }) + bail!("bytes must have a length of 4 or 8"); } /// Converts a float to bytes. @@ -153,13 +158,25 @@ impl f64 { #[named] #[default(Endianness::Little)] endian: Endianness, - ) -> Bytes { - match endian { - Endianness::Little => self.to_le_bytes(), - Endianness::Big => self.to_be_bytes(), - } - .as_slice() - .into() + #[named] + #[default(8)] + size: u32, + ) -> StrResult { + Ok(match size { + 8 => match endian { + Endianness::Little => self.to_le_bytes(), + Endianness::Big => self.to_be_bytes(), + } + .as_slice() + .into(), + 4 => match endian { + Endianness::Little => (self as f32).to_le_bytes(), + Endianness::Big => (self as f32).to_be_bytes(), + } + .as_slice() + .into(), + _ => bail!("size must be either 4 or 8"), + }) } } diff --git a/crates/typst-library/src/foundations/func.rs b/crates/typst-library/src/foundations/func.rs index e34f48a17..40c826df9 100644 --- a/crates/typst-library/src/foundations/func.rs +++ b/crates/typst-library/src/foundations/func.rs @@ -443,7 +443,7 @@ pub trait NativeFunc { Func::from(Self::data()) } - /// Get the function data for the native Rust type. + /// Get the function data for the native Rust function. fn data() -> &'static NativeFuncData; } @@ -462,6 +462,7 @@ pub struct NativeFuncData { pub keywords: &'static [&'static str], /// Whether this function makes use of context. pub contextual: bool, + /// Definitions in the scope of the function. pub scope: LazyLock, /// A list of parameter information for each parameter. pub params: LazyLock>, diff --git a/crates/typst-library/src/foundations/int.rs b/crates/typst-library/src/foundations/int.rs index e936353cc..bddffada3 100644 --- a/crates/typst-library/src/foundations/int.rs +++ b/crates/typst-library/src/foundations/int.rs @@ -11,7 +11,12 @@ use crate::foundations::{ /// /// The number can be negative, zero, or positive. As Typst uses 64 bits to /// store integers, integers cannot be smaller than `{-9223372036854775808}` or -/// larger than `{9223372036854775807}`. +/// larger than `{9223372036854775807}`. Integer literals are always positive, +/// so a negative integer such as `{-1}` is semantically the negation `-` of the +/// positive literal `1`. A positive integer greater than the maximum value and +/// a negative integer less than or equal to the minimum value cannot be +/// represented as an integer literal, and are instead parsed as a `{float}`. +/// The minimum integer value can still be obtained through integer arithmetic. /// /// The number can also be specified as hexadecimal, octal, or binary by /// starting it with a zero followed by either `x`, `o`, or `b`. diff --git a/crates/typst-library/src/foundations/label.rs b/crates/typst-library/src/foundations/label.rs index 726958df7..2f5520b1c 100644 --- a/crates/typst-library/src/foundations/label.rs +++ b/crates/typst-library/src/foundations/label.rs @@ -1,7 +1,7 @@ use ecow::{eco_format, EcoString}; -use typst_utils::PicoStr; +use typst_utils::{PicoStr, ResolvedPicoStr}; -use crate::foundations::{func, scope, ty, Repr}; +use crate::foundations::{func, scope, ty, Repr, Str}; /// A label for an element. /// @@ -45,17 +45,17 @@ use crate::foundations::{func, scope, ty, Repr}; /// Currently, labels can only be attached to elements in markup mode, not in /// code mode. This might change in the future. #[ty(scope, cast)] -#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub struct Label(PicoStr); impl Label { - /// Creates a label from a string, interning it. - pub fn new(name: impl Into) -> Self { - Self(name.into()) + /// Creates a label from an interned string. + pub fn new(name: PicoStr) -> Self { + Self(name) } /// Resolves the label to a string. - pub fn as_str(&self) -> &'static str { + pub fn resolve(self) -> ResolvedPicoStr { self.0.resolve() } @@ -71,15 +71,15 @@ impl Label { #[func(constructor)] pub fn construct( /// The name of the label. - name: PicoStr, + name: Str, ) -> Label { - Self(name) + Self(PicoStr::intern(name.as_str())) } } impl Repr for Label { fn repr(&self) -> EcoString { - eco_format!("<{}>", self.as_str()) + eco_format!("<{}>", self.resolve()) } } diff --git a/crates/typst-library/src/foundations/mod.rs b/crates/typst-library/src/foundations/mod.rs index 9259a7d16..28f983186 100644 --- a/crates/typst-library/src/foundations/mod.rs +++ b/crates/typst-library/src/foundations/mod.rs @@ -31,6 +31,8 @@ mod selector; mod str; mod styles; mod symbol; +#[path = "target.rs"] +mod target_; mod ty; mod value; mod version; @@ -61,6 +63,7 @@ pub use self::selector::*; pub use self::str::*; pub use self::styles::*; pub use self::symbol::*; +pub use self::target_::*; pub use self::ty::*; pub use self::value::*; pub use self::version::*; @@ -79,6 +82,7 @@ use typst_syntax::Spanned; use crate::diag::{bail, SourceResult, StrResult}; use crate::engine::Engine; use crate::routines::EvalMode; +use crate::{Feature, Features}; /// Foundational types and functions. /// @@ -88,7 +92,7 @@ use crate::routines::EvalMode; pub static FOUNDATIONS: Category; /// Hook up all `foundations` definitions. -pub(super) fn define(global: &mut Scope, inputs: Dict) { +pub(super) fn define(global: &mut Scope, inputs: Dict, features: &Features) { global.category(FOUNDATIONS); global.define_type::(); global.define_type::(); @@ -116,6 +120,9 @@ pub(super) fn define(global: &mut Scope, inputs: Dict) { global.define_func::(); global.define_func::(); global.define_func::