diff --git a/Cargo.lock b/Cargo.lock index eb4d3c47f..be5117da2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -890,9 +890,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" @@ -1169,7 +1169,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", ] @@ -2931,6 +2931,7 @@ dependencies = [ "qcms", "rayon", "regex", + "regex-syntax", "roxmltree", "rust_decimal", "rustybuzz", diff --git a/Cargo.toml b/Cargo.toml index 0be4c5ad9..b4f704f80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,6 +94,7 @@ qcms = "0.3.0" quote = "1" rayon = "1.7.0" regex = "1" +regex-syntax = "0.8" resvg = { version = "0.43", default-features = false, features = ["raster-images"] } roxmltree = "0.20" rust_decimal = { version = "1.36.0", default-features = false, features = ["maths"] } diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index 3aa3aa3b9..adeef0f2d 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -17,7 +17,7 @@ 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 typst_pdf::{PdfOptions, PdfStandards, Timestamp}; use crate::args::{ CompileArgs, CompileCommand, DiagnosticFormat, Input, Output, OutputFormat, @@ -55,7 +55,7 @@ pub struct CompileConfig { pub output_format: OutputFormat, /// Which pages to export. pub pages: Option, - /// The document's creation date formatted as a UNIX timestamp. + /// The document's creation date formatted as a UNIX timestamp, with UTC suffix. pub creation_timestamp: Option>, /// The format to emit diagnostics in. pub diagnostic_format: DiagnosticFormat, @@ -271,11 +271,23 @@ fn export_paged(document: &PagedDocument, config: &CompileConfig) -> SourceResul /// Export to a PDF. fn export_pdf(document: &PagedDocument, config: &CompileConfig) -> SourceResult<()> { + // If the timestamp is provided through the CLI, use UTC suffix, + // else, use the current local time and timezone. + let timestamp = match config.creation_timestamp { + Some(timestamp) => convert_datetime(timestamp).map(Timestamp::new_utc), + None => { + let local_datetime = chrono::Local::now(); + convert_datetime(local_datetime).and_then(|datetime| { + Timestamp::new_local( + datetime, + local_datetime.offset().local_minus_utc() / 60, + ) + }) + } + }; let options = PdfOptions { ident: Smart::Auto, - timestamp: convert_datetime( - config.creation_timestamp.unwrap_or_else(chrono::Utc::now), - ), + timestamp, page_ranges: config.pages.clone(), standards: config.pdf_standards.clone(), }; @@ -289,7 +301,9 @@ fn export_pdf(document: &PagedDocument, config: &CompileConfig) -> SourceResult< } /// Convert [`chrono::DateTime`] to [`Datetime`] -fn convert_datetime(date_time: chrono::DateTime) -> Option { +fn convert_datetime( + date_time: chrono::DateTime, +) -> Option { Datetime::from_ymd_hms( date_time.year(), date_time.month().try_into().ok()?, diff --git a/crates/typst-eval/src/call.rs b/crates/typst-eval/src/call.rs index 513d1dd2c..fc934cef5 100644 --- a/crates/typst-eval/src/call.rs +++ b/crates/typst-eval/src/call.rs @@ -593,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); @@ -613,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-html/src/encode.rs b/crates/typst-html/src/encode.rs index d4ff83d67..b87b0e1d6 100644 --- a/crates/typst-html/src/encode.rs +++ b/crates/typst-html/src/encode.rs @@ -8,14 +8,30 @@ use typst_syntax::Span; /// Encodes an HTML document into a string. pub fn html(document: &HtmlDocument) -> SourceResult { - let mut w = Writer { buf: String::new() }; + 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. @@ -67,9 +83,30 @@ fn write_element(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> { return Ok(()); } - for node in &element.children { - write_node(w, node)?; + 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(" SourceResult<()> { 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 diff --git a/crates/typst-ide/src/matchers.rs b/crates/typst-ide/src/matchers.rs index 4aeba29be..18262f701 100644 --- a/crates/typst-ide/src/matchers.rs +++ b/crates/typst-ide/src/matchers.rs @@ -123,6 +123,36 @@ pub fn named_items( } } + if let Some(v) = parent.cast::().filter(|v| { + // Check if the node is in the body of the closure. + let body = parent.find(v.body().span()); + body.is_some_and(|n| n.find(node.span()).is_some()) + }) { + for param in v.params().children() { + match param { + ast::Param::Pos(pattern) => { + for ident in pattern.bindings() { + if let Some(t) = recv(NamedItem::Var(ident)) { + return Some(t); + } + } + } + ast::Param::Named(n) => { + if let Some(t) = recv(NamedItem::Var(n.name())) { + return Some(t); + } + } + ast::Param::Spread(s) => { + if let Some(sink_ident) = s.sink_ident() { + if let Some(t) = recv(NamedItem::Var(sink_ident)) { + return Some(t); + } + } + } + } + } + } + ancestor = Some(parent.clone()); continue; } @@ -269,6 +299,17 @@ mod tests { assert!(!has_named_items(r#"#let a = 1;#let b = 2;"#, 8, "b")); } + #[test] + fn test_param_named_items() { + // Has named items + assert!(has_named_items(r#"#let f(a) = 1;#let b = 2;"#, 12, "a")); + assert!(has_named_items(r#"#let f(a: b) = 1;#let b = 2;"#, 15, "a")); + + // Doesn't have named items + assert!(!has_named_items(r#"#let f(a) = 1;#let b = 2;"#, 19, "a")); + assert!(!has_named_items(r#"#let f(a: b) = 1;#let b = 2;"#, 15, "b")); + } + #[test] fn test_import_named_items() { // Cannot test much. diff --git a/crates/typst-ide/src/tooltip.rs b/crates/typst-ide/src/tooltip.rs index adfbeda50..4eaaeda1f 100644 --- a/crates/typst-ide/src/tooltip.rs +++ b/crates/typst-ide/src/tooltip.rs @@ -337,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-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..f504d22e7 100644 --- a/crates/typst-layout/src/flow/distribute.rs +++ b/crates/typst-layout/src/flow/distribute.rs @@ -17,7 +17,7 @@ pub fn distribute(composer: &mut Composer, regions: Regions) -> FlowResult { /// A snapshot which can be restored to migrate a suffix of sticky blocks to /// the next region. sticky: Option>, - /// Whether there was at least one proper block. Otherwise, sticky blocks - /// are disabled (or else they'd keep being migrated). - stickable: bool, + /// Whether the current group of consecutive sticky blocks are still sticky + /// and may migrate with the attached frame. This is `None` while we aren't + /// processing sticky blocks. On the first sticky block, this will become + /// `Some(true)` if migrating sticky blocks as usual would make a + /// difference - this is given by `regions.may_progress()`. Otherwise, it + /// is set to `Some(false)`, which is usually the case when the first + /// sticky block in the group is at the very top of the page (then, + /// migrating it would just lead us back to the top of the page, leading + /// to an infinite loop). In that case, all sticky blocks of the group are + /// also disabled, until this is reset to `None` on the first non-sticky + /// frame we find. + /// + /// While this behavior of disabling stickiness of sticky blocks at the + /// very top of the page may seem non-ideal, it is only problematic (that + /// is, may lead to orphaned sticky blocks / headings) if the combination + /// of 'sticky blocks + attached frame' doesn't fit in one page, in which + /// case there is nothing Typst can do to improve the situation, as sticky + /// blocks are supposed to always be in the same page as the subsequent + /// frame, but that is impossible in that case, which is thus pathological. + stickable: Option, } /// A snapshot of the distribution state. @@ -240,7 +257,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(()); @@ -313,18 +331,41 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { // If the frame is sticky and we haven't remembered a preceding // sticky element, make a checkpoint which we can restore should we // end on this sticky element. - if self.stickable && self.sticky.is_none() { + // + // The first sticky block within consecutive sticky blocks + // determines whether this group of sticky blocks has stickiness + // disabled or not. + // + // The criteria used here is: if migrating this group of sticky + // blocks together with the "attached" block can't improve the lack + // of space, since we're at the start of the region, then we don't + // do so, and stickiness is disabled (at least, for this region). + // Otherwise, migration is allowed. + // + // Note that, since the whole region is checked, this ensures sticky + // blocks at the top of a block - but not necessarily of the page - + // can still be migrated. + if self.sticky.is_none() + && *self.stickable.get_or_insert_with(|| self.regions.may_progress()) + { self.sticky = Some(self.snapshot()); } } else if !frame.is_empty() { - // If the frame isn't sticky, we can forget a previous snapshot. - self.stickable = true; + // If the frame isn't sticky, we can forget a previous snapshot. We + // interrupt a group of sticky blocks, if there was one, so we reset + // the saved stickable check for the next group of sticky blocks. self.sticky = None; + self.stickable = None; } // 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 +388,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/image.rs b/crates/typst-layout/src/image.rs index 84a602823..f44d68873 100644 --- a/crates/typst-layout/src/image.rs +++ b/crates/typst-layout/src/image.rs @@ -54,7 +54,8 @@ pub fn layout_image( format, elem.alt(styles), engine.world, - &families(styles).collect::>(), + &families(styles).map(|f| f.as_str()).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/linebreak.rs b/crates/typst-layout/src/inline/linebreak.rs index 236d68921..7b66fcdb4 100644 --- a/crates/typst-layout/src/inline/linebreak.rs +++ b/crates/typst-layout/src/inline/linebreak.rs @@ -971,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/inline/shaping.rs b/crates/typst-layout/src/inline/shaping.rs index c2b892d82..d6b7632b6 100644 --- a/crates/typst-layout/src/inline/shaping.rs +++ b/crates/typst-layout/src/inline/shaping.rs @@ -11,8 +11,8 @@ use typst_library::engine::Engine; use typst_library::foundations::{Smart, StyleChain}; use typst_library::layout::{Abs, Dir, Em, Frame, FrameItem, Point, Size}; use typst_library::text::{ - families, features, is_default_ignorable, variant, Font, FontVariant, Glyph, Lang, - Region, TextEdgeBounds, TextElem, TextItem, + families, features, is_default_ignorable, variant, Font, FontFamily, FontVariant, + Glyph, Lang, Region, TextEdgeBounds, TextElem, TextItem, }; use typst_library::World; use typst_utils::SliceExt; @@ -351,7 +351,7 @@ impl<'a> ShapedText<'a> { for family in families(self.styles) { if let Some(font) = world .book() - .select(family, self.variant) + .select(family.as_str(), self.variant) .and_then(|id| world.font(id)) { expand(&font, TextEdgeBounds::Zero); @@ -463,7 +463,8 @@ impl<'a> ShapedText<'a> { None }; let mut chain = families(self.styles) - .map(|family| book.select(family, self.variant)) + .filter(|family| family.covers().map_or(true, |c| c.is_match("-"))) + .map(|family| book.select(family.as_str(), self.variant)) .chain(fallback_func.iter().map(|f| f())) .flatten(); @@ -719,7 +720,7 @@ fn shape_segment<'a>( ctx: &mut ShapingContext, base: usize, text: &str, - mut families: impl Iterator + Clone, + mut families: impl Iterator + Clone, ) { // Don't try shaping newlines, tabs, or default ignorables. if text @@ -732,11 +733,18 @@ fn shape_segment<'a>( // Find the next available family. let world = ctx.engine.world; let book = world.book(); - let mut selection = families.find_map(|family| { - book.select(family, ctx.variant) + let mut selection = None; + let mut covers = None; + for family in families.by_ref() { + selection = book + .select(family.as_str(), ctx.variant) .and_then(|id| world.font(id)) - .filter(|font| !ctx.used.contains(font)) - }); + .filter(|font| !ctx.used.contains(font)); + if selection.is_some() { + covers = family.covers(); + break; + } + } // Do font fallback if the families are exhausted and fallback is enabled. if selection.is_none() && ctx.fallback { @@ -795,6 +803,16 @@ fn shape_segment<'a>( let pos = buffer.glyph_positions(); let ltr = ctx.dir.is_positive(); + // Whether the character at the given offset is covered by the coverage. + let is_covered = |offset| { + let end = text[offset..] + .char_indices() + .nth(1) + .map(|(i, _)| offset + i) + .unwrap_or(text.len()); + covers.map_or(true, |cov| cov.is_match(&text[offset..end])) + }; + // Collect the shaped glyphs, doing fallback and shaping parts again with // the next font if necessary. let mut i = 0; @@ -803,7 +821,7 @@ fn shape_segment<'a>( let cluster = info.cluster as usize; // Add the glyph to the shaped output. - if info.glyph_id != 0 { + if info.glyph_id != 0 && is_covered(cluster) { // Determine the text range of the glyph. let start = base + cluster; let end = base @@ -836,7 +854,9 @@ fn shape_segment<'a>( } else { // First, search for the end of the tofu sequence. let k = i; - while infos.get(i + 1).is_some_and(|info| info.glyph_id == 0) { + while infos.get(i + 1).is_some_and(|info| { + info.glyph_id == 0 || !is_covered(info.cluster as usize) + }) { i += 1; } diff --git a/crates/typst-layout/src/lists.rs b/crates/typst-layout/src/lists.rs index 08c2a2f45..0d51a1e4e 100644 --- a/crates/typst-layout/src/lists.rs +++ b/crates/typst-layout/src/lists.rs @@ -74,6 +74,7 @@ pub fn layout_enum( regions: Regions, ) -> SourceResult { let numbering = elem.numbering(styles); + let reversed = elem.reversed(styles); let indent = elem.indent(styles); let body_indent = elem.body_indent(styles); let gutter = elem.spacing(styles).unwrap_or_else(|| { @@ -86,7 +87,9 @@ pub fn layout_enum( let mut cells = vec![]; let mut locator = locator.split(); - let mut number = elem.start(styles); + let mut number = + elem.start(styles) + .unwrap_or_else(|| if reversed { elem.children.len() } else { 1 }); let mut parents = EnumElem::parents_in(styles); let full = elem.full(styles); @@ -127,7 +130,8 @@ pub fn layout_enum( item.body.clone().styled(EnumElem::set_parents(smallvec![number])), locator.next(&item.body.span()), )); - number = number.saturating_add(1); + number = + if reversed { number.saturating_sub(1) } else { number.saturating_add(1) }; } let grid = CellGrid::new( 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/mod.rs b/crates/typst-layout/src/math/mod.rs index 32059cef9..e642f6338 100644 --- a/crates/typst-layout/src/math/mod.rs +++ b/crates/typst-layout/src/math/mod.rs @@ -237,7 +237,7 @@ fn find_math_font( let variant = variant(styles); let world = engine.world; let Some(font) = families(styles).find_map(|family| { - let id = world.book().select(family, variant)?; + let id = world.book().select(family.as_str(), variant)?; let font = world.font(id)?; let _ = font.ttf().tables().math?.constants?; Some(font) 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/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/repeat.rs b/crates/typst-layout/src/repeat.rs index b761438c8..bfc7b32ce 100644 --- a/crates/typst-layout/src/repeat.rs +++ b/crates/typst-layout/src/repeat.rs @@ -33,8 +33,17 @@ pub fn layout_repeat( let fill = region.size.x; let width = piece.width(); - // count * width + (count - 1) * gap = fill, but count is an integer so - // we need to round down and get the remainder. + // We need to fit the body N times, but the number of gaps is (N - 1): + // N * w + (N - 1) * g ≤ F + // where N - body count (count) + // w - body width (width) + // g - gap width (gap) + // F - available space to fill (fill) + // + // N * w + N * g - g ≤ F + // N * (w + g) ≤ F + g + // N ≤ (F + g) / (w + g) + // N = ⌊(F + g) / (w + g)⌋ let count = ((fill + gap) / (width + gap)).floor(); let remaining = (fill + gap) % (width + gap); @@ -52,7 +61,7 @@ pub fn layout_repeat( if width > Abs::zero() { for _ in 0..(count as usize).min(1000) { frame.push_frame(Point::with_x(offset), piece.clone()); - offset += piece.width() + gap; + offset += width + gap; } } diff --git a/crates/typst-layout/src/shapes.rs b/crates/typst-layout/src/shapes.rs index a35021721..2044c917e 100644 --- a/crates/typst-layout/src/shapes.rs +++ b/crates/typst-layout/src/shapes.rs @@ -348,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); } @@ -411,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, @@ -682,11 +719,7 @@ fn segment( false } - let solid = stroke - .dash - .as_ref() - .map(|pattern| pattern.array.is_empty()) - .unwrap_or(true); + let solid = stroke.dash.as_ref().map(|dash| dash.array.is_empty()).unwrap_or(true); let use_fill = solid && fill_corners(start, end, corners); let shape = if use_fill { diff --git a/crates/typst-library/Cargo.toml b/crates/typst-library/Cargo.toml index d854e4d53..cc5e26712 100644 --- a/crates/typst-library/Cargo.toml +++ b/crates/typst-library/Cargo.toml @@ -44,6 +44,7 @@ png = { workspace = true } qcms = { workspace = true } rayon = { workspace = true } regex = { workspace = true } +regex-syntax = { workspace = true } roxmltree = { workspace = true } rust_decimal = { workspace = true } rustybuzz = { workspace = true } 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/mod.rs b/crates/typst-library/src/foundations/mod.rs index 28f983186..d960a666c 100644 --- a/crates/typst-library/src/foundations/mod.rs +++ b/crates/typst-library/src/foundations/mod.rs @@ -119,7 +119,6 @@ pub(super) fn define(global: &mut Scope, inputs: Dict, features: &Features) { global.define_func::(); global.define_func::(); global.define_func::(); - global.define_func::