From d897ab5e7d2e941494df8ba137a1f92f8aada03a Mon Sep 17 00:00:00 2001 From: Laurenz Date: Thu, 6 Feb 2025 10:34:28 +0100 Subject: [PATCH 001/558] Autocomplete content methods (#5822) --- crates/typst-ide/src/complete.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index c1f08cf094..7df788dc32 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -398,7 +398,17 @@ fn field_access_completions( value: &Value, styles: &Option, ) { - for (name, binding) in value.ty().scope().iter() { + let scopes = { + let ty = value.ty().scope(); + let elem = match value { + Value::Content(content) => Some(content.elem().scope()), + _ => None, + }; + elem.into_iter().chain(Some(ty)) + }; + + // Autocomplete methods from the element's or type's scope. + for (name, binding) in scopes.flat_map(|scope| scope.iter()) { ctx.call_completion(name.clone(), binding.read()); } @@ -1747,4 +1757,15 @@ mod tests { .must_include(["this", "that"]) .must_exclude(["*", "figure"]); } + + #[test] + fn test_autocomplete_type_methods() { + test("#\"hello\".", -1).must_include(["len", "contains"]); + } + + #[test] + fn test_autocomplete_content_methods() { + test("#show outline.entry: it => it.\n#outline()\n= Hi", 30) + .must_include(["indented", "body", "page"]); + } } From ca702c7f82ef8e027e559228dc9c469e1a65ac6f Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Thu, 6 Feb 2025 11:18:10 +0100 Subject: [PATCH 002/558] Documentation fixes and improvements (#5816) --- crates/typst-layout/src/shapes.rs | 4 ++-- crates/typst-library/src/foundations/plugin.rs | 4 +--- crates/typst-library/src/loading/cbor.rs | 4 +--- crates/typst-library/src/loading/csv.rs | 4 +--- crates/typst-library/src/loading/json.rs | 4 +--- crates/typst-library/src/loading/toml.rs | 4 +--- crates/typst-library/src/loading/xml.rs | 4 +--- crates/typst-library/src/loading/yaml.rs | 4 +--- crates/typst-library/src/model/outline.rs | 2 +- crates/typst-library/src/pdf/embed.rs | 4 +--- crates/typst-library/src/visualize/curve.rs | 18 +++++++++--------- .../typst-library/src/visualize/image/mod.rs | 7 ++++--- crates/typst-library/src/visualize/path.rs | 6 +++--- crates/typst-library/src/visualize/shape.rs | 2 +- docs/changelog/0.13.0.md | 6 +++--- docs/reference/export/png.md | 2 +- 16 files changed, 32 insertions(+), 47 deletions(-) diff --git a/crates/typst-layout/src/shapes.rs b/crates/typst-layout/src/shapes.rs index eb665f06af..21d0a518fa 100644 --- a/crates/typst-layout/src/shapes.rs +++ b/crates/typst-layout/src/shapes.rs @@ -1281,7 +1281,7 @@ impl ControlPoints { } } -/// Helper to draw arcs with bezier curves. +/// Helper to draw arcs with Bézier curves. trait CurveExt { fn arc(&mut self, start: Point, center: Point, end: Point); fn arc_move(&mut self, start: Point, center: Point, end: Point); @@ -1305,7 +1305,7 @@ impl CurveExt for Curve { } } -/// Get the control points for a bezier curve that approximates a circular arc for +/// Get the control points for a Bézier curve that approximates a circular arc for /// a start point, an end point and a center of the circle whose arc connects /// the two. fn bezier_arc_control(start: Point, center: Point, end: Point) -> [Point; 2] { diff --git a/crates/typst-library/src/foundations/plugin.rs b/crates/typst-library/src/foundations/plugin.rs index a33f1cb91a..31f8cd7327 100644 --- a/crates/typst-library/src/foundations/plugin.rs +++ b/crates/typst-library/src/foundations/plugin.rs @@ -148,9 +148,7 @@ use crate::loading::{DataSource, Load}; #[func(scope)] pub fn plugin( engine: &mut Engine, - /// A path to a WebAssembly file or raw WebAssembly bytes. - /// - /// For more details about paths, see the [Paths section]($syntax/#paths). + /// A [path]($syntax/#paths) to a WebAssembly file or raw WebAssembly bytes. source: Spanned, ) -> SourceResult { let data = source.load(engine.world)?; diff --git a/crates/typst-library/src/loading/cbor.rs b/crates/typst-library/src/loading/cbor.rs index 801ca617a9..aa14c5c776 100644 --- a/crates/typst-library/src/loading/cbor.rs +++ b/crates/typst-library/src/loading/cbor.rs @@ -20,9 +20,7 @@ use crate::loading::{DataSource, Load}; #[func(scope, title = "CBOR")] pub fn cbor( engine: &mut Engine, - /// A path to a CBOR file or raw CBOR bytes. - /// - /// For more details about paths, see the [Paths section]($syntax/#paths). + /// A [path]($syntax/#paths) to a CBOR file or raw CBOR bytes. source: Spanned, ) -> SourceResult { let data = source.load(engine.world)?; diff --git a/crates/typst-library/src/loading/csv.rs b/crates/typst-library/src/loading/csv.rs index 6fdec44590..6afb5baeb0 100644 --- a/crates/typst-library/src/loading/csv.rs +++ b/crates/typst-library/src/loading/csv.rs @@ -26,9 +26,7 @@ use crate::loading::{DataSource, Load, Readable}; #[func(scope, title = "CSV")] pub fn csv( engine: &mut Engine, - /// Path to a CSV file or raw CSV bytes. - /// - /// For more details about paths, see the [Paths section]($syntax/#paths). + /// A [path]($syntax/#paths) to a CSV file or raw CSV bytes. source: Spanned, /// The delimiter that separates columns in the CSV file. /// Must be a single ASCII character. diff --git a/crates/typst-library/src/loading/json.rs b/crates/typst-library/src/loading/json.rs index 185bac1430..aa908cca4c 100644 --- a/crates/typst-library/src/loading/json.rs +++ b/crates/typst-library/src/loading/json.rs @@ -51,9 +51,7 @@ use crate::loading::{DataSource, Load, Readable}; #[func(scope, title = "JSON")] pub fn json( engine: &mut Engine, - /// Path to a JSON file or raw JSON bytes. - /// - /// For more details about paths, see the [Paths section]($syntax/#paths). + /// A [path]($syntax/#paths) to a JSON file or raw JSON bytes. source: Spanned, ) -> SourceResult { let data = source.load(engine.world)?; diff --git a/crates/typst-library/src/loading/toml.rs b/crates/typst-library/src/loading/toml.rs index 2660e7e7f2..f04b2e746d 100644 --- a/crates/typst-library/src/loading/toml.rs +++ b/crates/typst-library/src/loading/toml.rs @@ -29,9 +29,7 @@ use crate::loading::{DataSource, Load, Readable}; #[func(scope, title = "TOML")] pub fn toml( engine: &mut Engine, - /// A path to a TOML file or raw TOML bytes. - /// - /// For more details about paths, see the [Paths section]($syntax/#paths). + /// A [path]($syntax/#paths) to a TOML file or raw TOML bytes. source: Spanned, ) -> SourceResult { let data = source.load(engine.world)?; diff --git a/crates/typst-library/src/loading/xml.rs b/crates/typst-library/src/loading/xml.rs index 32ed6f24b7..daccd02fc8 100644 --- a/crates/typst-library/src/loading/xml.rs +++ b/crates/typst-library/src/loading/xml.rs @@ -58,9 +58,7 @@ use crate::loading::{DataSource, Load, Readable}; #[func(scope, title = "XML")] pub fn xml( engine: &mut Engine, - /// A path to an XML file or raw XML bytes. - /// - /// For more details about paths, see the [Paths section]($syntax/#paths). + /// A [path]($syntax/#paths) to an XML file or raw XML bytes. source: Spanned, ) -> SourceResult { let data = source.load(engine.world)?; diff --git a/crates/typst-library/src/loading/yaml.rs b/crates/typst-library/src/loading/yaml.rs index 4eeec28f1f..3f48113e8e 100644 --- a/crates/typst-library/src/loading/yaml.rs +++ b/crates/typst-library/src/loading/yaml.rs @@ -41,9 +41,7 @@ use crate::loading::{DataSource, Load, Readable}; #[func(scope, title = "YAML")] pub fn yaml( engine: &mut Engine, - /// A path to a YAML file or raw YAML bytes. - /// - /// For more details about paths, see the [Paths section]($syntax/#paths). + /// A [path]($syntax/#paths) to a YAML file or raw YAML bytes. source: Spanned, ) -> SourceResult { let data = source.load(engine.world)?; diff --git a/crates/typst-library/src/model/outline.rs b/crates/typst-library/src/model/outline.rs index f413189bad..7ceb530f83 100644 --- a/crates/typst-library/src/model/outline.rs +++ b/crates/typst-library/src/model/outline.rs @@ -623,7 +623,7 @@ impl OutlineEntry { /// The content which is displayed in place of the referred element at its /// entry in the outline. For a heading, this is its - /// [`body`]($heading.body), for a figure a caption, and for equations it is + /// [`body`]($heading.body); for a figure a caption and for equations, it is /// empty. #[func] pub fn body(&self) -> StrResult { diff --git a/crates/typst-library/src/pdf/embed.rs b/crates/typst-library/src/pdf/embed.rs index f9ca3ca092..001078e5e3 100644 --- a/crates/typst-library/src/pdf/embed.rs +++ b/crates/typst-library/src/pdf/embed.rs @@ -32,12 +32,10 @@ use crate::World; /// embedded file conforms to PDF/A-1 or PDF/A-2. #[elem(Show, Locatable)] pub struct EmbedElem { - /// Path of the file to be embedded. + /// The [path]($syntax/#paths) of the file to be embedded. /// /// Must always be specified, but is only read from if no data is provided /// in the following argument. - /// - /// For more details about paths, see the [Paths section]($syntax/#paths). #[required] #[parse( let Spanned { v: path, span } = diff --git a/crates/typst-library/src/visualize/curve.rs b/crates/typst-library/src/visualize/curve.rs index 607d92ab15..fb5151e8fa 100644 --- a/crates/typst-library/src/visualize/curve.rs +++ b/crates/typst-library/src/visualize/curve.rs @@ -10,12 +10,12 @@ use crate::foundations::{ use crate::layout::{Abs, Axes, BlockElem, Length, Point, Rel, Size}; use crate::visualize::{FillRule, Paint, Stroke}; -/// A curve consisting of movements, lines, and Beziér segments. +/// A curve consisting of movements, lines, and Bézier segments. /// /// At any point in time, there is a conceptual pen or cursor. /// - Move elements move the cursor without drawing. /// - Line/Quadratic/Cubic elements draw a segment from the cursor to a new -/// position, potentially with control point for a Beziér curve. +/// position, potentially with control point for a Bézier curve. /// - Close elements draw a straight or smooth line back to the start of the /// curve or the latest preceding move segment. /// @@ -26,7 +26,7 @@ use crate::visualize::{FillRule, Paint, Stroke}; /// or relative to the current pen/cursor position, that is, the position where /// the previous segment ended. /// -/// Beziér curve control points can be skipped by passing `{none}` or +/// Bézier curve control points can be skipped by passing `{none}` or /// automatically mirrored from the preceding segment by passing `{auto}`. /// /// # Example @@ -88,7 +88,7 @@ pub struct CurveElem { #[fold] pub stroke: Smart>, - /// The components of the curve, in the form of moves, line and Beziér + /// The components of the curve, in the form of moves, line and Bézier /// segment, and closes. #[variadic] pub components: Vec, @@ -225,7 +225,7 @@ pub struct CurveLine { pub relative: bool, } -/// Adds a quadratic Beziér curve segment from the last point to `end`, using +/// Adds a quadratic Bézier curve segment from the last point to `end`, using /// `control` as the control point. /// /// ```example @@ -245,9 +245,9 @@ pub struct CurveLine { /// ``` #[elem(name = "quad", title = "Curve Quadratic Segment")] pub struct CurveQuad { - /// The control point of the quadratic Beziér curve. + /// The control point of the quadratic Bézier curve. /// - /// - If `{auto}` and this segment follows another quadratic Beziér curve, + /// - If `{auto}` and this segment follows another quadratic Bézier curve, /// the previous control point will be mirrored. /// - If `{none}`, the control point defaults to `end`, and the curve will /// be a straight line. @@ -272,7 +272,7 @@ pub struct CurveQuad { pub relative: bool, } -/// Adds a cubic Beziér curve segment from the last point to `end`, using +/// Adds a cubic Bézier curve segment from the last point to `end`, using /// `control-start` and `control-end` as the control points. /// /// ```example @@ -388,7 +388,7 @@ pub enum CloseMode { Straight, } -/// A curve consisting of movements, lines, and Beziér segments. +/// A curve consisting of movements, lines, and Bézier segments. #[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] pub struct Curve(pub Vec); diff --git a/crates/typst-library/src/visualize/image/mod.rs b/crates/typst-library/src/visualize/image/mod.rs index 18d40caa86..97189e22d1 100644 --- a/crates/typst-library/src/visualize/image/mod.rs +++ b/crates/typst-library/src/visualize/image/mod.rs @@ -46,10 +46,11 @@ use crate::text::LocalName; /// ``` #[elem(scope, Show, LocalName, Figurable)] pub struct ImageElem { - /// A path to an image file or raw bytes making up an image in one of the - /// supported [formats]($image.format). + /// A [path]($syntax/#paths) to an image file or raw bytes making up an + /// image in one of the supported [formats]($image.format). /// - /// For more details about paths, see the [Paths section]($syntax/#paths). + /// Bytes can be used to specify raw pixel data in a row-major, + /// left-to-right, top-to-bottom format. /// /// ```example /// #let original = read("diagram.svg") diff --git a/crates/typst-library/src/visualize/path.rs b/crates/typst-library/src/visualize/path.rs index c1cfde94aa..968146cdab 100644 --- a/crates/typst-library/src/visualize/path.rs +++ b/crates/typst-library/src/visualize/path.rs @@ -8,7 +8,7 @@ use crate::foundations::{ use crate::layout::{Axes, BlockElem, Length, Rel}; use crate::visualize::{FillRule, Paint, Stroke}; -/// A path through a list of points, connected by Bezier curves. +/// A path through a list of points, connected by Bézier curves. /// /// # Example /// ```example @@ -59,8 +59,8 @@ pub struct PathElem { #[fold] pub stroke: Smart>, - /// Whether to close this path with one last bezier curve. This curve will - /// takes into account the adjacent control points. If you want to close + /// Whether to close this path with one last Bézier curve. This curve will + /// take into account the adjacent control points. If you want to close /// with a straight line, simply add one last point that's the same as the /// start point. #[default(false)] diff --git a/crates/typst-library/src/visualize/shape.rs b/crates/typst-library/src/visualize/shape.rs index 3c62b210f3..439b4cd988 100644 --- a/crates/typst-library/src/visualize/shape.rs +++ b/crates/typst-library/src/visualize/shape.rs @@ -412,7 +412,7 @@ pub enum Geometry { Line(Point), /// A rectangle with its origin in the topleft corner. Rect(Size), - /// A curve consisting of movements, lines, and Bezier segments. + /// A curve consisting of movements, lines, and Bézier segments. Curve(Curve), } diff --git a/docs/changelog/0.13.0.md b/docs/changelog/0.13.0.md index 50819f6592..4e4dd0c2df 100644 --- a/docs/changelog/0.13.0.md +++ b/docs/changelog/0.13.0.md @@ -16,7 +16,7 @@ description: Changes slated to appear in Typst 0.13.0 - The `image` function now supports raw [pixel raster formats]($image.format) for generating images from within Typst - Functions that accept [file paths]($syntax/#paths) now also accept raw - [bytes] instead, for full flexibility + [bytes], for full flexibility - WebAssembly [plugins]($plugin) are more flexible and automatically run multi-threaded - Fixed a long-standing bug where single-letter strings in math (`[$"a"$]`) @@ -155,7 +155,7 @@ description: Changes slated to appear in Typst 0.13.0 - Fixed multi-line annotations (e.g. overbrace) changing the math baseline - Fixed merging of attachments when the base is a nested equation - Fixed resolving of contextual (em-based) text sizes within math -- Fixed spacing around ⊥ +- Fixed spacing around up tacks (⊥) ## Bibliography - Prose and author-only citations now use editor names if the author names are @@ -229,7 +229,7 @@ description: Changes slated to appear in Typst 0.13.0 - A shebang `#!` at the very start of a file is now ignored ## PDF export -- Added `pdf.embed` function for embedding arbitrary files in the exported PDF +- Added [`pdf.embed`] function for embedding arbitrary files in the exported PDF - Added support for PDF/A-3b export - The PDF timestamp will now contain the timezone by default diff --git a/docs/reference/export/png.md b/docs/reference/export/png.md index fe122f4d31..0e817e0f1e 100644 --- a/docs/reference/export/png.md +++ b/docs/reference/export/png.md @@ -11,7 +11,7 @@ the PNG you exported, you will notice a loss of quality. Typst calculates the resolution of your PNGs based on each page's physical dimensions and the PPI. If you need guidance for choosing a PPI value, consider the following: -- A DPI value of 300 or 600 is typical for desktop printing. +- A value of 300 or 600 is typical for desktop printing. - Professional prints of detailed graphics can go up to 1200 PPI. - If your document is only viewed at a distance, e.g. a poster, you may choose a smaller value than 300. From d61f57365b931e7cd57ed0a88b21c79f3042e3f5 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Thu, 6 Feb 2025 11:18:35 +0100 Subject: [PATCH 003/558] Fix docs outline for nested definitions (#5823) --- docs/src/lib.rs | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/docs/src/lib.rs b/docs/src/lib.rs index fae74e0fcf..e9771738d7 100644 --- a/docs/src/lib.rs +++ b/docs/src/lib.rs @@ -550,8 +550,6 @@ fn func_outline(model: &FuncModel, id_base: &str) -> Vec { .collect(), }); } - - outline.extend(scope_outline(&model.scope)); } else { outline.extend(model.params.iter().map(|param| OutlineItem { id: eco_format!("{id_base}-{}", urlify(param.name)), @@ -560,27 +558,30 @@ fn func_outline(model: &FuncModel, id_base: &str) -> Vec { })); } + outline.extend(scope_outline(&model.scope, id_base)); + outline } /// Produce an outline for a function scope. -fn scope_outline(scope: &[FuncModel]) -> Option { +fn scope_outline(scope: &[FuncModel], id_base: &str) -> Option { if scope.is_empty() { return None; } - Some(OutlineItem { - id: "definitions".into(), - name: "Definitions".into(), - children: scope - .iter() - .map(|func| { - let id = urlify(&eco_format!("definitions-{}", func.name)); - let children = func_outline(func, &id); - OutlineItem { id, name: func.title.into(), children } - }) - .collect(), - }) + let dash = if id_base.is_empty() { "" } else { "-" }; + let id = eco_format!("{id_base}{dash}definitions"); + + let children = scope + .iter() + .map(|func| { + let id = urlify(&eco_format!("{id}-{}", func.name)); + let children = func_outline(func, &id); + OutlineItem { id, name: func.title.into(), children } + }) + .collect(); + + Some(OutlineItem { id, name: "Definitions".into(), children }) } /// Create a page for a group of functions. @@ -687,7 +688,7 @@ fn type_outline(model: &TypeModel) -> Vec { }); } - outline.extend(scope_outline(&model.scope)); + outline.extend(scope_outline(&model.scope, "")); outline } From a1c73b41b862eb95f609f18ee99bdb6da039f478 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Thu, 6 Feb 2025 21:57:46 +0100 Subject: [PATCH 004/558] Document removals in changelog (#5827) --- docs/changelog/0.13.0.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/changelog/0.13.0.md b/docs/changelog/0.13.0.md index 4e4dd0c2df..2caace7239 100644 --- a/docs/changelog/0.13.0.md +++ b/docs/changelog/0.13.0.md @@ -45,6 +45,7 @@ description: Changes slated to appear in Typst 0.13.0 result in a warning - The default show rules of various built-in elements like lists, quotes, etc. were adjusted to ensure they produce/don't produce paragraphs as appropriate + - Removed support for booleans and content in [`outline.indent`] - The [`outline`] function was fully reworked to improve its out-of-the-box behavior **(Breaking change)** - [Outline entries]($outline.entry) are now [blocks]($block) and are thus @@ -312,8 +313,20 @@ feature flag. functions directly accepting both paths and bytes - The `sect` and its variants in favor of `inter`, and `integral.sect` in favor of `integral.inter` -- Fully removed type/str compatibility behavior (e.g. `{int == "integer"}`) - which was temporarily introduced in Typst 0.8 **(Breaking change)** + +## Removals +- Removed `style` function and `styles` argument of [`measure`], use a [context] + expression instead **(Breaking change)** +- Removed `state.display` function, use [`state.get`] instead + **(Breaking change)** +- Removed `location` argument of [`state.at`], [`counter.at`], and [`query`] + **(Breaking change)** +- Removed compatibility behavior where [`counter.display`] worked without + [context] **(Breaking change)** +- Removed compatibility behavior of [`locate`] **(Breaking change)** +- Removed compatibility behavior of type/str comparisons + (e.g. `{int == "integer"}`) which was temporarily introduced in Typst 0.8 + **(Breaking change)** ## Development - The `typst::compile` function is now generic and can return either a From e4f8e57c534db8a31d51e0342c46b913a7e22422 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Thu, 6 Feb 2025 22:10:43 +0100 Subject: [PATCH 005/558] Fix unnecessary import rename warning (#5828) --- crates/typst-eval/src/import.rs | 6 +++--- tests/suite/scripting/import.typ | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/typst-eval/src/import.rs b/crates/typst-eval/src/import.rs index 27b06af416..1b16414875 100644 --- a/crates/typst-eval/src/import.rs +++ b/crates/typst-eval/src/import.rs @@ -44,11 +44,10 @@ impl Eval for ast::ModuleImport<'_> { } // If there is a rename, import the source itself under that name. - let bare_name = self.bare_name(); let new_name = self.new_name(); if let Some(new_name) = new_name { - if let Ok(source_name) = &bare_name { - if source_name == new_name.as_str() { + if let ast::Expr::Ident(ident) = self.source() { + if ident.as_str() == new_name.as_str() { // Warn on `import x as x` vm.engine.sink.warn(warning!( new_name.span(), @@ -57,6 +56,7 @@ impl Eval for ast::ModuleImport<'_> { } } + // Define renamed module on the scope. vm.define(new_name, source.clone()); } diff --git a/tests/suite/scripting/import.typ b/tests/suite/scripting/import.typ index 03e2efc6ba..49b66ee565 100644 --- a/tests/suite/scripting/import.typ +++ b/tests/suite/scripting/import.typ @@ -255,6 +255,10 @@ // Warning: 17-21 unnecessary import rename to same name #import enum as enum +--- import-rename-necessary --- +#import "module.typ" as module: a +#test(module.a, a) + --- import-rename-unnecessary-mixed --- // Warning: 17-21 unnecessary import rename to same name #import enum as enum: item @@ -263,10 +267,6 @@ // Warning: 31-35 unnecessary import rename to same name #import enum as enum: item as item ---- import-item-rename-unnecessary-string --- -// Warning: 25-31 unnecessary import rename to same name -#import "module.typ" as module - --- import-item-rename-unnecessary-but-ok --- #import "modul" + "e.typ" as module #test(module.b, 1) From 3fba256405c4aae9f121a07ddaa29cc10b825fc9 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Mon, 10 Feb 2025 07:39:04 -0300 Subject: [PATCH 006/558] Don't crash on image with zero DPI (#5835) --- crates/typst-layout/src/image.rs | 2 ++ crates/typst-library/src/visualize/image/raster.rs | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/crates/typst-layout/src/image.rs b/crates/typst-layout/src/image.rs index d963ea50d4..3e5b7d8bd0 100644 --- a/crates/typst-layout/src/image.rs +++ b/crates/typst-layout/src/image.rs @@ -95,6 +95,8 @@ pub fn layout_image( } else { // If neither is forced, take the natural image size at the image's // DPI bounded by the available space. + // + // Division by DPI is fine since it's guaranteed to be positive. let dpi = image.dpi().unwrap_or(Image::DEFAULT_DPI); let natural = Axes::new(pxw, pxh).map(|v| Abs::inches(v / dpi)); Size::new( diff --git a/crates/typst-library/src/visualize/image/raster.rs b/crates/typst-library/src/visualize/image/raster.rs index d43b154865..0883fe71d8 100644 --- a/crates/typst-library/src/visualize/image/raster.rs +++ b/crates/typst-library/src/visualize/image/raster.rs @@ -160,6 +160,8 @@ impl RasterImage { } /// The image's pixel density in pixels per inch, if known. + /// + /// This is guaranteed to be positive. pub fn dpi(&self) -> Option { self.0.dpi } @@ -334,6 +336,9 @@ fn apply_rotation(image: &mut DynamicImage, rotation: u32) { } /// Try to determine the DPI (dots per inch) of the image. +/// +/// This is guaranteed to be a positive value, or `None` if invalid or +/// unspecified. fn determine_dpi(data: &[u8], exif: Option<&exif::Exif>) -> Option { // Try to extract the DPI from the EXIF metadata. If that doesn't yield // anything, fall back to specialized procedures for extracting JPEG or PNG @@ -341,6 +346,7 @@ fn determine_dpi(data: &[u8], exif: Option<&exif::Exif>) -> Option { exif.and_then(exif_dpi) .or_else(|| jpeg_dpi(data)) .or_else(|| png_dpi(data)) + .filter(|&dpi| dpi > 0.0) } /// Try to get the DPI from the EXIF metadata. From 25e27169e1413c9e14184267be57fdbbb09e7c34 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Mon, 10 Feb 2025 11:39:32 +0100 Subject: [PATCH 007/558] Add warning for `pdf.embed` elem used with HTML (#5829) --- crates/typst-library/src/pdf/embed.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/typst-library/src/pdf/embed.rs b/crates/typst-library/src/pdf/embed.rs index 001078e5e3..f902e7f14a 100644 --- a/crates/typst-library/src/pdf/embed.rs +++ b/crates/typst-library/src/pdf/embed.rs @@ -1,9 +1,12 @@ use ecow::EcoString; +use typst_library::foundations::Target; use typst_syntax::Spanned; -use crate::diag::{At, SourceResult}; +use crate::diag::{warning, At, SourceResult}; use crate::engine::Engine; -use crate::foundations::{elem, Bytes, Cast, Content, Derived, Packed, Show, StyleChain}; +use crate::foundations::{ + elem, Bytes, Cast, Content, Derived, Packed, Show, StyleChain, TargetElem, +}; use crate::introspection::Locatable; use crate::World; @@ -78,7 +81,12 @@ pub struct EmbedElem { } impl Show for Packed { - fn show(&self, _: &mut Engine, _: StyleChain) -> SourceResult { + fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult { + if TargetElem::target_in(styles) == Target::Html { + engine + .sink + .warn(warning!(self.span(), "embed was ignored during HTML export")); + } Ok(Content::empty()) } } From ee47cb846924235be6eae968a7853ea7860ccc51 Mon Sep 17 00:00:00 2001 From: TwoF1nger <140991913+TwoF1nger@users.noreply.github.com> Date: Mon, 10 Feb 2025 10:42:16 +0000 Subject: [PATCH 008/558] Add smart quotes for Bulgarian (#5807) --- crates/typst-library/src/text/smartquote.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/typst-library/src/text/smartquote.rs b/crates/typst-library/src/text/smartquote.rs index 2f89fe298e..f457a63711 100644 --- a/crates/typst-library/src/text/smartquote.rs +++ b/crates/typst-library/src/text/smartquote.rs @@ -251,6 +251,7 @@ impl<'s> SmartQuotes<'s> { "el" => ("‘", "’", "«", "»"), "he" => ("’", "’", "”", "”"), "hr" => ("‘", "’", "„", "”"), + "bg" => ("’", "’", "„", "“"), _ if lang.dir() == Dir::RTL => ("’", "‘", "”", "“"), _ => default, }; From 89e71acecd4a3a06943d0bd4443fc80a9b8f41e4 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 10 Feb 2025 15:37:19 +0100 Subject: [PATCH 009/558] Respect `par` constructor arguments (#5842) --- crates/typst-layout/src/flow/collect.rs | 13 +- crates/typst-layout/src/flow/mod.rs | 77 ++++--- crates/typst-layout/src/inline/collect.rs | 54 +---- crates/typst-layout/src/inline/finalize.rs | 10 +- crates/typst-layout/src/inline/line.rs | 51 +++-- crates/typst-layout/src/inline/linebreak.rs | 44 ++-- crates/typst-layout/src/inline/mod.rs | 191 +++++++++++++++++- crates/typst-layout/src/inline/prepare.rs | 73 +------ crates/typst-layout/src/math/text.rs | 1 - crates/typst-library/src/model/link.rs | 4 +- crates/typst-library/src/text/mod.rs | 25 +-- crates/typst-library/src/text/raw.rs | 6 +- tests/ref/issue-5831-par-constructor-args.png | Bin 0 -> 1356 bytes tests/suite/model/par.typ | 14 ++ 14 files changed, 314 insertions(+), 249 deletions(-) create mode 100644 tests/ref/issue-5831-par-constructor-args.png diff --git a/crates/typst-layout/src/flow/collect.rs b/crates/typst-layout/src/flow/collect.rs index 34362a6c58..2c14f7a37f 100644 --- a/crates/typst-layout/src/flow/collect.rs +++ b/crates/typst-layout/src/flow/collect.rs @@ -124,7 +124,6 @@ impl<'a> Collector<'a, '_, '_> { styles, self.base, self.expand, - None, )? .into_frames(); @@ -133,7 +132,8 @@ impl<'a> Collector<'a, '_, '_> { self.output.push(Child::Tag(&elem.tag)); } - self.lines(lines, styles); + let leading = ParElem::leading_in(styles); + self.lines(lines, leading, styles); for (c, _) in &self.children[end..] { let elem = c.to_packed::().unwrap(); @@ -169,10 +169,12 @@ impl<'a> Collector<'a, '_, '_> { )? .into_frames(); - let spacing = ParElem::spacing_in(styles); + let spacing = elem.spacing(styles); + let leading = elem.leading(styles); + self.output.push(Child::Rel(spacing.into(), 4)); - self.lines(lines, styles); + self.lines(lines, leading, styles); self.output.push(Child::Rel(spacing.into(), 4)); self.par_situation = ParSituation::Consecutive; @@ -181,9 +183,8 @@ impl<'a> Collector<'a, '_, '_> { } /// Collect laid-out lines. - fn lines(&mut self, lines: Vec, styles: StyleChain<'a>) { + fn lines(&mut self, lines: Vec, leading: Abs, styles: StyleChain<'a>) { let align = AlignElem::alignment_in(styles).resolve(styles); - let leading = ParElem::leading_in(styles); let costs = TextElem::costs_in(styles); // Determine whether to prevent widow and orphans. diff --git a/crates/typst-layout/src/flow/mod.rs b/crates/typst-layout/src/flow/mod.rs index 2acbbcef3f..cba228bcd9 100644 --- a/crates/typst-layout/src/flow/mod.rs +++ b/crates/typst-layout/src/flow/mod.rs @@ -197,7 +197,50 @@ pub fn layout_flow<'a>( mode: FlowMode, ) -> SourceResult { // Prepare configuration that is shared across the whole flow. - let config = Config { + let config = configuration(shared, regions, columns, column_gutter, mode); + + // Collect the elements into pre-processed children. These are much easier + // to handle than the raw elements. + let bump = Bump::new(); + let children = collect( + engine, + &bump, + children, + locator.next(&()), + Size::new(config.columns.width, regions.full), + regions.expand.x, + mode, + )?; + + let mut work = Work::new(&children); + let mut finished = vec![]; + + // This loop runs once per region produced by the flow layout. + loop { + let frame = compose(engine, &mut work, &config, locator.next(&()), regions)?; + finished.push(frame); + + // Terminate the loop when everything is processed, though draining the + // backlog if necessary. + if work.done() && (!regions.expand.y || regions.backlog.is_empty()) { + break; + } + + regions.next(); + } + + Ok(Fragment::frames(finished)) +} + +/// Determine the flow's configuration. +fn configuration<'x>( + shared: StyleChain<'x>, + regions: Regions, + columns: NonZeroUsize, + column_gutter: Rel, + mode: FlowMode, +) -> Config<'x> { + Config { mode, shared, columns: { @@ -235,39 +278,7 @@ pub fn layout_flow<'a>( ) }, }), - }; - - // Collect the elements into pre-processed children. These are much easier - // to handle than the raw elements. - let bump = Bump::new(); - let children = collect( - engine, - &bump, - children, - locator.next(&()), - Size::new(config.columns.width, regions.full), - regions.expand.x, - mode, - )?; - - let mut work = Work::new(&children); - let mut finished = vec![]; - - // This loop runs once per region produced by the flow layout. - loop { - let frame = compose(engine, &mut work, &config, locator.next(&()), regions)?; - finished.push(frame); - - // Terminate the loop when everything is processed, though draining the - // backlog if necessary. - if work.done() && (!regions.expand.y || regions.backlog.is_empty()) { - break; - } - - regions.next(); } - - Ok(Fragment::frames(finished)) } /// The work that is left to do by flow layout. diff --git a/crates/typst-layout/src/inline/collect.rs b/crates/typst-layout/src/inline/collect.rs index 14cf2e3b83..5a1b7b4fc4 100644 --- a/crates/typst-layout/src/inline/collect.rs +++ b/crates/typst-layout/src/inline/collect.rs @@ -2,10 +2,8 @@ use typst_library::diag::warning; use typst_library::foundations::{Packed, Resolve}; use typst_library::introspection::{SplitLocator, Tag, TagElem}; use typst_library::layout::{ - Abs, AlignElem, BoxElem, Dir, Fr, Frame, HElem, InlineElem, InlineItem, Sizing, - Spacing, + Abs, BoxElem, Dir, Fr, Frame, HElem, InlineElem, InlineItem, Sizing, Spacing, }; -use typst_library::model::{EnumElem, ListElem, TermsElem}; use typst_library::routines::Pair; use typst_library::text::{ is_default_ignorable, LinebreakElem, SmartQuoteElem, SmartQuoter, SmartQuotes, @@ -123,40 +121,20 @@ pub fn collect<'a>( children: &[Pair<'a>], engine: &mut Engine<'_>, locator: &mut SplitLocator<'a>, - styles: StyleChain<'a>, + config: &Config, region: Size, - situation: Option, ) -> SourceResult<(String, Vec>, SpanMapper)> { let mut collector = Collector::new(2 + children.len()); let mut quoter = SmartQuoter::new(); - let outer_dir = TextElem::dir_in(styles); - - if let Some(situation) = situation { - let first_line_indent = ParElem::first_line_indent_in(styles); - if !first_line_indent.amount.is_zero() - && match situation { - // First-line indent for the first paragraph after a list bullet - // just looks bad. - ParSituation::First => first_line_indent.all && !in_list(styles), - ParSituation::Consecutive => true, - ParSituation::Other => first_line_indent.all, - } - && AlignElem::alignment_in(styles).resolve(styles).x - == outer_dir.start().into() - { - collector.push_item(Item::Absolute( - first_line_indent.amount.resolve(styles), - false, - )); - collector.spans.push(1, Span::detached()); - } + if !config.first_line_indent.is_zero() { + collector.push_item(Item::Absolute(config.first_line_indent, false)); + collector.spans.push(1, Span::detached()); + } - let hang = ParElem::hanging_indent_in(styles); - if !hang.is_zero() { - collector.push_item(Item::Absolute(-hang, false)); - collector.spans.push(1, Span::detached()); - } + if !config.hanging_indent.is_zero() { + collector.push_item(Item::Absolute(-config.hanging_indent, false)); + collector.spans.push(1, Span::detached()); } for &(child, styles) in children { @@ -167,7 +145,7 @@ pub fn collect<'a>( } else if let Some(elem) = child.to_packed::() { collector.build_text(styles, |full| { let dir = TextElem::dir_in(styles); - if dir != outer_dir { + if dir != config.dir { // Insert "Explicit Directional Embedding". match dir { Dir::LTR => full.push_str(LTR_EMBEDDING), @@ -182,7 +160,7 @@ pub fn collect<'a>( full.push_str(&elem.text); } - if dir != outer_dir { + if dir != config.dir { // Insert "Pop Directional Formatting". full.push_str(POP_EMBEDDING); } @@ -265,16 +243,6 @@ pub fn collect<'a>( Ok((collector.full, collector.segments, collector.spans)) } -/// Whether we have a list ancestor. -/// -/// When we support some kind of more general ancestry mechanism, this can -/// become more elegant. -fn in_list(styles: StyleChain) -> bool { - ListElem::depth_in(styles).0 > 0 - || !EnumElem::parents_in(styles).is_empty() - || TermsElem::within_in(styles) -} - /// Collects segments. struct Collector<'a> { full: String, diff --git a/crates/typst-layout/src/inline/finalize.rs b/crates/typst-layout/src/inline/finalize.rs index 7ad287c45d..c9de0085ef 100644 --- a/crates/typst-layout/src/inline/finalize.rs +++ b/crates/typst-layout/src/inline/finalize.rs @@ -9,7 +9,6 @@ pub fn finalize( engine: &mut Engine, p: &Preparation, lines: &[Line], - styles: StyleChain, region: Size, expand: bool, locator: &mut SplitLocator<'_>, @@ -19,9 +18,10 @@ pub fn finalize( let width = if !region.x.is_finite() || (!expand && lines.iter().all(|line| line.fr().is_zero())) { - region - .x - .min(p.hang + lines.iter().map(|line| line.width).max().unwrap_or_default()) + region.x.min( + p.config.hanging_indent + + lines.iter().map(|line| line.width).max().unwrap_or_default(), + ) } else { region.x }; @@ -29,7 +29,7 @@ pub fn finalize( // Stack the lines into one frame per region. lines .iter() - .map(|line| commit(engine, p, line, width, region.y, locator, styles)) + .map(|line| commit(engine, p, line, width, region.y, locator)) .collect::>() .map(Fragment::frames) } diff --git a/crates/typst-layout/src/inline/line.rs b/crates/typst-layout/src/inline/line.rs index 9f6973807e..bd08f30efd 100644 --- a/crates/typst-layout/src/inline/line.rs +++ b/crates/typst-layout/src/inline/line.rs @@ -2,10 +2,9 @@ use std::fmt::{self, Debug, Formatter}; use std::ops::{Deref, DerefMut}; use typst_library::engine::Engine; -use typst_library::foundations::NativeElement; use typst_library::introspection::{SplitLocator, Tag}; use typst_library::layout::{Abs, Dir, Em, Fr, Frame, FrameItem, Point}; -use typst_library::model::{ParLine, ParLineMarker}; +use typst_library::model::ParLineMarker; use typst_library::text::{Lang, TextElem}; use typst_utils::Numeric; @@ -135,7 +134,7 @@ pub fn line<'a>( // Whether the line is justified. let justify = full.ends_with(LINE_SEPARATOR) - || (p.justify && breakpoint != Breakpoint::Mandatory); + || (p.config.justify && breakpoint != Breakpoint::Mandatory); // Process dashes. let dash = if breakpoint.is_hyphen() || full.ends_with(SHY) { @@ -157,14 +156,14 @@ pub fn line<'a>( // Add a hyphen at the line start, if a previous dash should be repeated. if pred.map_or(false, |pred| should_repeat_hyphen(pred, full)) { if let Some(shaped) = items.first_text_mut() { - shaped.prepend_hyphen(engine, p.fallback); + shaped.prepend_hyphen(engine, p.config.fallback); } } // Add a hyphen at the line end, if we ended on a soft hyphen. if dash == Some(Dash::Soft) { if let Some(shaped) = items.last_text_mut() { - shaped.push_hyphen(engine, p.fallback); + shaped.push_hyphen(engine, p.config.fallback); } } @@ -234,13 +233,13 @@ where { // If there is nothing bidirectional going on, skip reordering. let Some(bidi) = &p.bidi else { - f(range, p.dir == Dir::RTL); + f(range, p.config.dir == Dir::RTL); return; }; // The bidi crate panics for empty lines. if range.is_empty() { - f(range, p.dir == Dir::RTL); + f(range, p.config.dir == Dir::RTL); return; } @@ -308,13 +307,13 @@ fn collect_range<'a>( /// punctuation marks at line start or line end. fn adjust_cj_at_line_boundaries(p: &Preparation, text: &str, items: &mut Items) { if text.starts_with(BEGIN_PUNCT_PAT) - || (p.cjk_latin_spacing && text.starts_with(is_of_cj_script)) + || (p.config.cjk_latin_spacing && text.starts_with(is_of_cj_script)) { adjust_cj_at_line_start(p, items); } if text.ends_with(END_PUNCT_PAT) - || (p.cjk_latin_spacing && text.ends_with(is_of_cj_script)) + || (p.config.cjk_latin_spacing && text.ends_with(is_of_cj_script)) { adjust_cj_at_line_end(p, items); } @@ -332,7 +331,10 @@ fn adjust_cj_at_line_start(p: &Preparation, items: &mut Items) { let shrink = glyph.shrinkability().0; glyph.shrink_left(shrink); shaped.width -= shrink.at(shaped.size); - } else if p.cjk_latin_spacing && glyph.is_cj_script() && glyph.x_offset > Em::zero() { + } else if p.config.cjk_latin_spacing + && glyph.is_cj_script() + && glyph.x_offset > Em::zero() + { // If the first glyph is a CJK character adjusted by // [`add_cjk_latin_spacing`], restore the original width. let glyph = shaped.glyphs.to_mut().first_mut().unwrap(); @@ -359,7 +361,7 @@ fn adjust_cj_at_line_end(p: &Preparation, items: &mut Items) { let punct = shaped.glyphs.to_mut().last_mut().unwrap(); punct.shrink_right(shrink); shaped.width -= shrink.at(shaped.size); - } else if p.cjk_latin_spacing + } else if p.config.cjk_latin_spacing && glyph.is_cj_script() && (glyph.x_advance - glyph.x_offset) > Em::one() { @@ -424,16 +426,15 @@ pub fn commit( width: Abs, full: Abs, locator: &mut SplitLocator<'_>, - styles: StyleChain, ) -> SourceResult { - let mut remaining = width - line.width - p.hang; + let mut remaining = width - line.width - p.config.hanging_indent; let mut offset = Abs::zero(); // We always build the line from left to right. In an LTR paragraph, we must // thus add the hanging indent to the offset. In an RTL paragraph, the // hanging indent arises naturally due to the line width. - if p.dir == Dir::LTR { - offset += p.hang; + if p.config.dir == Dir::LTR { + offset += p.config.hanging_indent; } // Handle hanging punctuation to the left. @@ -554,11 +555,13 @@ pub fn commit( let mut output = Frame::soft(size); output.set_baseline(top); - add_par_line_marker(&mut output, styles, engine, locator, top); + if let Some(marker) = &p.config.numbering_marker { + add_par_line_marker(&mut output, marker, engine, locator, top); + } // Construct the line's frame. for (offset, frame) in frames { - let x = offset + p.align.position(remaining); + let x = offset + p.config.align.position(remaining); let y = top - frame.baseline(); output.push_frame(Point::new(x, y), frame); } @@ -575,26 +578,18 @@ pub fn commit( /// number in the margin, is aligned to the line's baseline. fn add_par_line_marker( output: &mut Frame, - styles: StyleChain, + marker: &Packed, engine: &mut Engine, locator: &mut SplitLocator, top: Abs, ) { - let Some(numbering) = ParLine::numbering_in(styles) else { return }; - let margin = ParLine::number_margin_in(styles); - let align = ParLine::number_align_in(styles); - - // Delay resolving the number clearance until line numbers are laid out to - // avoid inconsistent spacing depending on varying font size. - let clearance = ParLine::number_clearance_in(styles); - // Elements in tags must have a location for introspection to work. We do // the work here instead of going through all of the realization process // just for this, given we don't need to actually place the marker as we // manually search for it in the frame later (when building a root flow, // where line numbers can be displayed), so we just need it to be in a tag // and to be valid (to have a location). - let mut marker = ParLineMarker::new(numbering, align, margin, clearance).pack(); + let mut marker = marker.clone(); let key = typst_utils::hash128(&marker); let loc = locator.next_location(engine.introspector, key); marker.set_location(loc); @@ -606,7 +601,7 @@ fn add_par_line_marker( // line's general baseline. However, the line number will still need to // manually adjust its own 'y' position based on its own baseline. let pos = Point::with_y(top); - output.push(pos, FrameItem::Tag(Tag::Start(marker))); + output.push(pos, FrameItem::Tag(Tag::Start(marker.pack()))); output.push(pos, FrameItem::Tag(Tag::End(loc, key))); } diff --git a/crates/typst-layout/src/inline/linebreak.rs b/crates/typst-layout/src/inline/linebreak.rs index 87113c689d..a9f21188b4 100644 --- a/crates/typst-layout/src/inline/linebreak.rs +++ b/crates/typst-layout/src/inline/linebreak.rs @@ -110,15 +110,7 @@ pub fn linebreak<'a>( p: &'a Preparation<'a>, width: Abs, ) -> Vec> { - let linebreaks = p.linebreaks.unwrap_or_else(|| { - if p.justify { - Linebreaks::Optimized - } else { - Linebreaks::Simple - } - }); - - match linebreaks { + match p.config.linebreaks { Linebreaks::Simple => linebreak_simple(engine, p, width), Linebreaks::Optimized => linebreak_optimized(engine, p, width), } @@ -384,7 +376,7 @@ fn linebreak_optimized_approximate( // Whether the line is justified. This is not 100% accurate w.r.t // to line()'s behaviour, but good enough. - let justify = p.justify && breakpoint != Breakpoint::Mandatory; + let justify = p.config.justify && breakpoint != Breakpoint::Mandatory; // We don't really know whether the line naturally ends with a dash // here, so we can miss that case, but it's ok, since all of this @@ -573,7 +565,7 @@ fn raw_ratio( // calculate the extra amount. Also, don't divide by zero. let extra_stretch = (delta - adjustability) / justifiables.max(1) as f64; // Normalize the amount by half the em size. - ratio = 1.0 + extra_stretch / (p.size / 2.0); + ratio = 1.0 + extra_stretch / (p.config.font_size / 2.0); } // The min value must be < MIN_RATIO, but how much smaller doesn't matter @@ -663,9 +655,9 @@ fn breakpoints(p: &Preparation, mut f: impl FnMut(usize, Breakpoint)) { return; } - let hyphenate = p.hyphenate != Some(false); + let hyphenate = p.config.hyphenate != Some(false); let lb = LINEBREAK_DATA.as_borrowed(); - let segmenter = match p.lang { + let segmenter = match p.config.lang { Some(Lang::CHINESE | Lang::JAPANESE) => &CJ_SEGMENTER, _ => &SEGMENTER, }; @@ -830,18 +822,18 @@ fn linebreak_link(link: &str, mut f: impl FnMut(usize)) { /// Whether hyphenation is enabled at the given offset. fn hyphenate_at(p: &Preparation, offset: usize) -> bool { - p.hyphenate - .or_else(|| { - let (_, item) = p.get(offset); - let styles = item.text()?.styles; - Some(TextElem::hyphenate_in(styles)) - }) - .unwrap_or(false) + p.config.hyphenate.unwrap_or_else(|| { + let (_, item) = p.get(offset); + match item.text() { + Some(text) => TextElem::hyphenate_in(text.styles).unwrap_or(p.config.justify), + None => false, + } + }) } /// The text language at the given offset. fn lang_at(p: &Preparation, offset: usize) -> Option { - let lang = p.lang.or_else(|| { + let lang = p.config.lang.or_else(|| { let (_, item) = p.get(offset); let styles = item.text()?.styles; Some(TextElem::lang_in(styles)) @@ -865,13 +857,13 @@ impl CostMetrics { fn compute(p: &Preparation) -> Self { Self { // When justifying, we may stretch spaces below their natural width. - min_ratio: if p.justify { MIN_RATIO } else { 0.0 }, - min_approx_ratio: if p.justify { MIN_APPROX_RATIO } else { 0.0 }, + min_ratio: if p.config.justify { MIN_RATIO } else { 0.0 }, + min_approx_ratio: if p.config.justify { MIN_APPROX_RATIO } else { 0.0 }, // Approximate hyphen width for estimates. - approx_hyphen_width: Em::new(0.33).at(p.size), + approx_hyphen_width: Em::new(0.33).at(p.config.font_size), // Costs. - hyph_cost: DEFAULT_HYPH_COST * p.costs.hyphenation().get(), - runt_cost: DEFAULT_RUNT_COST * p.costs.runt().get(), + hyph_cost: DEFAULT_HYPH_COST * p.config.costs.hyphenation().get(), + runt_cost: DEFAULT_RUNT_COST * p.config.costs.runt().get(), } } diff --git a/crates/typst-layout/src/inline/mod.rs b/crates/typst-layout/src/inline/mod.rs index f8a36368d2..5ef820d076 100644 --- a/crates/typst-layout/src/inline/mod.rs +++ b/crates/typst-layout/src/inline/mod.rs @@ -13,12 +13,17 @@ pub use self::box_::layout_box; use comemo::{Track, Tracked, TrackedMut}; use typst_library::diag::SourceResult; use typst_library::engine::{Engine, Route, Sink, Traced}; -use typst_library::foundations::{Packed, StyleChain}; +use typst_library::foundations::{Packed, Resolve, Smart, StyleChain}; use typst_library::introspection::{Introspector, Locator, LocatorLink, SplitLocator}; -use typst_library::layout::{Fragment, Size}; -use typst_library::model::ParElem; +use typst_library::layout::{Abs, AlignElem, Dir, FixedAlignment, Fragment, Size}; +use typst_library::model::{ + EnumElem, FirstLineIndent, Linebreaks, ListElem, ParElem, ParLine, ParLineMarker, + TermsElem, +}; use typst_library::routines::{Arenas, Pair, RealizationKind, Routines}; +use typst_library::text::{Costs, Lang, TextElem}; use typst_library::World; +use typst_utils::{Numeric, SliceExt}; use self::collect::{collect, Item, Segment, SpanMapper}; use self::deco::decorate; @@ -98,7 +103,7 @@ fn layout_par_impl( styles, )?; - layout_inline( + layout_inline_impl( &mut engine, &children, &mut locator, @@ -106,33 +111,134 @@ fn layout_par_impl( region, expand, Some(situation), + &ConfigBase { + justify: elem.justify(styles), + linebreaks: elem.linebreaks(styles), + first_line_indent: elem.first_line_indent(styles), + hanging_indent: elem.hanging_indent(styles), + }, ) } /// Lays out realized content with inline layout. -#[allow(clippy::too_many_arguments)] pub fn layout_inline<'a>( engine: &mut Engine, children: &[Pair<'a>], locator: &mut SplitLocator<'a>, - styles: StyleChain<'a>, + shared: StyleChain<'a>, + region: Size, + expand: bool, +) -> SourceResult { + layout_inline_impl( + engine, + children, + locator, + shared, + region, + expand, + None, + &ConfigBase { + justify: ParElem::justify_in(shared), + linebreaks: ParElem::linebreaks_in(shared), + first_line_indent: ParElem::first_line_indent_in(shared), + hanging_indent: ParElem::hanging_indent_in(shared), + }, + ) +} + +/// The internal implementation of [`layout_inline`]. +#[allow(clippy::too_many_arguments)] +fn layout_inline_impl<'a>( + engine: &mut Engine, + children: &[Pair<'a>], + locator: &mut SplitLocator<'a>, + shared: StyleChain<'a>, region: Size, expand: bool, par: Option, + base: &ConfigBase, ) -> SourceResult { + // Prepare configuration that is shared across the whole inline layout. + let config = configuration(base, children, shared, par); + // Collect all text into one string for BiDi analysis. - let (text, segments, spans) = - collect(children, engine, locator, styles, region, par)?; + let (text, segments, spans) = collect(children, engine, locator, &config, region)?; // Perform BiDi analysis and performs some preparation steps before we // proceed to line breaking. - let p = prepare(engine, children, &text, segments, spans, styles, par)?; + let p = prepare(engine, &config, &text, segments, spans)?; // Break the text into lines. - let lines = linebreak(engine, &p, region.x - p.hang); + let lines = linebreak(engine, &p, region.x - config.hanging_indent); // Turn the selected lines into frames. - finalize(engine, &p, &lines, styles, region, expand, locator) + finalize(engine, &p, &lines, region, expand, locator) +} + +/// Determine the inline layout's configuration. +fn configuration( + base: &ConfigBase, + children: &[Pair], + shared: StyleChain, + situation: Option, +) -> Config { + let justify = base.justify; + let font_size = TextElem::size_in(shared); + let dir = TextElem::dir_in(shared); + + Config { + justify, + linebreaks: base.linebreaks.unwrap_or_else(|| { + if justify { + Linebreaks::Optimized + } else { + Linebreaks::Simple + } + }), + first_line_indent: { + let FirstLineIndent { amount, all } = base.first_line_indent; + if !amount.is_zero() + && match situation { + // First-line indent for the first paragraph after a list + // bullet just looks bad. + Some(ParSituation::First) => all && !in_list(shared), + Some(ParSituation::Consecutive) => true, + Some(ParSituation::Other) => all, + None => false, + } + && AlignElem::alignment_in(shared).resolve(shared).x == dir.start().into() + { + amount.at(font_size) + } else { + Abs::zero() + } + }, + hanging_indent: if situation.is_some() { + base.hanging_indent + } else { + Abs::zero() + }, + numbering_marker: ParLine::numbering_in(shared).map(|numbering| { + Packed::new(ParLineMarker::new( + numbering, + ParLine::number_align_in(shared), + ParLine::number_margin_in(shared), + // Delay resolving the number clearance until line numbers are + // laid out to avoid inconsistent spacing depending on varying + // font size. + ParLine::number_clearance_in(shared), + )) + }), + align: AlignElem::alignment_in(shared).fix(dir).x, + font_size, + dir, + hyphenate: shared_get(children, shared, TextElem::hyphenate_in) + .map(|uniform| uniform.unwrap_or(justify)), + lang: shared_get(children, shared, TextElem::lang_in), + fallback: TextElem::fallback_in(shared), + cjk_latin_spacing: TextElem::cjk_latin_spacing_in(shared).is_auto(), + costs: TextElem::costs_in(shared), + } } /// Distinguishes between a few different kinds of paragraphs. @@ -148,3 +254,66 @@ pub enum ParSituation { /// Any other kind of paragraph. Other, } + +/// Raw values from a `ParElem` or style chain. Used to initialize a [`Config`]. +struct ConfigBase { + justify: bool, + linebreaks: Smart, + first_line_indent: FirstLineIndent, + hanging_indent: Abs, +} + +/// Shared configuration for the whole inline layout. +struct Config { + /// Whether to justify text. + justify: bool, + /// How to determine line breaks. + linebreaks: Linebreaks, + /// The indent the first line of a paragraph should have. + first_line_indent: Abs, + /// The indent that all but the first line of a paragraph should have. + hanging_indent: Abs, + /// Configuration for line numbering. + numbering_marker: Option>, + /// The resolved horizontal alignment. + align: FixedAlignment, + /// The text size. + font_size: Abs, + /// The dominant direction. + dir: Dir, + /// A uniform hyphenation setting (only `Some(_)` if it's the same for all + /// children, otherwise `None`). + hyphenate: Option, + /// The text language (only `Some(_)` if it's the same for all + /// children, otherwise `None`). + lang: Option, + /// Whether font fallback is enabled. + fallback: bool, + /// Whether to add spacing between CJK and Latin characters. + cjk_latin_spacing: bool, + /// Costs for various layout decisions. + costs: Costs, +} + +/// Get a style property, but only if it is the same for all of the children. +fn shared_get( + children: &[Pair], + styles: StyleChain<'_>, + getter: fn(StyleChain) -> T, +) -> Option { + let value = getter(styles); + children + .group_by_key(|&(_, s)| s) + .all(|(s, _)| getter(s) == value) + .then_some(value) +} + +/// Whether we have a list ancestor. +/// +/// When we support some kind of more general ancestry mechanism, this can +/// become more elegant. +fn in_list(styles: StyleChain) -> bool { + ListElem::depth_in(styles).0 > 0 + || !EnumElem::parents_in(styles).is_empty() + || TermsElem::within_in(styles) +} diff --git a/crates/typst-layout/src/inline/prepare.rs b/crates/typst-layout/src/inline/prepare.rs index 0344d43316..5d7fcd7cbd 100644 --- a/crates/typst-layout/src/inline/prepare.rs +++ b/crates/typst-layout/src/inline/prepare.rs @@ -1,9 +1,4 @@ -use typst_library::foundations::{Resolve, Smart}; -use typst_library::layout::{Abs, AlignElem, Dir, Em, FixedAlignment}; -use typst_library::model::Linebreaks; -use typst_library::routines::Pair; -use typst_library::text::{Costs, Lang, TextElem}; -use typst_utils::SliceExt; +use typst_library::layout::{Dir, Em}; use unicode_bidi::{BidiInfo, Level as BidiLevel}; use super::*; @@ -17,6 +12,8 @@ use super::*; pub struct Preparation<'a> { /// The full text. pub text: &'a str, + /// Configuration for inline layout. + pub config: &'a Config, /// Bidirectional text embedding levels. /// /// This is `None` if all text directions are uniform (all the base @@ -28,28 +25,6 @@ pub struct Preparation<'a> { pub indices: Vec, /// The span mapper. pub spans: SpanMapper, - /// Whether to hyphenate if it's the same for all children. - pub hyphenate: Option, - /// Costs for various layout decisions. - pub costs: Costs, - /// The dominant direction. - pub dir: Dir, - /// The text language if it's the same for all children. - pub lang: Option, - /// The resolved horizontal alignment. - pub align: FixedAlignment, - /// Whether to justify text. - pub justify: bool, - /// Hanging indent to apply. - pub hang: Abs, - /// Whether to add spacing between CJK and Latin characters. - pub cjk_latin_spacing: bool, - /// Whether font fallback is enabled. - pub fallback: bool, - /// How to determine line breaks. - pub linebreaks: Smart, - /// The text size. - pub size: Abs, } impl<'a> Preparation<'a> { @@ -80,15 +55,12 @@ impl<'a> Preparation<'a> { #[typst_macros::time] pub fn prepare<'a>( engine: &mut Engine, - children: &[Pair<'a>], + config: &'a Config, text: &'a str, segments: Vec>, spans: SpanMapper, - styles: StyleChain<'a>, - situation: Option, ) -> SourceResult> { - let dir = TextElem::dir_in(styles); - let default_level = match dir { + let default_level = match config.dir { Dir::RTL => BidiLevel::rtl(), _ => BidiLevel::ltr(), }; @@ -124,51 +96,20 @@ pub fn prepare<'a>( indices.extend(range.clone().map(|_| i)); } - let cjk_latin_spacing = TextElem::cjk_latin_spacing_in(styles).is_auto(); - if cjk_latin_spacing { + if config.cjk_latin_spacing { add_cjk_latin_spacing(&mut items); } - // Only apply hanging indent to real paragraphs. - let hang = if situation.is_some() { - ParElem::hanging_indent_in(styles) - } else { - Abs::zero() - }; - Ok(Preparation { + config, text, bidi: is_bidi.then_some(bidi), items, indices, spans, - hyphenate: shared_get(children, styles, TextElem::hyphenate_in), - costs: TextElem::costs_in(styles), - dir, - lang: shared_get(children, styles, TextElem::lang_in), - align: AlignElem::alignment_in(styles).resolve(styles).x, - justify: ParElem::justify_in(styles), - hang, - cjk_latin_spacing, - fallback: TextElem::fallback_in(styles), - linebreaks: ParElem::linebreaks_in(styles), - size: TextElem::size_in(styles), }) } -/// Get a style property, but only if it is the same for all of the children. -fn shared_get( - children: &[Pair], - styles: StyleChain<'_>, - getter: fn(StyleChain) -> T, -) -> Option { - let value = getter(styles); - children - .group_by_key(|&(_, s)| s) - .all(|(s, _)| getter(s) == value) - .then_some(value) -} - /// Add some spacing between Han characters and western characters. See /// Requirements for Chinese Text Layout, Section 3.2.2 Mixed Text Composition /// in Horizontal Written Mode diff --git a/crates/typst-layout/src/math/text.rs b/crates/typst-layout/src/math/text.rs index 9a64992aaf..59ac5b0895 100644 --- a/crates/typst-layout/src/math/text.rs +++ b/crates/typst-layout/src/math/text.rs @@ -107,7 +107,6 @@ fn layout_inline_text( styles, Size::splat(Abs::inf()), false, - None, )? .into_frame(); diff --git a/crates/typst-library/src/model/link.rs b/crates/typst-library/src/model/link.rs index 24b746b7e0..ea85aa945e 100644 --- a/crates/typst-library/src/model/link.rs +++ b/crates/typst-library/src/model/link.rs @@ -11,7 +11,7 @@ use crate::foundations::{ use crate::html::{attr, tag, HtmlElem}; use crate::introspection::Location; use crate::layout::Position; -use crate::text::{Hyphenate, TextElem}; +use crate::text::TextElem; /// Links to a URL or a location in the document. /// @@ -138,7 +138,7 @@ impl Show for Packed { impl ShowSet for Packed { fn show_set(&self, _: StyleChain) -> Styles { let mut out = Styles::new(); - out.set(TextElem::set_hyphenate(Hyphenate(Smart::Custom(false)))); + out.set(TextElem::set_hyphenate(Smart::Custom(false))); out } } diff --git a/crates/typst-library/src/text/mod.rs b/crates/typst-library/src/text/mod.rs index 12f4e4c598..30c2ea1d1f 100644 --- a/crates/typst-library/src/text/mod.rs +++ b/crates/typst-library/src/text/mod.rs @@ -51,7 +51,6 @@ use crate::foundations::{ }; use crate::layout::{Abs, Axis, Dir, Em, Length, Ratio, Rel}; use crate::math::{EquationElem, MathSize}; -use crate::model::ParElem; use crate::visualize::{Color, Paint, RelativeTo, Stroke}; use crate::World; @@ -504,9 +503,8 @@ pub struct TextElem { /// enabling hyphenation can /// improve justification. /// ``` - #[resolve] #[ghost] - pub hyphenate: Hyphenate, + pub hyphenate: Smart, /// The "cost" of various choices when laying out text. A higher cost means /// the layout engine will make the choice less often. Costs are specified @@ -1110,27 +1108,6 @@ impl Resolve for TextDir { } } -/// Whether to hyphenate text. -#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)] -pub struct Hyphenate(pub Smart); - -cast! { - Hyphenate, - self => self.0.into_value(), - v: Smart => Self(v), -} - -impl Resolve for Hyphenate { - type Output = bool; - - fn resolve(self, styles: StyleChain) -> Self::Output { - match self.0 { - Smart::Auto => ParElem::justify_in(styles), - Smart::Custom(v) => v, - } - } -} - /// A set of stylistic sets to enable. #[derive(Debug, Copy, Clone, Default, Eq, PartialEq, Hash)] pub struct StylisticSets(u32); diff --git a/crates/typst-library/src/text/raw.rs b/crates/typst-library/src/text/raw.rs index 5bb21e43af..b330c01ef8 100644 --- a/crates/typst-library/src/text/raw.rs +++ b/crates/typst-library/src/text/raw.rs @@ -21,9 +21,7 @@ use crate::html::{tag, HtmlElem}; use crate::layout::{BlockBody, BlockElem, Em, HAlignment}; use crate::loading::{DataSource, Load}; use crate::model::{Figurable, ParElem}; -use crate::text::{ - FontFamily, FontList, Hyphenate, LinebreakElem, LocalName, TextElem, TextSize, -}; +use crate::text::{FontFamily, FontList, LinebreakElem, LocalName, TextElem, TextSize}; use crate::visualize::Color; use crate::World; @@ -472,7 +470,7 @@ impl ShowSet for Packed { let mut out = Styles::new(); out.set(TextElem::set_overhang(false)); out.set(TextElem::set_lang(Lang::ENGLISH)); - out.set(TextElem::set_hyphenate(Hyphenate(Smart::Custom(false)))); + out.set(TextElem::set_hyphenate(Smart::Custom(false))); out.set(TextElem::set_size(TextSize(Em::new(0.8).into()))); out.set(TextElem::set_font(FontList(vec![FontFamily::new("DejaVu Sans Mono")]))); out.set(TextElem::set_cjk_latin_spacing(Smart::Custom(None))); diff --git a/tests/ref/issue-5831-par-constructor-args.png b/tests/ref/issue-5831-par-constructor-args.png new file mode 100644 index 0000000000000000000000000000000000000000..440b612ba938a714148f598c52501450b6fe85aa GIT binary patch literal 1356 zcmV-S1+)5zP)e!Amty9JdH6Cr0@k9ib)FL`~)DA^aL==tIa*S3aps_WI7!m|Y zxFm8E5iG3+fk2BwOcqTzB4-jpf+07_{z?KbiX9NGm^b@dzf>25RyYT zs0D5-@$jV-NA4G?-k~?$>~K6F+X3hRMP)ZIVm& zAxx^J4#S0*9O?TS@CfpfU{UNgDFHmUkI$(!BY>lI6h>{23jh+X4tI>0JDm0&ivoa3 zkEDw_n+oVx16pIB4xW~dwgFITff2dw6)@Z|6w6sVML1p;_7nh424pd6)y&FRL(HV3$R&Z^hfP+{YE&z$wlW5_yB$yb}sfP;BGNA zTVarFY*PDGlQV5F#T_7Te6Sca2M%JrfV`QKPIm^sxo2>L-i+err>efBd!l=@ZrSZWp0Kgs^ zXTQ(LTg0kfpue)t9B@Y1pSPzz*Ka5#M!eq<&NP3`2Y{CMHPxRE_|*iT$tvNNy%Hvf zK`04}g0MQ8hv0!Dnhb!<+HR8*Mh{HL?daC_po@4hb}3{!I9o|2F%0b!IEXe2(GCDT zgua_FXr3E;OpiL>WC6JoiPy{4{&rTbtLHmG;-yGTooEF-wTl1$lIf4hT`R=Y+36EU z^Ne6ud0H<#4RJ3Ikvv`_a&BcY4XEUo|2?0tJo+%X>xEymLpIqS)pRxas-eSpaDTy zo$7VSKSypN^LkmuIA4JP+2hX?R=8thdmlMhB8kSg7VQKqIFHZKwHXXU8FX6PRIn|Z zziE812*COdBwv?CK==#J(4}rTRE6R7C{1n&1uzD$RpDpevv7>p%HL888u~)4i{Y4n zZ>NBUfvx!6_vUC~ylg5sxl$VdPjd#Ki5B2MUGrY#q$#_eguxdGQNX$s**|GNa*XOz z3lNou$OhSVcyg>k>;w4Vc@RD{YyBo*7M5Du)}zY2+%?Us{xhzC@eJ<7JJ`eK;~Xwh zcj%5q5^>j?aF8WqqyXp6rhRY7+nere+$Ax^aWZk?8yP$u=EWyguWP*-()rsDnT$tp zm!FOzCVxv?j3E2nZUnB~o$=>13dqy1*{Yy|C=JDwzq-+=TGe$j*0TEEPy~07F3sTC=IM0v?AQC=K8$jO zR8(J8+*47kHi;V0AySvvr1$j2w_qGQm5UxVi`wc;)GrJLQ!oX45&j3xlV%AnmsgDd O0000).len(), 1) +--- issue-5831-par-constructor-args --- +// Make sure that all arguments are also respected in the constructor. +A +#par( + leading: 2pt, + spacing: 20pt, + justify: true, + linebreaks: "simple", + first-line-indent: (amount: 1em, all: true), + hanging-indent: 5pt, +)[ + The par function has a constructor and justification. +] + --- show-par-set-block-hint --- // Warning: 2-36 `show par: set block(spacing: ..)` has no effect anymore // Hint: 2-36 this is specific to paragraphs as they are not considered blocks anymore From 81021fa1a277b04e8726d82fdf8f5fe11aeacab6 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 10 Feb 2025 16:39:14 +0100 Subject: [PATCH 010/558] Bump `typst-assets` (#5845) --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 140dccf741..bf69c12af9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2753,7 +2753,7 @@ dependencies = [ [[package]] name = "typst-assets" version = "0.12.0" -source = "git+https://github.com/typst/typst-assets?rev=8cccef9#8cccef93b5da73a1c80389722cf2b655b624f577" +source = "git+https://github.com/typst/typst-assets?rev=8536748#8536748e4350198f34e519adff8593f258259cca" [[package]] name = "typst-cli" diff --git a/Cargo.toml b/Cargo.toml index 469439d389..ea9f8510ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,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 = "8cccef9" } +typst-assets = { git = "https://github.com/typst/typst-assets", rev = "8536748" } typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "7f8999d" } arrayvec = "0.7.4" az = "1.2" From a0cd89b478437e53ece754a901ccfc035b4f2acf Mon Sep 17 00:00:00 2001 From: Laurenz Date: Tue, 11 Feb 2025 11:30:30 +0100 Subject: [PATCH 011/558] Fix autocomplete and jumps in math (#5849) --- crates/typst-ide/src/complete.rs | 17 +++++++++++++++-- crates/typst-ide/src/jump.rs | 17 +++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index 7df788dc32..564b97bd77 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -306,7 +306,10 @@ fn complete_math(ctx: &mut CompletionContext) -> bool { } // Behind existing atom or identifier: "$a|$" or "$abc|$". - if matches!(ctx.leaf.kind(), SyntaxKind::Text | SyntaxKind::MathIdent) { + if matches!( + ctx.leaf.kind(), + SyntaxKind::Text | SyntaxKind::MathText | SyntaxKind::MathIdent + ) { ctx.from = ctx.leaf.offset(); math_completions(ctx); return true; @@ -358,7 +361,7 @@ fn complete_field_accesses(ctx: &mut CompletionContext) -> bool { // Behind an expression plus dot: "emoji.|". if_chain! { if ctx.leaf.kind() == SyntaxKind::Dot - || (ctx.leaf.kind() == SyntaxKind::Text + || (matches!(ctx.leaf.kind(), SyntaxKind::Text | SyntaxKind::MathText) && ctx.leaf.text() == "."); if ctx.leaf.range().end == ctx.cursor; if let Some(prev) = ctx.leaf.prev_sibling(); @@ -1768,4 +1771,14 @@ mod tests { test("#show outline.entry: it => it.\n#outline()\n= Hi", 30) .must_include(["indented", "body", "page"]); } + + #[test] + fn test_autocomplete_symbol_variants() { + test("#sym.arrow.", -1) + .must_include(["r", "dashed"]) + .must_exclude(["cases"]); + test("$ arrow. $", -3) + .must_include(["r", "dashed"]) + .must_exclude(["cases"]); + } } diff --git a/crates/typst-ide/src/jump.rs b/crates/typst-ide/src/jump.rs index ed74df226e..4283354268 100644 --- a/crates/typst-ide/src/jump.rs +++ b/crates/typst-ide/src/jump.rs @@ -73,7 +73,10 @@ pub fn jump_from_click( let Some(id) = span.id() else { continue }; let source = world.source(id).ok()?; let node = source.find(span)?; - let pos = if node.kind() == SyntaxKind::Text { + let pos = if matches!( + node.kind(), + SyntaxKind::Text | SyntaxKind::MathText + ) { let range = node.range(); let mut offset = range.start + usize::from(span_offset); if (click.x - pos.x) > width / 2.0 { @@ -115,7 +118,7 @@ pub fn jump_from_cursor( cursor: usize, ) -> Vec { fn is_text(node: &LinkedNode) -> bool { - node.get().kind() == SyntaxKind::Text + matches!(node.kind(), SyntaxKind::Text | SyntaxKind::MathText) } let root = LinkedNode::new(source.root()); @@ -261,6 +264,11 @@ mod tests { test_click(s, point(21.0, 12.0), cursor(56)); } + #[test] + fn test_jump_from_click_math() { + test_click("$a + b$", point(28.0, 14.0), cursor(5)); + } + #[test] fn test_jump_from_cursor() { let s = "*Hello* #box[ABC] World"; @@ -268,6 +276,11 @@ mod tests { test_cursor(s, 14, pos(1, 37.55, 16.58)); } + #[test] + fn test_jump_from_cursor_math() { + test_cursor("$a + b$", -3, pos(1, 27.51, 16.83)); + } + #[test] fn test_backlink() { let s = "#footnote[Hi]"; From 83ad407d3ccff4a8de1e7ffe198bfed874f5c0c7 Mon Sep 17 00:00:00 2001 From: +merlan #flirora Date: Wed, 12 Feb 2025 07:35:03 -0500 Subject: [PATCH 012/558] Update documentation for `float.{to-bits, from-bits}` (#5836) --- crates/typst-library/src/foundations/float.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/typst-library/src/foundations/float.rs b/crates/typst-library/src/foundations/float.rs index fcc46b0346..21d0a8d812 100644 --- a/crates/typst-library/src/foundations/float.rs +++ b/crates/typst-library/src/foundations/float.rs @@ -110,7 +110,7 @@ impl f64 { f64::signum(self) } - /// Converts bytes to a float. + /// Interprets bytes as a float. /// /// ```example /// #float.from-bytes(bytes((0, 0, 0, 0, 0, 0, 240, 63))) \ @@ -120,8 +120,10 @@ impl f64 { pub fn from_bytes( /// The bytes that should be converted to a float. /// - /// Must be of length exactly 8 so that the result fits into a 64-bit - /// float. + /// Must have a length of either 4 or 8. The bytes are then + /// interpreted in [IEEE 754](https://en.wikipedia.org/wiki/IEEE_754)'s + /// binary32 (single-precision) or binary64 (double-precision) format + /// depending on the length of the bytes. bytes: Bytes, /// The endianness of the conversion. #[named] @@ -158,6 +160,13 @@ impl f64 { #[named] #[default(Endianness::Little)] endian: Endianness, + /// The size of the resulting bytes. + /// + /// This must be either 4 or 8. The call will return the + /// representation of this float in either + /// [IEEE 754](https://en.wikipedia.org/wiki/IEEE_754)'s binary32 + /// (single-precision) or binary64 (double-precision) format + /// depending on the provided size. #[named] #[default(8)] size: u32, From 02cd43e27f2aafd7c332d7672a837e1b11cce120 Mon Sep 17 00:00:00 2001 From: +merlan #flirora Date: Wed, 12 Feb 2025 07:38:40 -0500 Subject: [PATCH 013/558] `Gradient::repeat`: Fix floating-point error in stop calculation (#5837) --- crates/typst-library/src/visualize/gradient.rs | 7 +++---- tests/suite/visualize/gradient.typ | 8 ++++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/typst-library/src/visualize/gradient.rs b/crates/typst-library/src/visualize/gradient.rs index 431f07dd43..d6530dd095 100644 --- a/crates/typst-library/src/visualize/gradient.rs +++ b/crates/typst-library/src/visualize/gradient.rs @@ -582,12 +582,11 @@ impl Gradient { let mut stops = stops .iter() .map(move |&(color, offset)| { - let t = i as f64 / n as f64; let r = offset.get(); if i % 2 == 1 && mirror { - (color, Ratio::new(t + (1.0 - r) / n as f64)) + (color, Ratio::new((i as f64 + 1.0 - r) / n as f64)) } else { - (color, Ratio::new(t + r / n as f64)) + (color, Ratio::new((i as f64 + r) / n as f64)) } }) .collect::>(); @@ -1230,7 +1229,7 @@ fn process_stops(stops: &[Spanned]) -> SourceResult Date: Wed, 12 Feb 2025 16:50:48 +0100 Subject: [PATCH 014/558] Lazy parsing of the package index (#5851) --- Cargo.lock | 2 + crates/typst-kit/Cargo.toml | 2 + crates/typst-kit/src/package.rs | 75 ++++++++++++++++++++++++++++++--- 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bf69c12af9..66a1e3a126 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2901,6 +2901,8 @@ dependencies = [ "native-tls", "once_cell", "openssl", + "serde", + "serde_json", "tar", "typst-assets", "typst-library", diff --git a/crates/typst-kit/Cargo.toml b/crates/typst-kit/Cargo.toml index 266eba0b42..52aa407c32 100644 --- a/crates/typst-kit/Cargo.toml +++ b/crates/typst-kit/Cargo.toml @@ -23,6 +23,8 @@ flate2 = { workspace = true, optional = true } fontdb = { workspace = true, optional = true } native-tls = { workspace = true, optional = true } once_cell = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } tar = { workspace = true, optional = true } ureq = { workspace = true, optional = true } diff --git a/crates/typst-kit/src/package.rs b/crates/typst-kit/src/package.rs index e7eb71ee45..172d8740af 100644 --- a/crates/typst-kit/src/package.rs +++ b/crates/typst-kit/src/package.rs @@ -5,10 +5,9 @@ use std::path::{Path, PathBuf}; use ecow::eco_format; use once_cell::sync::OnceCell; +use serde::Deserialize; use typst_library::diag::{bail, PackageError, PackageResult, StrResult}; -use typst_syntax::package::{ - PackageInfo, PackageSpec, PackageVersion, VersionlessPackageSpec, -}; +use typst_syntax::package::{PackageSpec, PackageVersion, VersionlessPackageSpec}; use crate::download::{Downloader, Progress}; @@ -32,7 +31,7 @@ pub struct PackageStorage { /// The downloader used for fetching the index and packages. downloader: Downloader, /// The cached index of the default namespace. - index: OnceCell>, + index: OnceCell>, } impl PackageStorage { @@ -42,6 +41,18 @@ impl PackageStorage { package_cache_path: Option, package_path: Option, downloader: Downloader, + ) -> Self { + Self::with_index(package_cache_path, package_path, downloader, OnceCell::new()) + } + + /// Creates a new package storage with a pre-defined index. + /// + /// Useful for testing. + fn with_index( + package_cache_path: Option, + package_path: Option, + downloader: Downloader, + index: OnceCell>, ) -> Self { Self { package_cache_path: package_cache_path.or_else(|| { @@ -51,7 +62,7 @@ impl PackageStorage { dirs::data_dir().map(|data_dir| data_dir.join(DEFAULT_PACKAGES_SUBDIR)) }), downloader, - index: OnceCell::new(), + index, } } @@ -109,6 +120,7 @@ impl PackageStorage { // version. self.download_index()? .iter() + .filter_map(|value| MinimalPackageInfo::deserialize(value).ok()) .filter(|package| package.name == spec.name) .map(|package| package.version) .max() @@ -131,7 +143,7 @@ impl PackageStorage { } /// Download the package index. The result of this is cached for efficiency. - pub fn download_index(&self) -> StrResult<&[PackageInfo]> { + pub fn download_index(&self) -> StrResult<&[serde_json::Value]> { self.index .get_or_try_init(|| { let url = format!("{DEFAULT_REGISTRY}/{DEFAULT_NAMESPACE}/index.json"); @@ -186,3 +198,54 @@ impl PackageStorage { }) } } + +/// Minimal information required about a package to determine its latest +/// version. +#[derive(Deserialize)] +struct MinimalPackageInfo { + name: String, + version: PackageVersion, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lazy_deser_index() { + let storage = PackageStorage::with_index( + None, + None, + Downloader::new("typst/test"), + OnceCell::with_value(vec![ + serde_json::json!({ + "name": "charged-ieee", + "version": "0.1.0", + "entrypoint": "lib.typ", + }), + serde_json::json!({ + "name": "unequivocal-ams", + // This version number is currently not valid, so this package + // can't be parsed. + "version": "0.2.0-dev", + "entrypoint": "lib.typ", + }), + ]), + ); + + let ieee_version = storage.determine_latest_version(&VersionlessPackageSpec { + namespace: "preview".into(), + name: "charged-ieee".into(), + }); + assert_eq!(ieee_version, Ok(PackageVersion { major: 0, minor: 1, patch: 0 })); + + let ams_version = storage.determine_latest_version(&VersionlessPackageSpec { + namespace: "preview".into(), + name: "unequivocal-ams".into(), + }); + assert_eq!( + ams_version, + Err("failed to find package @preview/unequivocal-ams".into()) + ) + } +} From 5fc679f3e7501ee5831f1b4b7789350f43b4c221 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Sun, 16 Feb 2025 14:18:39 +0100 Subject: [PATCH 015/558] Remove Linux Libertine warning (#5876) --- crates/typst-library/src/text/mod.rs | 19 +------------------ tests/suite/text/font.typ | 5 ----- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/crates/typst-library/src/text/mod.rs b/crates/typst-library/src/text/mod.rs index 30c2ea1d1f..3aac15ba58 100644 --- a/crates/typst-library/src/text/mod.rs +++ b/crates/typst-library/src/text/mod.rs @@ -1380,24 +1380,7 @@ pub fn is_default_ignorable(c: char) -> bool { fn check_font_list(engine: &mut Engine, list: &Spanned) { let book = engine.world.book(); for family in &list.v { - let found = book.contains_family(family.as_str()); - if family.as_str() == "linux libertine" { - let mut warning = warning!( - list.span, - "Typst's default font has changed from Linux Libertine to its successor Libertinus Serif"; - hint: "please set the font to `\"Libertinus Serif\"` instead" - ); - - if found { - warning.hint( - "Linux Libertine is available on your system - \ - you can ignore this warning if you are sure you want to use it", - ); - warning.hint("this warning will be removed in Typst 0.13"); - } - - engine.sink.warn(warning); - } else if !found { + if !book.contains_family(family.as_str()) { engine.sink.warn(warning!( list.span, "unknown font family: {}", diff --git a/tests/suite/text/font.typ b/tests/suite/text/font.typ index 9e5c0150dc..60a1cd94df 100644 --- a/tests/suite/text/font.typ +++ b/tests/suite/text/font.typ @@ -77,11 +77,6 @@ I #let var = text(font: ("list-of", "nonexistent-fonts"))[don't] #var ---- text-font-linux-libertine --- -// Warning: 17-34 Typst's default font has changed from Linux Libertine to its successor Libertinus Serif -// Hint: 17-34 please set the font to `"Libertinus Serif"` instead -#set text(font: "Linux Libertine") - --- issue-5499-text-fill-in-clip-block --- #let t = tiling( From 25c86accbb4adc0e7542d2c5957dff6e939cbf48 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 17 Feb 2025 11:56:00 +0100 Subject: [PATCH 016/558] More robust SVG auto-detection (#5878) --- Cargo.lock | 1 + Cargo.toml | 1 + crates/typst-library/Cargo.toml | 1 + .../typst-library/src/visualize/image/mod.rs | 18 ++++++++++++++++-- docs/changelog/0.13.0.md | 3 +-- tests/ref/image-svg-auto-detection.png | Bin 0 -> 129 bytes tests/suite/visualize/image.typ | 15 +++++++++++++-- 7 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 tests/ref/image-svg-auto-detection.png diff --git a/Cargo.lock b/Cargo.lock index 66a1e3a126..249ee3bc51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2966,6 +2966,7 @@ dependencies = [ "kamadak-exif", "kurbo", "lipsum", + "memchr", "palette", "phf", "png", diff --git a/Cargo.toml b/Cargo.toml index ea9f8510ee..6fb64d3ab0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ kamadak-exif = "0.6" kurbo = "0.11" libfuzzer-sys = "0.4" lipsum = "0.9" +memchr = "2" miniz_oxide = "0.8" native-tls = "0.2" notify = "8" diff --git a/crates/typst-library/Cargo.toml b/crates/typst-library/Cargo.toml index cc5e267127..fb45ec8620 100644 --- a/crates/typst-library/Cargo.toml +++ b/crates/typst-library/Cargo.toml @@ -38,6 +38,7 @@ indexmap = { workspace = true } kamadak-exif = { workspace = true } kurbo = { workspace = true } lipsum = { workspace = true } +memchr = { workspace = true } palette = { workspace = true } phf = { workspace = true } png = { workspace = true } diff --git a/crates/typst-library/src/visualize/image/mod.rs b/crates/typst-library/src/visualize/image/mod.rs index 97189e22d1..258eb96f34 100644 --- a/crates/typst-library/src/visualize/image/mod.rs +++ b/crates/typst-library/src/visualize/image/mod.rs @@ -398,8 +398,7 @@ impl ImageFormat { return Some(Self::Raster(RasterFormat::Exchange(format))); } - // SVG or compressed SVG. - if data.starts_with(b" bool { + // Check for the gzip magic bytes. This check is perhaps a bit too + // permissive as other formats than SVGZ could use gzip. + if data.starts_with(&[0x1f, 0x8b]) { + return true; + } + + // If the first 2048 bytes contain the SVG namespace declaration, we assume + // that it's an SVG. Note that, if the SVG does not contain a namespace + // declaration, usvg will reject it. + let head = &data[..data.len().min(2048)]; + memchr::memmem::find(head, b"http://www.w3.org/2000/svg").is_some() +} + /// A vector graphics format. #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] pub enum VectorFormat { diff --git a/docs/changelog/0.13.0.md b/docs/changelog/0.13.0.md index 2caace7239..e5315e5b6d 100644 --- a/docs/changelog/0.13.0.md +++ b/docs/changelog/0.13.0.md @@ -99,8 +99,7 @@ description: Changes slated to appear in Typst 0.13.0 - Fixed interaction of clipping and outset on [`box`] and [`block`] - Fixed panic with [`path`] of infinite length - Fixed non-solid (e.g. tiling) text fills in clipped blocks -- Auto-detection of image formats from a raw buffer now has basic support for - SVGs +- Auto-detection of image formats from a raw buffer now has support for SVGs ## Scripting - Functions that accept [file paths]($syntax/#paths) now also accept raw diff --git a/tests/ref/image-svg-auto-detection.png b/tests/ref/image-svg-auto-detection.png new file mode 100644 index 0000000000000000000000000000000000000000..0240f8f5cf74eaa704282288c12784b981ebcf37 GIT binary patch literal 129 zcmeAS@N?(olHy`uVBq!ia0vp^6+j%%#0(_k7Qa3Zq<8{+LR|m<|KFdT-QeKxpMgPb zzGnhZ+`!YtF{I*FvIOhm1P;b+p%Mv9lT5zX^L$7Mse-`@58)NOZm&-S8niGl$Zgtk U`tV(DZlGQUPgg&ebxsLQ04L`tRR910 literal 0 HcmV?d00001 diff --git a/tests/suite/visualize/image.typ b/tests/suite/visualize/image.typ index e37932f28c..7ce0c8c0af 100644 --- a/tests/suite/visualize/image.typ +++ b/tests/suite/visualize/image.typ @@ -65,6 +65,17 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B caption: [Bilingual text] ) +--- image-svg-auto-detection --- +#image(bytes( + ``` + + + + + + ```.text +)) + --- image-pixmap-rgb8 --- #image( bytes(( @@ -152,8 +163,8 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B #image("path/does/not/exist") --- image-bad-format --- -// Error: 2-22 unknown image format -#image("./image.typ") +// Error: 2-37 unknown image format +#image("/assets/plugins/hello.wasm") --- image-bad-svg --- // Error: 2-33 failed to parse SVG (found closing tag 'g' instead of 'style' in line 4) From 74e4f78687d7acb5db3d531959c956717cce837a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=A1=A5=E1=A0=A0=E1=A1=B3=E1=A1=A4=E1=A1=B3=E1=A0=B6?= =?UTF-8?q?=E1=A0=A0=20=E1=A1=A5=E1=A0=A0=E1=A0=AF=E1=A0=A0=C2=B7=E1=A0=A8?= =?UTF-8?q?=E1=A1=9D=E1=A1=B4=E1=A0=A3=20=E7=8C=AB?= Date: Tue, 18 Feb 2025 18:16:19 +0800 Subject: [PATCH 017/558] HTML export: Use `` for inline `RawElem` (#5884) --- crates/typst-library/src/text/raw.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/typst-library/src/text/raw.rs b/crates/typst-library/src/text/raw.rs index b330c01ef8..1ce8bfc611 100644 --- a/crates/typst-library/src/text/raw.rs +++ b/crates/typst-library/src/text/raw.rs @@ -446,10 +446,14 @@ impl Show for Packed { let mut realized = Content::sequence(seq); if TargetElem::target_in(styles).is_html() { - return Ok(HtmlElem::new(tag::pre) - .with_body(Some(realized)) - .pack() - .spanned(self.span())); + return Ok(HtmlElem::new(if self.block(styles) { + tag::pre + } else { + tag::code + }) + .with_body(Some(realized)) + .pack() + .spanned(self.span())); } if self.block(styles) { From 3de3813ca06c332cd1eae14c64913725a9333aff Mon Sep 17 00:00:00 2001 From: Matthew Toohey Date: Tue, 18 Feb 2025 13:04:40 -0500 Subject: [PATCH 018/558] --make-deps fixes (#5873) --- crates/typst-cli/src/compile.rs | 83 +++++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 25 deletions(-) diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index 515a777a26..2b6a7d820b 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -6,8 +6,9 @@ use std::path::{Path, PathBuf}; use chrono::{DateTime, Datelike, Timelike, Utc}; use codespan_reporting::diagnostic::{Diagnostic, Label}; use codespan_reporting::term; -use ecow::{eco_format, EcoString}; +use ecow::eco_format; use parking_lot::RwLock; +use pathdiff::diff_paths; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use typst::diag::{ bail, At, Severity, SourceDiagnostic, SourceResult, StrResult, Warned, @@ -188,7 +189,7 @@ pub fn compile_once( match output { // Export the PDF / PNG. - Ok(()) => { + Ok(outputs) => { let duration = start.elapsed(); if config.watching { @@ -202,7 +203,7 @@ pub fn compile_once( print_diagnostics(world, &[], &warnings, config.diagnostic_format) .map_err(|err| eco_format!("failed to print diagnostics ({err})"))?; - write_make_deps(world, config)?; + write_make_deps(world, config, outputs)?; open_output(config)?; } @@ -226,12 +227,15 @@ pub fn compile_once( fn compile_and_export( world: &mut SystemWorld, config: &mut CompileConfig, -) -> Warned> { +) -> 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 } + Warned { + output: result.map(|()| vec![config.output.clone()]), + warnings, + } } _ => { let Warned { output, warnings } = typst::compile::(world); @@ -257,9 +261,14 @@ fn export_html(document: &HtmlDocument, config: &CompileConfig) -> SourceResult< } /// Export to a paged target format. -fn export_paged(document: &PagedDocument, config: &CompileConfig) -> SourceResult<()> { +fn export_paged( + document: &PagedDocument, + config: &CompileConfig, +) -> SourceResult> { match config.output_format { - OutputFormat::Pdf => export_pdf(document, config), + OutputFormat::Pdf => { + export_pdf(document, config).map(|()| vec![config.output.clone()]) + } OutputFormat::Png => { export_image(document, config, ImageExportFormat::Png).at(Span::detached()) } @@ -327,7 +336,7 @@ fn export_image( document: &PagedDocument, config: &CompileConfig, fmt: ImageExportFormat, -) -> StrResult<()> { +) -> StrResult> { // Determine whether we have indexable templates in output let can_handle_multiple = match config.output { Output::Stdout => false, @@ -383,7 +392,7 @@ fn export_image( && config.export_cache.is_cached(*i, &page.frame) && path.exists() { - return Ok(()); + return Ok(Output::Path(path.to_path_buf())); } Output::Path(path.to_owned()) @@ -392,11 +401,9 @@ fn export_image( }; export_image_page(config, page, &output, fmt)?; - Ok(()) + Ok(output) }) - .collect::, EcoString>>()?; - - Ok(()) + .collect::>>() } mod output_template { @@ -501,14 +508,25 @@ 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, config: &CompileConfig) -> StrResult<()> { +fn write_make_deps( + world: &mut SystemWorld, + config: &CompileConfig, + outputs: Vec, +) -> 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 Some(output_path) = output_path.as_os_str().to_str() else { + let Ok(output_paths) = outputs + .into_iter() + .filter_map(|o| match o { + Output::Path(path) => Some(path.into_os_string().into_string()), + Output::Stdout => None, + }) + .collect::, _>>() + else { bail!("failed to create make dependencies file because output path was not valid unicode") }; + if output_paths.is_empty() { + bail!("failed to create make dependencies file because output was stdout") + } // Based on `munge` in libcpp/mkdeps.cc from the GCC source code. This isn't // perfect as some special characters can't be escaped. @@ -522,6 +540,10 @@ fn write_make_deps(world: &mut SystemWorld, config: &CompileConfig) -> StrResult res.push('$'); slashes = 0; } + ':' => { + res.push('\\'); + slashes = 0; + } ' ' | '\t' => { // `munge`'s source contains a comment here that says: "A // space or tab preceded by 2N+1 backslashes represents N @@ -544,18 +566,29 @@ fn write_make_deps(world: &mut SystemWorld, config: &CompileConfig) -> StrResult fn write( make_deps_path: &Path, - output_path: &str, + output_paths: Vec, root: PathBuf, dependencies: impl Iterator, ) -> io::Result<()> { let mut file = File::create(make_deps_path)?; + let current_dir = std::env::current_dir()?; + let relative_root = diff_paths(&root, ¤t_dir).unwrap_or(root.clone()); - file.write_all(munge(output_path).as_bytes())?; + for (i, output_path) in output_paths.into_iter().enumerate() { + if i != 0 { + file.write_all(b" ")?; + } + file.write_all(munge(&output_path).as_bytes())?; + } file.write_all(b":")?; for dependency in dependencies { - let Some(dependency) = - dependency.strip_prefix(&root).unwrap_or(&dependency).to_str() - else { + let relative_dependency = match dependency.strip_prefix(&root) { + Ok(root_relative_dependency) => { + relative_root.join(root_relative_dependency) + } + Err(_) => dependency, + }; + let Some(relative_dependency) = relative_dependency.to_str() else { // Silently skip paths that aren't valid unicode so we still // produce a rule that will work for the other paths that can be // processed. @@ -563,14 +596,14 @@ fn write_make_deps(world: &mut SystemWorld, config: &CompileConfig) -> StrResult }; file.write_all(b" ")?; - file.write_all(munge(dependency).as_bytes())?; + file.write_all(munge(relative_dependency).as_bytes())?; } file.write_all(b"\n")?; Ok(()) } - write(make_deps_path, output_path, world.root().to_owned(), world.dependencies()) + write(make_deps_path, output_paths, world.root().to_owned(), world.dependencies()) .map_err(|err| { eco_format!("failed to create make dependencies file due to IO error ({err})") }) From a543ee9445c0541b34a2bb5ea3b48ca596b71152 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Wed, 19 Feb 2025 10:59:27 +0100 Subject: [PATCH 019/558] Update changelog (#5894) --- docs/changelog/0.13.0.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/changelog/0.13.0.md b/docs/changelog/0.13.0.md index e5315e5b6d..4212c8251b 100644 --- a/docs/changelog/0.13.0.md +++ b/docs/changelog/0.13.0.md @@ -99,6 +99,8 @@ description: Changes slated to appear in Typst 0.13.0 - Fixed interaction of clipping and outset on [`box`] and [`block`] - Fixed panic with [`path`] of infinite length - Fixed non-solid (e.g. tiling) text fills in clipped blocks +- Fixed a crash for images with a DPI value of zero +- Fixed floating-point error in [`gradient.repeat`] - Auto-detection of image formats from a raw buffer now has support for SVGs ## Scripting @@ -186,12 +188,12 @@ description: Changes slated to appear in Typst 0.13.0 - [CJK-Latin-spacing]($text.cjk-latin-spacing) does not affect [raw] text anymore - Fixed wrong language codes being used for Greek and Ukrainian -- Fixed default quotes for Croatian +- Fixed default quotes for Croatian and Bulgarian - Fixed crash in RTL text handling - Added support for [`raw`] syntax highlighting for a few new languages: CFML, NSIS, and WGSL - New font metadata exception for New Computer Modern Sans Math -- Updated bundled New Computer Modern fonts to version 7.0 +- Updated bundled New Computer Modern fonts to version 7.0.1 ## Layout - Fixed various bugs with footnotes @@ -270,6 +272,9 @@ feature flag. - Added a live reloading HTTP server to `typst watch` when targeting HTML - Fixed self-update not being aware about certain target architectures - Fixed crash when piping `typst fonts` output to another command +- Fixed handling of relative paths in `--make-deps` output +- Fixed handling of multipage SVG and PNG export in `--make-deps` output +- Colons in filenames are now correctly escaped in `--make-deps` output ## Symbols - New @@ -312,6 +317,9 @@ feature flag. functions directly accepting both paths and bytes - The `sect` and its variants in favor of `inter`, and `integral.sect` in favor of `integral.inter` +- The compatibility behavior of type/str comparisons (e.g. `{int == "integer"}`) + which was temporarily introduced in Typst 0.8 now emits warnings. It will be + removed in Typst 0.14. ## Removals - Removed `style` function and `styles` argument of [`measure`], use a [context] @@ -323,9 +331,6 @@ feature flag. - Removed compatibility behavior where [`counter.display`] worked without [context] **(Breaking change)** - Removed compatibility behavior of [`locate`] **(Breaking change)** -- Removed compatibility behavior of type/str comparisons - (e.g. `{int == "integer"}`) which was temporarily introduced in Typst 0.8 - **(Breaking change)** ## Development - The `typst::compile` function is now generic and can return either a From d199546f9fe92b2d380dc337298fdca3e6fca8c8 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Wed, 19 Feb 2025 11:25:09 +0100 Subject: [PATCH 020/558] Bump version on main The tagged commit itself is on the 0.13 branch. --- Cargo.lock | 46 +++++++++++++++++++-------------------- Cargo.toml | 38 ++++++++++++++++---------------- docs/changelog/0.13.0.md | 9 +++++--- docs/changelog/welcome.md | 2 +- 4 files changed, 49 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 249ee3bc51..1851134a55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2735,7 +2735,7 @@ checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" [[package]] name = "typst" -version = "0.12.0" +version = "0.13.0" dependencies = [ "comemo", "ecow", @@ -2752,12 +2752,12 @@ dependencies = [ [[package]] name = "typst-assets" -version = "0.12.0" -source = "git+https://github.com/typst/typst-assets?rev=8536748#8536748e4350198f34e519adff8593f258259cca" +version = "0.13.0" +source = "git+https://github.com/typst/typst-assets?rev=fa0f8a4#fa0f8a438cc4bc2113cc0aa3304cd68cdc2bc020" [[package]] name = "typst-cli" -version = "0.12.0" +version = "0.13.0" dependencies = [ "chrono", "clap", @@ -2802,12 +2802,12 @@ dependencies = [ [[package]] name = "typst-dev-assets" -version = "0.12.0" -source = "git+https://github.com/typst/typst-dev-assets?rev=7f8999d#7f8999d19907cd6e1148b295efbc844921c0761c" +version = "0.13.0" +source = "git+https://github.com/typst/typst-dev-assets?rev=61aebe9#61aebe9575a5abff889f76d73c7b01dc8e17e340" [[package]] name = "typst-docs" -version = "0.12.0" +version = "0.13.0" dependencies = [ "clap", "ecow", @@ -2830,7 +2830,7 @@ dependencies = [ [[package]] name = "typst-eval" -version = "0.12.0" +version = "0.13.0" dependencies = [ "comemo", "ecow", @@ -2848,7 +2848,7 @@ dependencies = [ [[package]] name = "typst-fuzz" -version = "0.12.0" +version = "0.13.0" dependencies = [ "comemo", "libfuzzer-sys", @@ -2860,7 +2860,7 @@ dependencies = [ [[package]] name = "typst-html" -version = "0.12.0" +version = "0.13.0" dependencies = [ "comemo", "ecow", @@ -2874,7 +2874,7 @@ dependencies = [ [[package]] name = "typst-ide" -version = "0.12.0" +version = "0.13.0" dependencies = [ "comemo", "ecow", @@ -2891,7 +2891,7 @@ dependencies = [ [[package]] name = "typst-kit" -version = "0.12.0" +version = "0.13.0" dependencies = [ "dirs", "ecow", @@ -2914,7 +2914,7 @@ dependencies = [ [[package]] name = "typst-layout" -version = "0.12.0" +version = "0.13.0" dependencies = [ "az", "bumpalo", @@ -2944,7 +2944,7 @@ dependencies = [ [[package]] name = "typst-library" -version = "0.12.0" +version = "0.13.0" dependencies = [ "az", "bitflags 2.8.0", @@ -3004,7 +3004,7 @@ dependencies = [ [[package]] name = "typst-macros" -version = "0.12.0" +version = "0.13.0" dependencies = [ "heck", "proc-macro2", @@ -3014,7 +3014,7 @@ dependencies = [ [[package]] name = "typst-pdf" -version = "0.12.0" +version = "0.13.0" dependencies = [ "arrayvec", "base64", @@ -3040,7 +3040,7 @@ dependencies = [ [[package]] name = "typst-realize" -version = "0.12.0" +version = "0.13.0" dependencies = [ "arrayvec", "bumpalo", @@ -3056,7 +3056,7 @@ dependencies = [ [[package]] name = "typst-render" -version = "0.12.0" +version = "0.13.0" dependencies = [ "bytemuck", "comemo", @@ -3072,7 +3072,7 @@ dependencies = [ [[package]] name = "typst-svg" -version = "0.12.0" +version = "0.13.0" dependencies = [ "base64", "comemo", @@ -3090,7 +3090,7 @@ dependencies = [ [[package]] name = "typst-syntax" -version = "0.12.0" +version = "0.13.0" dependencies = [ "ecow", "serde", @@ -3106,7 +3106,7 @@ dependencies = [ [[package]] name = "typst-tests" -version = "0.12.0" +version = "0.13.0" dependencies = [ "clap", "comemo", @@ -3131,7 +3131,7 @@ dependencies = [ [[package]] name = "typst-timing" -version = "0.12.0" +version = "0.13.0" dependencies = [ "parking_lot", "serde", @@ -3141,7 +3141,7 @@ dependencies = [ [[package]] name = "typst-utils" -version = "0.12.0" +version = "0.13.0" dependencies = [ "once_cell", "portable-atomic", diff --git a/Cargo.toml b/Cargo.toml index 6fb64d3ab0..198aff3c61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ default-members = ["crates/typst-cli"] resolver = "2" [workspace.package] -version = "0.12.0" +version = "0.13.0" rust-version = "1.80" # also change in ci.yml authors = ["The Typst Project Developers"] edition = "2021" @@ -16,24 +16,24 @@ keywords = ["typst"] readme = "README.md" [workspace.dependencies] -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" } -typst-library = { path = "crates/typst-library", version = "0.12.0" } -typst-macros = { path = "crates/typst-macros", version = "0.12.0" } -typst-pdf = { path = "crates/typst-pdf", version = "0.12.0" } -typst-realize = { path = "crates/typst-realize", version = "0.12.0" } -typst-render = { path = "crates/typst-render", version = "0.12.0" } -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 = "8536748" } -typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "7f8999d" } +typst = { path = "crates/typst", version = "0.13.0" } +typst-cli = { path = "crates/typst-cli", version = "0.13.0" } +typst-eval = { path = "crates/typst-eval", version = "0.13.0" } +typst-html = { path = "crates/typst-html", version = "0.13.0" } +typst-ide = { path = "crates/typst-ide", version = "0.13.0" } +typst-kit = { path = "crates/typst-kit", version = "0.13.0" } +typst-layout = { path = "crates/typst-layout", version = "0.13.0" } +typst-library = { path = "crates/typst-library", version = "0.13.0" } +typst-macros = { path = "crates/typst-macros", version = "0.13.0" } +typst-pdf = { path = "crates/typst-pdf", version = "0.13.0" } +typst-realize = { path = "crates/typst-realize", version = "0.13.0" } +typst-render = { path = "crates/typst-render", version = "0.13.0" } +typst-svg = { path = "crates/typst-svg", version = "0.13.0" } +typst-syntax = { path = "crates/typst-syntax", version = "0.13.0" } +typst-timing = { path = "crates/typst-timing", version = "0.13.0" } +typst-utils = { path = "crates/typst-utils", version = "0.13.0" } +typst-assets = { git = "https://github.com/typst/typst-assets", rev = "fa0f8a4" } +typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "61aebe9" } arrayvec = "0.7.4" az = "1.2" base64 = "0.22" diff --git a/docs/changelog/0.13.0.md b/docs/changelog/0.13.0.md index 4212c8251b..6c2fe4275a 100644 --- a/docs/changelog/0.13.0.md +++ b/docs/changelog/0.13.0.md @@ -1,9 +1,9 @@ --- -title: Unreleased changes planned for 0.13.0 -description: Changes slated to appear in Typst 0.13.0 +title: 0.13.0 +description: Changes in Typst 0.13.0 --- -# Unreleased +# Version 0.13.0 (February 19, 2025) ## Highlights - There is now a distinction between [proper paragraphs]($par) and just @@ -339,3 +339,6 @@ feature flag. feature is enabled - Increased minimum supported Rust version to 1.80 - Fixed linux/arm64 Docker image + +## Contributors + diff --git a/docs/changelog/welcome.md b/docs/changelog/welcome.md index bb245eb01d..8fb85f870c 100644 --- a/docs/changelog/welcome.md +++ b/docs/changelog/welcome.md @@ -10,7 +10,7 @@ forward. This section documents all changes to Typst since its initial public release. ## Versions -- [Unreleased changes planned for Typst 0.13.0]($changelog/0.13.0) +- [Typst 0.13.0]($changelog/0.13.0) - [Typst 0.12.0]($changelog/0.12.0) - [Typst 0.11.1]($changelog/0.11.1) - [Typst 0.11.0]($changelog/0.11.0) From 240f238eee4d6dfce7e3c4cabb9315ad052ca230 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 23 Feb 2025 08:26:14 -0300 Subject: [PATCH 021/558] Fix HTML export of table with gutter (#5920) --- .../typst-library/src/layout/grid/resolve.rs | 21 +++++++++++---- crates/typst-library/src/model/table.rs | 2 +- tests/ref/html/col-gutter-table.html | 26 ++++++++++++++++++ tests/ref/html/col-row-gutter-table.html | 26 ++++++++++++++++++ tests/ref/html/row-gutter-table.html | 26 ++++++++++++++++++ tests/suite/layout/grid/html.typ | 27 +++++++++++++++++++ 6 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 tests/ref/html/col-gutter-table.html create mode 100644 tests/ref/html/col-row-gutter-table.html create mode 100644 tests/ref/html/row-gutter-table.html diff --git a/crates/typst-library/src/layout/grid/resolve.rs b/crates/typst-library/src/layout/grid/resolve.rs index f6df57a37c..762f94ed06 100644 --- a/crates/typst-library/src/layout/grid/resolve.rs +++ b/crates/typst-library/src/layout/grid/resolve.rs @@ -1526,11 +1526,7 @@ impl<'a> CellGrid<'a> { self.entry(x, y).map(|entry| match entry { Entry::Cell(_) => Axes::new(x, y), Entry::Merged { parent } => { - let c = if self.has_gutter { - 1 + self.cols.len() / 2 - } else { - self.cols.len() - }; + let c = self.non_gutter_column_count(); let factor = if self.has_gutter { 2 } else { 1 }; Axes::new(factor * (*parent % c), factor * (*parent / c)) } @@ -1602,6 +1598,21 @@ impl<'a> CellGrid<'a> { cell.rowspan.get() } } + + #[inline] + pub fn non_gutter_column_count(&self) -> usize { + if self.has_gutter { + // Calculation: With gutters, we have + // 'cols = 2 * (non-gutter cols) - 1', since there is a gutter + // column between each regular column. Therefore, + // 'floor(cols / 2)' will be equal to + // 'floor(non-gutter cols - 1/2) = non-gutter-cols - 1', + // so 'non-gutter cols = 1 + floor(cols / 2)'. + 1 + self.cols.len() / 2 + } else { + self.cols.len() + } + } } /// Given a cell's requested x and y, the vector with the resolved cell diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs index 82c1cc08b0..6f4461bd44 100644 --- a/crates/typst-library/src/model/table.rs +++ b/crates/typst-library/src/model/table.rs @@ -282,7 +282,7 @@ fn show_cell_html(tag: HtmlTag, cell: &Cell, styles: StyleChain) -> Content { fn show_cellgrid_html(grid: CellGrid, styles: StyleChain) -> Content { let elem = |tag, body| HtmlElem::new(tag).with_body(Some(body)).pack(); - let mut rows: Vec<_> = grid.entries.chunks(grid.cols.len()).collect(); + let mut rows: Vec<_> = grid.entries.chunks(grid.non_gutter_column_count()).collect(); let tr = |tag, row: &[Entry]| { let row = row diff --git a/tests/ref/html/col-gutter-table.html b/tests/ref/html/col-gutter-table.html new file mode 100644 index 0000000000..54170f5340 --- /dev/null +++ b/tests/ref/html/col-gutter-table.html @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + +
abc
def
ghi
+ + diff --git a/tests/ref/html/col-row-gutter-table.html b/tests/ref/html/col-row-gutter-table.html new file mode 100644 index 0000000000..54170f5340 --- /dev/null +++ b/tests/ref/html/col-row-gutter-table.html @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + +
abc
def
ghi
+ + diff --git a/tests/ref/html/row-gutter-table.html b/tests/ref/html/row-gutter-table.html new file mode 100644 index 0000000000..54170f5340 --- /dev/null +++ b/tests/ref/html/row-gutter-table.html @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + +
abc
def
ghi
+ + diff --git a/tests/suite/layout/grid/html.typ b/tests/suite/layout/grid/html.typ index 2a7dfc2cec..10345cb067 100644 --- a/tests/suite/layout/grid/html.typ +++ b/tests/suite/layout/grid/html.typ @@ -30,3 +30,30 @@ [row], ), ) + +--- col-gutter-table html --- +#table( + columns: 3, + column-gutter: 3pt, + [a], [b], [c], + [d], [e], [f], + [g], [h], [i] +) + +--- row-gutter-table html --- +#table( + columns: 3, + row-gutter: 3pt, + [a], [b], [c], + [d], [e], [f], + [g], [h], [i] +) + +--- col-row-gutter-table html --- +#table( + columns: 3, + gutter: 3pt, + [a], [b], [c], + [d], [e], [f], + [g], [h], [i] +) From 55bc5f4c940c86377f1ffe25b42fdb01a6827358 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 23 Feb 2025 11:28:24 +0000 Subject: [PATCH 022/558] Make math shorthands noncontinuable (#5925) --- crates/typst-syntax/src/parser.rs | 9 +++++---- tests/ref/math-shorthands-noncontinuable.png | Bin 0 -> 475 bytes tests/suite/math/syntax.typ | 5 +++++ 3 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 tests/ref/math-shorthands-noncontinuable.png diff --git a/crates/typst-syntax/src/parser.rs b/crates/typst-syntax/src/parser.rs index e187212dac..c5d13c8b33 100644 --- a/crates/typst-syntax/src/parser.rs +++ b/crates/typst-syntax/src/parser.rs @@ -271,10 +271,11 @@ fn math_expr_prec(p: &mut Parser, min_prec: usize, stop: SyntaxKind) { } SyntaxKind::Text | SyntaxKind::MathText | SyntaxKind::MathShorthand => { - continuable = matches!( - math_class(p.current_text()), - None | Some(MathClass::Alphabetic) - ); + continuable = !p.at(SyntaxKind::MathShorthand) + && matches!( + math_class(p.current_text()), + None | Some(MathClass::Alphabetic) + ); if !maybe_delimited(p) { p.eat(); } diff --git a/tests/ref/math-shorthands-noncontinuable.png b/tests/ref/math-shorthands-noncontinuable.png new file mode 100644 index 0000000000000000000000000000000000000000..2f1ad1d14e0ebda63769157fe6a64631dfe58a31 GIT binary patch literal 475 zcmV<10VMv3P)LBEL@~mBuQWyMo>h%CY9761u02MG_@Qx zNm5Z8=fpv+UoiJT-FGuDvw0Smd+zA~qI7VW!yM)?CG6I?KurPr7jBGuE$I%VUVB2D(T@AvTS9$n7S&jjwZk@Yt{W{Hq;&9BN(G8cHT|SwLGrPxe zP=(}Hk1U(3c-niIlHzCF1FwJy%pZIKrjfx&3d5s9fAkE?u2W_@GSiPaKVXVkjKqFc zUs^FK+Sf_)K$--$8)y6^F!)?R0^j&+&lGTQePXOa0oS0ympu;uF}NYrp+Es2nVj_j z3RoOTxR!_(`ju-EcqGtSWd>hWerhD<9tURd%c|j{Rrhx`^UA{)jyrp^<@7qS*~$#= zv5?eSw%SmpD+~Z~9@9`5mS9k9)#A1`h2N?PQ&JcXOI_f_BI-BtEJvo=GJ6=ra{fs) ztDQznf<0WXV2Gc^=wwtNfqmKO0tpPZERew3w}%h~?DLK5`Lf4h4s-Y)z~4s$EOM5) RtCj!&002ovPDHLkV1k2W)8YUC literal 0 HcmV?d00001 diff --git a/tests/suite/math/syntax.typ b/tests/suite/math/syntax.typ index cd1124c37d..7091d908c3 100644 --- a/tests/suite/math/syntax.typ +++ b/tests/suite/math/syntax.typ @@ -13,6 +13,11 @@ $ underline(f' : NN -> RR) \ 1 - 0 thick &..., ) $ +--- math-shorthands-noncontinuable --- +// Test that shorthands are not continuable. +$ x >=(y) / z \ + x >= (y) / z $ + --- math-common-symbols --- // Test common symbols. $ dot \ dots \ ast \ tilde \ star $ From 56f4fa2b4d4d772c5b19c9842419dcc4e078744b Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Sun, 23 Feb 2025 12:31:28 +0100 Subject: [PATCH 023/558] Documentation improvements (#5888) --- crates/typst-library/src/foundations/symbol.rs | 1 + crates/typst-library/src/visualize/color.rs | 2 +- crates/typst-library/src/visualize/gradient.rs | 11 +++++------ docs/reference/groups.yml | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/typst-library/src/foundations/symbol.rs b/crates/typst-library/src/foundations/symbol.rs index 8a80506fef..2c391ee4cb 100644 --- a/crates/typst-library/src/foundations/symbol.rs +++ b/crates/typst-library/src/foundations/symbol.rs @@ -21,6 +21,7 @@ use crate::foundations::{ /// be accessed using [field access notation]($scripting/#fields): /// /// - General symbols are defined in the [`sym` module]($category/symbols/sym) +/// and are accessible without the `sym.` prefix in math mode. /// - Emoji are defined in the [`emoji` module]($category/symbols/emoji) /// /// Moreover, you can define custom symbols with this type's constructor diff --git a/crates/typst-library/src/visualize/color.rs b/crates/typst-library/src/visualize/color.rs index b143125137..20b0f5719e 100644 --- a/crates/typst-library/src/visualize/color.rs +++ b/crates/typst-library/src/visualize/color.rs @@ -130,7 +130,7 @@ static TO_SRGB: LazyLock = LazyLock::new(|| { /// /// # Predefined color maps /// Typst also includes a number of preset color maps that can be used for -/// [gradients]($gradient.linear). These are simply arrays of colors defined in +/// [gradients]($gradient/#stops). These are simply arrays of colors defined in /// the module `color.map`. /// /// ```example diff --git a/crates/typst-library/src/visualize/gradient.rs b/crates/typst-library/src/visualize/gradient.rs index d6530dd095..1a723a9f5f 100644 --- a/crates/typst-library/src/visualize/gradient.rs +++ b/crates/typst-library/src/visualize/gradient.rs @@ -70,6 +70,9 @@ use crate::visualize::{Color, ColorSpace, WeightedColor}; /// the offsets when defining a gradient. In this case, Typst will space all /// stops evenly. /// +/// Typst predefines color maps that you can use as stops. See the +/// [`color`]($color/#predefined-color-maps) documentation for more details. +/// /// # Relativeness /// The location of the `{0%}` and `{100%}` stops depends on the dimensions /// of a container. This container can either be the shape that it is being @@ -157,10 +160,6 @@ use crate::visualize::{Color, ColorSpace, WeightedColor}; /// ) /// ``` /// -/// # Presets -/// Typst predefines color maps that you can use with your gradients. See the -/// [`color`]($color/#predefined-color-maps) documentation for more details. -/// /// # Note on file sizes /// /// Gradients can be quite large, especially if they have many stops. This is @@ -288,7 +287,7 @@ impl Gradient { /// )), /// ) /// ``` - #[func] + #[func(title = "Radial Gradient")] fn radial( span: Span, /// The color [stops](#stops) of the gradient. @@ -402,7 +401,7 @@ impl Gradient { /// )), /// ) /// ``` - #[func] + #[func(title = "Conic Gradient")] pub fn conic( span: Span, /// The color [stops](#stops) of the gradient. diff --git a/docs/reference/groups.yml b/docs/reference/groups.yml index 961d675dc0..8fea3a1f2b 100644 --- a/docs/reference/groups.yml +++ b/docs/reference/groups.yml @@ -170,8 +170,8 @@ category: symbols path: ["emoji"] details: | - Named emoji. + Named emojis. For example, `#emoji.face` produces the 😀 emoji. If you frequently use certain emojis, you can also import them from the `emoji` module (`[#import - emoji: face]`) to use them without the `#emoji.` prefix. + emoji: face]`) to use them without the `emoji.` prefix. From ebe25432641a729780578a2440eaf9fb07c80e38 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 24 Feb 2025 12:17:31 +0100 Subject: [PATCH 024/558] Fix comparison of `Func` and `NativeFuncData` (#5943) --- crates/typst-library/src/foundations/func.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/typst-library/src/foundations/func.rs b/crates/typst-library/src/foundations/func.rs index 3ed1562f6e..66c6b70a5c 100644 --- a/crates/typst-library/src/foundations/func.rs +++ b/crates/typst-library/src/foundations/func.rs @@ -437,10 +437,10 @@ impl PartialEq for Func { } } -impl PartialEq<&NativeFuncData> for Func { - fn eq(&self, other: &&NativeFuncData) -> bool { +impl PartialEq<&'static NativeFuncData> for Func { + fn eq(&self, other: &&'static NativeFuncData) -> bool { match &self.repr { - Repr::Native(native) => native.function == other.function, + Repr::Native(native) => *native == Static(*other), _ => false, } } From 69c3f957051358eff961addbcae4ff02448513dc Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 24 Feb 2025 13:28:01 +0100 Subject: [PATCH 025/558] Bump MSRV to 1.83 and Rust in CI to 1.85 (#5946) --- .github/workflows/ci.yml | 6 +++--- .github/workflows/release.yml | 2 +- Cargo.toml | 2 +- crates/typst-cli/src/compile.rs | 2 +- crates/typst-ide/src/complete.rs | 2 +- crates/typst-layout/src/grid/layouter.rs | 8 ++++---- crates/typst-layout/src/grid/lines.rs | 2 +- crates/typst-layout/src/grid/rowspans.rs | 2 +- crates/typst-layout/src/inline/line.rs | 4 ++-- crates/typst-layout/src/inline/linebreak.rs | 4 ++-- crates/typst-layout/src/inline/shaping.rs | 6 +++--- crates/typst-library/src/foundations/symbol.rs | 2 +- crates/typst-library/src/layout/grid/resolve.rs | 2 +- crates/typst-library/src/text/font/book.rs | 2 +- crates/typst-library/src/text/shift.rs | 2 +- crates/typst-pdf/src/outline.rs | 4 ++-- crates/typst-syntax/src/node.rs | 2 +- crates/typst-syntax/src/package.rs | 4 ++-- crates/typst-utils/src/scalar.rs | 13 +------------ flake.lock | 6 +++--- flake.nix | 2 +- tests/src/collect.rs | 4 ++-- tests/src/run.rs | 2 +- 23 files changed, 37 insertions(+), 48 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01b3e8c3a1..9f0ada9f9f 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.83.0 + - uses: dtolnay/rust-toolchain@1.85.0 - uses: Swatinem/rust-cache@v2 - run: cargo test --workspace --no-run - run: cargo test --workspace --no-fail-fast @@ -59,7 +59,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.83.0 + - uses: dtolnay/rust-toolchain@1.85.0 with: components: clippy, rustfmt - uses: Swatinem/rust-cache@v2 @@ -73,7 +73,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.80.0 + - uses: dtolnay/rust-toolchain@1.83.0 - uses: Swatinem/rust-cache@v2 - run: cargo check --workspace diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5be6bfa2cf..0d235aec5d 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.83.0 + - uses: dtolnay/rust-toolchain@1.85.0 with: target: ${{ matrix.target }} diff --git a/Cargo.toml b/Cargo.toml index 198aff3c61..36195230e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.package] version = "0.13.0" -rust-version = "1.80" # also change in ci.yml +rust-version = "1.83" # also change in ci.yml authors = ["The Typst Project Developers"] edition = "2021" homepage = "https://typst.app" diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index 2b6a7d820b..ae71e298cc 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -350,7 +350,7 @@ fn export_image( .iter() .enumerate() .filter(|(i, _)| { - config.pages.as_ref().map_or(true, |exported_page_ranges| { + config.pages.as_ref().is_none_or(|exported_page_ranges| { exported_page_ranges.includes_page_index(*i) }) }) diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index 564b97bd77..e3dcc442ef 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -1455,7 +1455,7 @@ impl<'a> CompletionContext<'a> { let mut defined = BTreeMap::>::new(); named_items(self.world, self.leaf.clone(), |item| { let name = item.name(); - if !name.is_empty() && item.value().as_ref().map_or(true, filter) { + if !name.is_empty() && item.value().as_ref().is_none_or(filter) { defined.insert(name.clone(), item.value()); } diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 1f9cf67963..af47ff72f9 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -1377,7 +1377,7 @@ impl<'a> GridLayouter<'a> { .footer .as_ref() .and_then(Repeatable::as_repeated) - .map_or(true, |footer| footer.start != header.end) + .is_none_or(|footer| footer.start != header.end) && self.lrows.last().is_some_and(|row| row.index() < header.end) && !in_last_with_offset( self.regions, @@ -1446,7 +1446,7 @@ impl<'a> GridLayouter<'a> { .iter_mut() .filter(|rowspan| (rowspan.y..rowspan.y + rowspan.rowspan).contains(&y)) .filter(|rowspan| { - rowspan.max_resolved_row.map_or(true, |max_row| y > max_row) + rowspan.max_resolved_row.is_none_or(|max_row| y > max_row) }) { // If the first region wasn't defined yet, it will have the @@ -1494,7 +1494,7 @@ impl<'a> GridLayouter<'a> { // laid out at the first frame of the row). // Any rowspans ending before this row are laid out even // on this row's first frame. - if laid_out_footer_start.map_or(true, |footer_start| { + if laid_out_footer_start.is_none_or(|footer_start| { // If this is a footer row, then only lay out this rowspan // if the rowspan is contained within the footer. y < footer_start || rowspan.y >= footer_start @@ -1580,5 +1580,5 @@ pub(super) fn points( /// our case, headers). pub(super) fn in_last_with_offset(regions: Regions<'_>, offset: Abs) -> bool { regions.backlog.is_empty() - && regions.last.map_or(true, |height| regions.size.y + offset == height) + && regions.last.is_none_or(|height| regions.size.y + offset == height) } diff --git a/crates/typst-layout/src/grid/lines.rs b/crates/typst-layout/src/grid/lines.rs index 1227953d10..7549673f1d 100644 --- a/crates/typst-layout/src/grid/lines.rs +++ b/crates/typst-layout/src/grid/lines.rs @@ -463,7 +463,7 @@ pub fn hline_stroke_at_column( // region, we have the last index, and (as a failsafe) we don't have the // last row of cells above us. let use_bottom_border_stroke = !in_last_region - && local_top_y.map_or(true, |top_y| top_y + 1 != grid.rows.len()) + && local_top_y.is_none_or(|top_y| top_y + 1 != grid.rows.len()) && y == grid.rows.len(); let bottom_y = if use_bottom_border_stroke { grid.rows.len().saturating_sub(1) } else { y }; diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs index 5039695d84..21992ed02d 100644 --- a/crates/typst-layout/src/grid/rowspans.rs +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -588,7 +588,7 @@ impl GridLayouter<'_> { measurement_data: &CellMeasurementData<'_>, ) -> bool { if sizes.len() <= 1 - && sizes.first().map_or(true, |&first_frame_size| { + && sizes.first().is_none_or(|&first_frame_size| { first_frame_size <= measurement_data.height_in_this_region }) { diff --git a/crates/typst-layout/src/inline/line.rs b/crates/typst-layout/src/inline/line.rs index bd08f30efd..659d33f4a1 100644 --- a/crates/typst-layout/src/inline/line.rs +++ b/crates/typst-layout/src/inline/line.rs @@ -154,7 +154,7 @@ pub fn line<'a>( let mut items = collect_items(engine, p, range, trim); // Add a hyphen at the line start, if a previous dash should be repeated. - if pred.map_or(false, |pred| should_repeat_hyphen(pred, full)) { + if pred.is_some_and(|pred| should_repeat_hyphen(pred, full)) { if let Some(shaped) = items.first_text_mut() { shaped.prepend_hyphen(engine, p.config.fallback); } @@ -406,7 +406,7 @@ fn should_repeat_hyphen(pred_line: &Line, text: &str) -> bool { // // See § 4.1.1.1.2.e on the "Ortografía de la lengua española" // https://www.rae.es/ortografía/como-signo-de-división-de-palabras-a-final-de-línea - Lang::SPANISH => text.chars().next().map_or(false, |c| !c.is_uppercase()), + Lang::SPANISH => text.chars().next().is_some_and(|c| !c.is_uppercase()), _ => false, } diff --git a/crates/typst-layout/src/inline/linebreak.rs b/crates/typst-layout/src/inline/linebreak.rs index a9f21188b4..31512604f3 100644 --- a/crates/typst-layout/src/inline/linebreak.rs +++ b/crates/typst-layout/src/inline/linebreak.rs @@ -290,7 +290,7 @@ fn linebreak_optimized_bounded<'a>( } // If this attempt is better than what we had before, take it! - if best.as_ref().map_or(true, |best| best.total >= total) { + if best.as_ref().is_none_or(|best| best.total >= total) { best = Some(Entry { pred: pred_index, total, line: attempt, end }); } } @@ -423,7 +423,7 @@ fn linebreak_optimized_approximate( let total = pred.total + line_cost; // If this attempt is better than what we had before, take it! - if best.as_ref().map_or(true, |best| best.total >= total) { + if best.as_ref().is_none_or(|best| best.total >= total) { best = Some(Entry { pred: pred_index, total, diff --git a/crates/typst-layout/src/inline/shaping.rs b/crates/typst-layout/src/inline/shaping.rs index b688981ae1..159619eb3a 100644 --- a/crates/typst-layout/src/inline/shaping.rs +++ b/crates/typst-layout/src/inline/shaping.rs @@ -465,7 +465,7 @@ impl<'a> ShapedText<'a> { None }; let mut chain = families(self.styles) - .filter(|family| family.covers().map_or(true, |c| c.is_match("-"))) + .filter(|family| family.covers().is_none_or(|c| c.is_match("-"))) .map(|family| book.select(family.as_str(), self.variant)) .chain(fallback_func.iter().map(|f| f())) .flatten(); @@ -570,7 +570,7 @@ impl<'a> ShapedText<'a> { // for the next line. let dec = if ltr { usize::checked_sub } else { usize::checked_add }; while let Some(next) = dec(idx, 1) { - if self.glyphs.get(next).map_or(true, |g| g.range.start != text_index) { + if self.glyphs.get(next).is_none_or(|g| g.range.start != text_index) { break; } idx = next; @@ -812,7 +812,7 @@ fn shape_segment<'a>( .nth(1) .map(|(i, _)| offset + i) .unwrap_or(text.len()); - covers.map_or(true, |cov| cov.is_match(&text[offset..end])) + covers.is_none_or(|cov| cov.is_match(&text[offset..end])) }; // Collect the shaped glyphs, doing fallback and shaping parts again with diff --git a/crates/typst-library/src/foundations/symbol.rs b/crates/typst-library/src/foundations/symbol.rs index 2c391ee4cb..50fcfb4030 100644 --- a/crates/typst-library/src/foundations/symbol.rs +++ b/crates/typst-library/src/foundations/symbol.rs @@ -411,7 +411,7 @@ fn find<'a>( } let score = (matching, Reverse(total)); - if best_score.map_or(true, |b| score > b) { + if best_score.is_none_or(|b| score > b) { best = Some(candidate.1); best_score = Some(score); } diff --git a/crates/typst-library/src/layout/grid/resolve.rs b/crates/typst-library/src/layout/grid/resolve.rs index 762f94ed06..08d0130dab 100644 --- a/crates/typst-library/src/layout/grid/resolve.rs +++ b/crates/typst-library/src/layout/grid/resolve.rs @@ -1387,7 +1387,7 @@ impl<'a> CellGrid<'a> { // Include the gutter right before the footer, unless there is // none, or the gutter is already included in the header (no // rows between the header and the footer). - if header_end.map_or(true, |header_end| header_end != footer.start) { + if header_end != Some(footer.start) { footer.start = footer.start.saturating_sub(1); } } diff --git a/crates/typst-library/src/text/font/book.rs b/crates/typst-library/src/text/font/book.rs index 23e27f64c4..9f8acce878 100644 --- a/crates/typst-library/src/text/font/book.rs +++ b/crates/typst-library/src/text/font/book.rs @@ -160,7 +160,7 @@ impl FontBook { current.variant.weight.distance(variant.weight), ); - if best_key.map_or(true, |b| key < b) { + if best_key.is_none_or(|b| key < b) { best = Some(id); best_key = Some(key); } diff --git a/crates/typst-library/src/text/shift.rs b/crates/typst-library/src/text/shift.rs index 3eec0758bd..dbf1be8a16 100644 --- a/crates/typst-library/src/text/shift.rs +++ b/crates/typst-library/src/text/shift.rs @@ -159,7 +159,7 @@ fn is_shapable(engine: &Engine, text: &str, styles: StyleChain) -> bool { { let covers = family.covers(); return text.chars().all(|c| { - covers.map_or(true, |cov| cov.is_match(c.encode_utf8(&mut [0; 4]))) + covers.is_none_or(|cov| cov.is_match(c.encode_utf8(&mut [0; 4]))) && font.ttf().glyph_index(c).is_some() }); } diff --git a/crates/typst-pdf/src/outline.rs b/crates/typst-pdf/src/outline.rs index ff72eb86af..eff1182c1a 100644 --- a/crates/typst-pdf/src/outline.rs +++ b/crates/typst-pdf/src/outline.rs @@ -70,7 +70,7 @@ pub(crate) fn write_outline( // (not exceeding whichever is the most restrictive depth limit // of those two). while children.last().is_some_and(|last| { - last_skipped_level.map_or(true, |l| last.level < l) + last_skipped_level.is_none_or(|l| last.level < l) && last.level < leaf.level }) { children = &mut children.last_mut().unwrap().children; @@ -83,7 +83,7 @@ pub(crate) fn write_outline( // needed, following the usual rules listed above. last_skipped_level = None; children.push(leaf); - } else if last_skipped_level.map_or(true, |l| leaf.level < l) { + } else if last_skipped_level.is_none_or(|l| leaf.level < l) { // Only the topmost / lowest-level skipped heading matters when you // have consecutive skipped headings (since none of them are being // added to the bookmark tree), hence the condition above. diff --git a/crates/typst-syntax/src/node.rs b/crates/typst-syntax/src/node.rs index b7e1809d7c..fde2eaca0f 100644 --- a/crates/typst-syntax/src/node.rs +++ b/crates/typst-syntax/src/node.rs @@ -753,7 +753,7 @@ impl<'a> LinkedNode<'a> { // sibling's span number is larger than the target span's number. if children .peek() - .map_or(true, |next| next.span().number() > span.number()) + .is_none_or(|next| next.span().number() > span.number()) { if let Some(found) = child.find(span) { return Some(found); diff --git a/crates/typst-syntax/src/package.rs b/crates/typst-syntax/src/package.rs index 387057f375..aa537863d2 100644 --- a/crates/typst-syntax/src/package.rs +++ b/crates/typst-syntax/src/package.rs @@ -327,8 +327,8 @@ impl PackageVersion { /// missing in the bound are ignored. pub fn matches_eq(&self, bound: &VersionBound) -> bool { self.major == bound.major - && bound.minor.map_or(true, |minor| self.minor == minor) - && bound.patch.map_or(true, |patch| self.patch == patch) + && bound.minor.is_none_or(|minor| self.minor == minor) + && bound.patch.is_none_or(|patch| self.patch == patch) } /// Performs a `>` match with the given version bound. The match only diff --git a/crates/typst-utils/src/scalar.rs b/crates/typst-utils/src/scalar.rs index 4036c23103..6d84fbfdf1 100644 --- a/crates/typst-utils/src/scalar.rs +++ b/crates/typst-utils/src/scalar.rs @@ -28,7 +28,7 @@ impl Scalar { /// /// If the value is NaN, then it is set to `0.0` in the result. pub const fn new(x: f64) -> Self { - Self(if is_nan(x) { 0.0 } else { x }) + Self(if x.is_nan() { 0.0 } else { x }) } /// Gets the value of this [`Scalar`]. @@ -37,17 +37,6 @@ impl Scalar { } } -// We have to detect NaNs this way since `f64::is_nan` isn’t const -// on stable yet: -// ([tracking issue](https://github.com/rust-lang/rust/issues/57241)) -#[allow(clippy::unusual_byte_groupings)] -const fn is_nan(x: f64) -> bool { - // Safety: all bit patterns are valid for u64, and f64 has no padding bits. - // We cannot use `f64::to_bits` because it is not const. - let x_bits = unsafe { std::mem::transmute::(x) }; - (x_bits << 1 >> (64 - 12 + 1)) == 0b0_111_1111_1111 && (x_bits << 12) != 0 -} - impl Numeric for Scalar { fn zero() -> Self { Self(0.0) diff --git a/flake.lock b/flake.lock index c024664224..ad47d29cde 100644 --- a/flake.lock +++ b/flake.lock @@ -112,13 +112,13 @@ "rust-manifest": { "flake": false, "locked": { - "narHash": "sha256-Yqu2/i9170R7pQhvOCR1f5SyFr7PcFbO6xcMr9KWruQ=", + "narHash": "sha256-irgHsBXecwlFSdmP9MfGP06Cbpca2QALJdbN4cymcko=", "type": "file", - "url": "https://static.rust-lang.org/dist/channel-rust-1.83.0.toml" + "url": "https://static.rust-lang.org/dist/channel-rust-1.85.0.toml" }, "original": { "type": "file", - "url": "https://static.rust-lang.org/dist/channel-rust-1.83.0.toml" + "url": "https://static.rust-lang.org/dist/channel-rust-1.85.0.toml" } }, "systems": { diff --git a/flake.nix b/flake.nix index abdad27aae..6938f6e571 100644 --- a/flake.nix +++ b/flake.nix @@ -10,7 +10,7 @@ inputs.nixpkgs.follows = "nixpkgs"; }; rust-manifest = { - url = "https://static.rust-lang.org/dist/channel-rust-1.83.0.toml"; + url = "https://static.rust-lang.org/dist/channel-rust-1.85.0.toml"; flake = false; }; }; diff --git a/tests/src/collect.rs b/tests/src/collect.rs index c6deba77b2..33f4f73668 100644 --- a/tests/src/collect.rs +++ b/tests/src/collect.rs @@ -149,7 +149,7 @@ impl Collector { for entry in walkdir::WalkDir::new(crate::SUITE_PATH).sort_by_file_name() { let entry = entry.unwrap(); let path = entry.path(); - if !path.extension().is_some_and(|ext| ext == "typ") { + if path.extension().is_none_or(|ext| ext != "typ") { continue; } @@ -168,7 +168,7 @@ impl Collector { for entry in walkdir::WalkDir::new(crate::REF_PATH).sort_by_file_name() { let entry = entry.unwrap(); let path = entry.path(); - if !path.extension().is_some_and(|ext| ext == "png") { + if path.extension().is_none_or(|ext| ext != "png") { continue; } diff --git a/tests/src/run.rs b/tests/src/run.rs index f9a3c04340..4d08362cf3 100644 --- a/tests/src/run.rs +++ b/tests/src/run.rs @@ -161,7 +161,7 @@ impl<'a> Runner<'a> { // Compare against reference output if available. // Test that is ok doesn't need to be updated. - if ref_data.as_ref().map_or(false, |r| D::matches(&live, r)) { + if ref_data.as_ref().is_ok_and(|r| D::matches(&live, r)) { return; } From 81efc82d3c0f7ccbcb40959ac8bddeca49e4c9f8 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 24 Feb 2025 16:05:36 +0000 Subject: [PATCH 026/558] Fix math accent base height calculation (#5941) --- crates/typst-layout/src/math/accent.rs | 4 ++-- tests/ref/gradient-math-misc.png | Bin 2993 -> 2993 bytes tests/ref/issue-math-realize-scripting.png | Bin 2610 -> 2605 bytes tests/ref/math-accent-align.png | Bin 614 -> 625 bytes tests/ref/math-accent-bounds.png | Bin 327 -> 327 bytes tests/ref/math-accent-dotless.png | Bin 1026 -> 1024 bytes tests/ref/math-accent-func.png | Bin 284 -> 284 bytes tests/ref/math-accent-sym-call.png | Bin 926 -> 930 bytes tests/ref/math-spacing-decorated.png | Bin 2385 -> 2375 bytes 9 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/typst-layout/src/math/accent.rs b/crates/typst-layout/src/math/accent.rs index 951870d68c..f2dfa2c45a 100644 --- a/crates/typst-layout/src/math/accent.rs +++ b/crates/typst-layout/src/math/accent.rs @@ -34,7 +34,7 @@ pub fn layout_accent( // Try to replace accent glyph with flattened variant. let flattened_base_height = scaled!(ctx, styles, flattened_accent_base_height); - if base.height() > flattened_base_height { + if base.ascent() > flattened_base_height { glyph.make_flattened_accent_form(ctx); } @@ -50,7 +50,7 @@ pub fn layout_accent( // minus the accent base height. Only if the base is very small, we need // a larger gap so that the accent doesn't move too low. let accent_base_height = scaled!(ctx, styles, accent_base_height); - let gap = -accent.descent() - base.height().min(accent_base_height); + let gap = -accent.descent() - base.ascent().min(accent_base_height); let size = Size::new(base.width(), accent.height() + gap + base.height()); let accent_pos = Point::with_x(base_attach - accent_attach); let base_pos = Point::with_y(accent.height() + gap); diff --git a/tests/ref/gradient-math-misc.png b/tests/ref/gradient-math-misc.png index acf14c6fe20e84146001d4117700a949a4fda6d1..13f5c27b38c0a80e502068b2784146ae58b838ed 100644 GIT binary patch delta 2915 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 N002ovPDHLkV1m<1w@d&4 diff --git a/tests/ref/issue-math-realize-scripting.png b/tests/ref/issue-math-realize-scripting.png index ee2d4cdf7a64d95e4bd8fffe538a89de1bde1ae4..7d721ed776199bd6611d1178bb7582c1dd6fe7c8 100644 GIT binary patch delta 2596 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 LNkvXXu0mjf*}K?_ diff --git a/tests/ref/math-accent-align.png b/tests/ref/math-accent-align.png index 84e8dc8ccda955a6b747d1462610b51219f43f43..efc66ec3faf784dcc17fcb8d3fbcb7a58c151773 100644 GIT binary patch delta 600 zcmV-e0;m1v1n~rrB!4(bL_t(|+U?ihOH*MS$8rBN$w3kfBPv$eMcov2VL?y{MM8Gd z57tG+P#LwUIa5}x)1Z##bk5AS;bf#!r!0#&JL+oI**ZJt6W_~c-3eb0>hr#O_};vD z9u8kP4RELi2}@YQ65cUb>$&>GV*>U>jQR%P-C18S!VZ7Ng@1IO4#^ox1Mso6P;xWF z)B#ihSiJ%23r(7!o@a+U7MiOeIutdk0wCwcnPql(Tuls_5bElDzUahPZ6D16PQZo1 zDY-x-yvt$Ra0piK?60S;1Rf6Gt^qLqP!@pf{ngoic6h|RqP_-T`5sdl0MU;4#$9HZ zI%tB*%~L<=*ne#QNU*~LS`#7wthaBdJk47066|o-i$JdiF@N-{+*}ft@HXI$?hn(B zz$jG>N8s4#oC-00c-~-##}}^$+D*Xn-lNj=_$R8K1Up>Dkxhu*>c=6I9WGjp$UwO9 zsg*5vmK{FXb1pOkA*IjXs<6YbQ2T#m%grU>T?`lW@_&+*L0vXKocq2$_csAC^V%MM z>R)hCJgvkbG!Rw7{BUK< mETOK_Z@IZ7EMW=nVE+J61?9wOxq%M=0000@y^($#u_1zm+% zU4*?s7G`a@&AB-$v&h;&a;CDE7F1JbTFoVF%9^cBx3hD;@m!wW6+9kf&-Wkra9;R5 z@U&o2Eeck!f)%`FaK7KsdrSi(@=o*R>~LN6 zq%jrs`p-wyfGAkO{|tXvT$!r8q@e}*aG_Gmv5YybPk%m4ZA1S}i{HEd0uQ^@uw>x;^K>s?!o;_IoS)CbKf?i6j{pu)Hwcu$%!1JYD@< J);T3K0RVa;6*B+; delta 53 zcmX@kbew6z2hrCT?4_2y>JfK%bbHQ$Ri`Dy>}_k?CbKf?iKI9dFkf_)7H0qgPgg&e IbxsLQ0CcJp7XSbN diff --git a/tests/ref/math-accent-dotless.png b/tests/ref/math-accent-dotless.png index 81eb4fa2bd7b48cba24fa3e1c8d6beda09bb6506..389ceb634aad124cc19196123c8ea188a72a0633 100644 GIT binary patch delta 1002 zcmVCXn9AOw3HbC%@)O|M?u=yLe9RO2H|3+iKUIDXjeaBY#OPp@e5UJcz12iXlDm zA-RGw>lT-6aDPQ+rU#X9caat;a~o`z$u4bE=^-+Nsx>^#}PaW7~pC^5tMD}(lY;D7d!kM2=(2TCgkMhb!Qj!O}a z2H@(cc;N`l?XGb?JO_dO$D+kU0)M>-9WPx|3DdhuuN~(9W=;_$ox znC}j#fGO#e89pWxerIQRGc%kB6MNad9ennP8hGEDw-LBnO{*JLGqLn z5d-eF3A~mIP@NM8OpCICa$<)^=1*hN?`~t!M Y0Al&n0a`kXD*ylh07*qoM6N<$g5P%2D*ylh delta 1004 zcmVpnSbg;BivD_N6K0T_;x!|MRqw-ah_GybnFYHL&Y-nmojC2*{5Wi&jGA_ z`Lb4c>d1g>JVVCz2}q4)NJZVfvcY%VNNoZ$d^8v7z<3a&m=q@cuH_(-~R?Le-F|~6~J^2$x~2djYBil z0Mr`*R;oh3O+k2Q=(;XBjQ&@dr6Y^LeCaI{K)g7=I0V30Q&UrfwN5(a=`RLAHIzIA zp!AB&!a*CbIy!2o)c_M?U2)&qF~)!HUWDYZy?^RDQg#^NyKP9f-`@|?Pq|(|a(*0_ z?Ro>rd+ohW0QPQ0%2WZyUO%7#HuoQwjik#2G5~2CNI%rgfTZ7X4x}8Uj&M#n0E1~r z=>qfh4h=A8+hhjz$*jJ$IULPeO@xWP=-3E8xkn4UeaTk`T&$qc1B+SMde-1N9|GSo z1AkCf0o<4oI9LVH_ZrR?1RggE>}dq@^EJTncL*EOd4!2`44 z_rQD2(|yP9dwM`@0K7wD~l&E{xDzw0#8>zmvv4FO#o$i9smFU delta 69 zcmbQkG>2(|yBc@TM`@0K7wz|+;wWt~$(698sT9m@a! diff --git a/tests/ref/math-accent-sym-call.png b/tests/ref/math-accent-sym-call.png index 0837a86c9e861a960cbca4d157ea7e719b54cd0f..609197f3c3da71d9f4b4efa72b92bcc64311b935 100644 GIT binary patch delta 908 zcmV;719SYI2cid%B!6j1L_t(|+U?cdPg`XG$MMC##Ka44jLDMua>Iqg7~_IVhFRvK zbB3sM5rUh8xj`l{7fet$L4t{Mq@fFRFaaAFAVpxPY|t|3RyMKix{j`ID?0^B*VFSg zd7hI>7Bwc%7B1G$&G{wg;`=;T&n7m=6Pq?jKD@!<3F@gD2)<+#AwBM2 zK=9&K3u`u{m%$W!>&GlWrz1WFz-qO#4M87W^dQtmE7iicQwY&rw~-9%r)dD@8!=rA zU`j@i!ZU_axPNk}yaRx)J#$qE_x7BO0iYdhmZxg8pXf&DZLpNeLmjyEp-T9|$CP>i zD7%{7g0wS^(B|;e05CiF%?I+n&olG|0I|NV=TyQK{S-z3*v`dwkqVLg$9v8LQ2xEA zBn)tIKSzGO3t->7QV>=MpX(cV&B{kN;j{32x7eKol7BmZnkgVs8K=w#1%Wd~JvTmq z#KAvaY`<&mfVv-?&z?~TGkeFQarH7Vp}{~1vcEI~0SBaF!5Tc1ppS=d+|O8nJG1cx z;&#C8j;h>P`We#@@W$a3FsVVX(>lzsfs}P%3S3804rkVnP!w|!TNFbw3``jm!byNf&iZ%x&=Tn zf?=@?0uZ=dxJJ?Z2*L{W2LN+dSECjl+Z^C*1fdF{VpQ7-fYbK^6i3b@S$31tQi598 zb!w`3aS*8=fVF2Btpi~7rz6hg&DX{e241~SJ5u*{UAHNPs~b4FpMaM10iynIiXDzy5op9!~y=;KG!+2{|svnaMwk i`EWj*4?kAnzW^mukOPEx)hPe~002ovP6b4+LSTZ{g0Nix delta 904 zcmV;319$wQ2c8FzB!6W|L_t(|+U?cdPg7?A$8od2WXUdevn(-jFE?CFmSveCCbAf3 zHs?aNIWq{;S)4)~;0#OHrh^fd%t<C+g0$zm zz7@PCBL+)wE^rpA2%fUaXT zBLD>5Zuc7M?i*f>V2b-01IQq2Bm#ke@wmfuMLV}k%JSGb!IR_rq_gsMoHTKIMvA+qZ>lA(RE3cz$7NK-mU z$uLrA+)xUa^?#PO0?@H%vK(P?&&enNiGvODRCSIMHiQec)?#_610$cPgfD(Zu@``n zYuPPGJ7WmVPH!au6FuL5Ebsd=yTLPjAAPp$~7dI|(Fr0DqM(AY2xs#0Po$nF9Nb&mn&B zpI2M%XsuBFv-`y}3SlPZy$P;e1%?vP9fa(q20(X0IvS|NI}Dl2p&NJCG+=%rHbcw- zcsvo68%w`~)C;_QI1LOZAkeP$(PtoKognGgk(9%0{~y7Kxrok*AsNQ{R|;Vzxo|H0 zNZ~ldd4CWCj3*UtuC8(-m8ZBzU(Lx@Sl4bKO-?I^Nv?tPm%+V4*TK}UY1{lrmJ^y5 zQww|k#?+lfn74tH868Sv3I~I;NuDd56)1@f{e@ifKGY{6kt<~{- z8-im;+lA*GN@06rGo~$RgwzO<)l=IsdTt4!A%8>3H%RpcMU5zh{dqcH9l8ZTA%bDG z_5h$?&0nElx*uVQngd|&iVD=igPV2Eh7rmUECY$H0JwdxKw@5GXEJR{;fh+0?nh4~eZYXhBrNR2((~t{ z4u9C^IxqUpt{5J`m6zc3AQ>+(^x86~MwP<8eW#m_f7is=M}Q@@v=z{7Kh!0LivgSt zM@M^l;SFH#vHGLC3_=CKJ5}|E-$Nz5&SgF%b3J^c9!~y?Fa}cmCX)HqG@kn{=EAvf eF8ox5{{vrCkOUezRbl`D002ovPDHLkU;%=IT%-#C diff --git a/tests/ref/math-spacing-decorated.png b/tests/ref/math-spacing-decorated.png index b8846ff0595667a56f7004ac4b0fcc5925670200..2f3c704e5d981ce119ac1e903e154c4a186e1553 100644 GIT binary patch delta 2364 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 From 3744c99b07f97a954a8468bef5fdb08c5c7914d7 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Mon, 24 Feb 2025 17:15:17 +0100 Subject: [PATCH 027/558] Override the default math class of some characters (#5949) --- crates/typst-utils/src/lib.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/typst-utils/src/lib.rs b/crates/typst-utils/src/lib.rs index 34d6a94325..b346a80963 100644 --- a/crates/typst-utils/src/lib.rs +++ b/crates/typst-utils/src/lib.rs @@ -360,6 +360,21 @@ pub fn default_math_class(c: char) -> Option { // https://github.com/typst/typst/pull/5714 '\u{22A5}' => Some(MathClass::Normal), + // Used as a binary connector in linear logic, where it is referred to + // as "par". + // https://github.com/typst/typst/issues/5764 + '⅋' => Some(MathClass::Binary), + + // Those overrides should become the default in the next revision of + // MathClass.txt. + // https://github.com/typst/typst/issues/5764#issuecomment-2632435247 + '⎰' | '⟅' => Some(MathClass::Opening), + '⎱' | '⟆' => Some(MathClass::Closing), + + // Both ∨ and ⟑ are classified as Binary. + // https://github.com/typst/typst/issues/5764 + '⟇' => Some(MathClass::Binary), + c => unicode_math_class::class(c), } } From 36d83c8c092e7984eaa03dbecc1083f49da13129 Mon Sep 17 00:00:00 2001 From: Sharzy Date: Tue, 25 Feb 2025 00:35:13 +0800 Subject: [PATCH 028/558] HTML export: fix elem counting on classify_output (#5910) Co-authored-by: Laurenz --- crates/typst-html/src/lib.rs | 6 +++--- tests/ref/html/html-elem-alone-context.html | 2 ++ tests/suite/html/elem.typ | 7 +++++++ 3 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 tests/ref/html/html-elem-alone-context.html create mode 100644 tests/suite/html/elem.typ diff --git a/crates/typst-html/src/lib.rs b/crates/typst-html/src/lib.rs index 25d0cd5d8f..236a325440 100644 --- a/crates/typst-html/src/lib.rs +++ b/crates/typst-html/src/lib.rs @@ -307,18 +307,18 @@ fn head_element(info: &DocumentInfo) -> HtmlElement { /// Determine which kind of output the user generated. fn classify_output(mut output: Vec) -> SourceResult { - let len = output.len(); + let count = output.iter().filter(|node| !matches!(node, HtmlNode::Tag(_))).count(); 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) { + match (tag, count) { (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 + elem.tag, ), _ => {} } diff --git a/tests/ref/html/html-elem-alone-context.html b/tests/ref/html/html-elem-alone-context.html new file mode 100644 index 0000000000..69e9da4114 --- /dev/null +++ b/tests/ref/html/html-elem-alone-context.html @@ -0,0 +1,2 @@ + + diff --git a/tests/suite/html/elem.typ b/tests/suite/html/elem.typ new file mode 100644 index 0000000000..81ab945776 --- /dev/null +++ b/tests/suite/html/elem.typ @@ -0,0 +1,7 @@ +--- html-elem-alone-context html --- +#context html.elem("html") + +--- html-elem-not-alone html --- +// Error: 2-19 `` element must be the only element in the document +#html.elem("html") +Text From 225e845021b9cfb37e6dc719c8bc85ccdc1ff69f Mon Sep 17 00:00:00 2001 From: Laurenz Date: Tue, 25 Feb 2025 12:31:15 +0100 Subject: [PATCH 029/558] Fix introspection of HTML root sibling metadata (#5953) --- crates/typst-html/src/lib.rs | 2 +- .../src/introspection/introspector.rs | 18 +++++++++--------- tests/ref/html/html-elem-metadata.html | 2 ++ tests/suite/html/elem.typ | 8 ++++++++ 4 files changed, 20 insertions(+), 10 deletions(-) create mode 100644 tests/ref/html/html-elem-metadata.html diff --git a/crates/typst-html/src/lib.rs b/crates/typst-html/src/lib.rs index 236a325440..aa769976e7 100644 --- a/crates/typst-html/src/lib.rs +++ b/crates/typst-html/src/lib.rs @@ -83,8 +83,8 @@ fn html_document_impl( )?; let output = handle_list(&mut engine, &mut locator, children.iter().copied())?; + let introspector = Introspector::html(&output); let root = root_element(output, &info)?; - let introspector = Introspector::html(&root); Ok(HtmlDocument { info, root, introspector }) } diff --git a/crates/typst-library/src/introspection/introspector.rs b/crates/typst-library/src/introspection/introspector.rs index 8cbaea8917..9751dfcb81 100644 --- a/crates/typst-library/src/introspection/introspector.rs +++ b/crates/typst-library/src/introspection/introspector.rs @@ -10,7 +10,7 @@ use typst_utils::NonZeroExt; use crate::diag::{bail, StrResult}; use crate::foundations::{Content, Label, Repr, Selector}; -use crate::html::{HtmlElement, HtmlNode}; +use crate::html::HtmlNode; use crate::introspection::{Location, Tag}; use crate::layout::{Frame, FrameItem, Page, Point, Position, Transform}; use crate::model::Numbering; @@ -55,8 +55,8 @@ impl Introspector { /// Creates an introspector for HTML. #[typst_macros::time(name = "introspect html")] - pub fn html(root: &HtmlElement) -> Self { - IntrospectorBuilder::new().build_html(root) + pub fn html(output: &[HtmlNode]) -> Self { + IntrospectorBuilder::new().build_html(output) } /// Iterates over all locatable elements. @@ -392,9 +392,9 @@ impl IntrospectorBuilder { } /// Build an introspector for an HTML document. - fn build_html(mut self, root: &HtmlElement) -> Introspector { + fn build_html(mut self, output: &[HtmlNode]) -> Introspector { let mut elems = Vec::new(); - self.discover_in_html(&mut elems, root); + self.discover_in_html(&mut elems, output); self.finalize(elems) } @@ -434,16 +434,16 @@ impl IntrospectorBuilder { } /// Processes the tags in the HTML element. - fn discover_in_html(&mut self, sink: &mut Vec, elem: &HtmlElement) { - for child in &elem.children { - match child { + fn discover_in_html(&mut self, sink: &mut Vec, nodes: &[HtmlNode]) { + for node in nodes { + match node { HtmlNode::Tag(tag) => self.discover_in_tag( sink, tag, Position { page: NonZeroUsize::ONE, point: Point::zero() }, ), HtmlNode::Text(_, _) => {} - HtmlNode::Element(elem) => self.discover_in_html(sink, elem), + HtmlNode::Element(elem) => self.discover_in_html(sink, &elem.children), HtmlNode::Frame(frame) => self.discover_in_frame( sink, frame, diff --git a/tests/ref/html/html-elem-metadata.html b/tests/ref/html/html-elem-metadata.html new file mode 100644 index 0000000000..c37a7d2ef4 --- /dev/null +++ b/tests/ref/html/html-elem-metadata.html @@ -0,0 +1,2 @@ + +Hi diff --git a/tests/suite/html/elem.typ b/tests/suite/html/elem.typ index 81ab945776..b416fdf946 100644 --- a/tests/suite/html/elem.typ +++ b/tests/suite/html/elem.typ @@ -5,3 +5,11 @@ // Error: 2-19 `` element must be the only element in the document #html.elem("html") Text + +--- html-elem-metadata html --- +#html.elem("html", context { + let val = query().first().value + test(val, "Hi") + val +}) +#metadata("Hi") From acd3a5b7a5999d22fbf2da488744d564b2f3638e Mon Sep 17 00:00:00 2001 From: aodenis <45949528+aodenis@users.noreply.github.com> Date: Tue, 25 Feb 2025 13:41:54 +0100 Subject: [PATCH 030/558] Fix high CPU usage due to inotify watch triggering itself (#5905) Co-authored-by: Laurenz --- crates/typst-cli/src/watch.rs | 57 +++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/crates/typst-cli/src/watch.rs b/crates/typst-cli/src/watch.rs index 91132fc30c..cc727f0fc8 100644 --- a/crates/typst-cli/src/watch.rs +++ b/crates/typst-cli/src/watch.rs @@ -204,6 +204,10 @@ impl Watcher { let event = event .map_err(|err| eco_format!("failed to watch dependencies ({err})"))?; + if !is_relevant_event_kind(&event.kind) { + continue; + } + // Workaround for notify-rs' implicit unwatch on remove/rename // (triggered by some editors when saving files) with the // inotify backend. By keeping track of the potentially @@ -224,7 +228,17 @@ impl Watcher { } } - relevant |= self.is_event_relevant(&event); + // Don't recompile because the output file changed. + // FIXME: This doesn't work properly for multifile image export. + if event + .paths + .iter() + .all(|path| is_same_file(path, &self.output).unwrap_or(false)) + { + continue; + } + + relevant = true; } // If we found a relevant event or if any of the missing files now @@ -234,32 +248,23 @@ impl Watcher { } } } +} - /// Whether a watch event is relevant for compilation. - fn is_event_relevant(&self, event: ¬ify::Event) -> bool { - // Never recompile because the output file changed. - if event - .paths - .iter() - .all(|path| is_same_file(path, &self.output).unwrap_or(false)) - { - return false; - } - - match &event.kind { - notify::EventKind::Any => true, - notify::EventKind::Access(_) => false, - notify::EventKind::Create(_) => true, - notify::EventKind::Modify(kind) => match kind { - notify::event::ModifyKind::Any => true, - notify::event::ModifyKind::Data(_) => true, - notify::event::ModifyKind::Metadata(_) => false, - notify::event::ModifyKind::Name(_) => true, - notify::event::ModifyKind::Other => false, - }, - notify::EventKind::Remove(_) => true, - notify::EventKind::Other => false, - } +/// Whether a kind of watch event is relevant for compilation. +fn is_relevant_event_kind(kind: ¬ify::EventKind) -> bool { + match kind { + notify::EventKind::Any => true, + notify::EventKind::Access(_) => false, + notify::EventKind::Create(_) => true, + notify::EventKind::Modify(kind) => match kind { + notify::event::ModifyKind::Any => true, + notify::event::ModifyKind::Data(_) => true, + notify::event::ModifyKind::Metadata(_) => false, + notify::event::ModifyKind::Name(_) => true, + notify::event::ModifyKind::Other => false, + }, + notify::EventKind::Remove(_) => true, + notify::EventKind::Other => false, } } From f31c9716240eb5c81ae225455c069089088015bc Mon Sep 17 00:00:00 2001 From: Laurenz Date: Tue, 25 Feb 2025 13:47:41 +0100 Subject: [PATCH 031/558] Deduplicate watcher update call (#5955) --- crates/typst-cli/src/watch.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/typst-cli/src/watch.rs b/crates/typst-cli/src/watch.rs index cc727f0fc8..0813d8ffde 100644 --- a/crates/typst-cli/src/watch.rs +++ b/crates/typst-cli/src/watch.rs @@ -55,11 +55,11 @@ pub fn watch(timer: &mut Timer, command: &WatchCommand) -> StrResult<()> { // Perform initial compilation. timer.record(&mut world, |world| compile_once(world, &mut config))??; - // Watch all dependencies of the initial compilation. - watcher.update(world.dependencies())?; - // Recompile whenever something relevant happens. loop { + // Watch all dependencies of the most recent compilation. + watcher.update(world.dependencies())?; + // Wait until anything relevant happens. watcher.wait()?; @@ -71,9 +71,6 @@ pub fn watch(timer: &mut Timer, command: &WatchCommand) -> StrResult<()> { // Evict the cache. comemo::evict(10); - - // Adjust the file watching. - watcher.update(world.dependencies())?; } } From bad343748b834cdc155c5fe76cd944e74f4665cf Mon Sep 17 00:00:00 2001 From: Laurenz Date: Tue, 25 Feb 2025 14:00:22 +0100 Subject: [PATCH 032/558] Fix paper name in page setup guide (#5956) --- docs/guides/page-setup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/page-setup.md b/docs/guides/page-setup.md index c93a778e2f..36ed0fa234 100644 --- a/docs/guides/page-setup.md +++ b/docs/guides/page-setup.md @@ -56,7 +56,7 @@ requirements with examples. Typst's default page size is A4 paper. Depending on your region and your use case, you will want to change this. You can do this by using the [`{page}`]($page) set rule and passing it a string argument to use a common page -size. Options include the complete ISO 216 series (e.g. `"iso-a4"`, `"iso-c2"`), +size. Options include the complete ISO 216 series (e.g. `"a4"` and `"iso-c2"`), customary US formats like `"us-legal"` or `"us-letter"`, and more. Check out the reference for the [page's paper argument]($page.paper) to learn about all available options. From d11ad80dee669c5e2285ca8df8ebc99abc031ccd Mon Sep 17 00:00:00 2001 From: evie <50974538+mi2ebi@users.noreply.github.com> Date: Tue, 25 Feb 2025 06:01:01 -0800 Subject: [PATCH 033/558] Add `#str.normalize(form)` (#5631) Co-authored-by: +merlan #flirora Co-authored-by: Laurenz --- Cargo.lock | 1 + Cargo.toml | 1 + crates/typst-library/Cargo.toml | 1 + crates/typst-library/src/foundations/str.rs | 46 ++++++++++++++++++++- tests/suite/foundations/str.typ | 7 ++++ 5 files changed, 55 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 1851134a55..86f04ee529 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2995,6 +2995,7 @@ dependencies = [ "typst-timing", "typst-utils", "unicode-math-class", + "unicode-normalization", "unicode-segmentation", "unscanny", "usvg", diff --git a/Cargo.toml b/Cargo.toml index 36195230e5..f643856e1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -129,6 +129,7 @@ unicode-bidi = "0.3.18" unicode-ident = "1.0" unicode-math-class = "0.1" unicode-script = "0.5" +unicode-normalization = "0.1.24" unicode-segmentation = "1" unscanny = "0.1" ureq = { version = "2", default-features = false, features = ["native-tls", "gzip", "json"] } diff --git a/crates/typst-library/Cargo.toml b/crates/typst-library/Cargo.toml index fb45ec8620..71729b63a6 100644 --- a/crates/typst-library/Cargo.toml +++ b/crates/typst-library/Cargo.toml @@ -61,6 +61,7 @@ ttf-parser = { workspace = true } two-face = { workspace = true } typed-arena = { workspace = true } unicode-math-class = { workspace = true } +unicode-normalization = { workspace = true } unicode-segmentation = { workspace = true } unscanny = { workspace = true } usvg = { workspace = true } diff --git a/crates/typst-library/src/foundations/str.rs b/crates/typst-library/src/foundations/str.rs index 551ac04f59..23a1bd4cfd 100644 --- a/crates/typst-library/src/foundations/str.rs +++ b/crates/typst-library/src/foundations/str.rs @@ -7,12 +7,13 @@ use comemo::Tracked; use ecow::EcoString; use serde::{Deserialize, Serialize}; use typst_syntax::{Span, Spanned}; +use unicode_normalization::UnicodeNormalization; use unicode_segmentation::UnicodeSegmentation; use crate::diag::{bail, At, SourceResult, StrResult}; use crate::engine::Engine; use crate::foundations::{ - cast, dict, func, repr, scope, ty, Array, Bytes, Context, Decimal, Dict, Func, + cast, dict, func, repr, scope, ty, Array, Bytes, Cast, Context, Decimal, Dict, Func, IntoValue, Label, Repr, Type, Value, Version, }; use crate::layout::Alignment; @@ -286,6 +287,30 @@ impl Str { Ok(c.into()) } + /// Normalizes the string to the given Unicode normal form. + /// + /// This is useful when manipulating strings containing Unicode combining + /// characters. + /// + /// ```typ + /// #assert.eq("é".normalize(form: "nfd"), "e\u{0301}") + /// #assert.eq("ſ́".normalize(form: "nfkc"), "ś") + /// ``` + #[func] + pub fn normalize( + &self, + #[named] + #[default(UnicodeNormalForm::Nfc)] + form: UnicodeNormalForm, + ) -> Str { + match form { + UnicodeNormalForm::Nfc => self.nfc().collect(), + UnicodeNormalForm::Nfd => self.nfd().collect(), + UnicodeNormalForm::Nfkc => self.nfkc().collect(), + UnicodeNormalForm::Nfkd => self.nfkd().collect(), + } + } + /// Whether the string contains the specified pattern. /// /// This method also has dedicated syntax: You can write `{"bc" in "abcd"}` @@ -788,6 +813,25 @@ cast! { v: Str => Self::Str(v), } +/// A Unicode normalization form. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum UnicodeNormalForm { + /// Canonical composition where e.g. accented letters are turned into a + /// single Unicode codepoint. + #[string("nfc")] + Nfc, + /// Canonical decomposition where e.g. accented letters are split into a + /// separate base and diacritic. + #[string("nfd")] + Nfd, + /// Like NFC, but using the Unicode compatibility decompositions. + #[string("nfkc")] + Nfkc, + /// Like NFD, but using the Unicode compatibility decompositions. + #[string("nfkd")] + Nfkd, +} + /// Convert an item of std's `match_indices` to a dictionary. fn match_to_dict((start, text): (usize, &str)) -> Dict { dict! { diff --git a/tests/suite/foundations/str.typ b/tests/suite/foundations/str.typ index 56756416d7..66fb912c03 100644 --- a/tests/suite/foundations/str.typ +++ b/tests/suite/foundations/str.typ @@ -86,6 +86,13 @@ // Error: 2-28 0x110000 is not a valid codepoint #str.from-unicode(0x110000) // 0x10ffff is the highest valid code point +--- str-normalize --- +// Test the `normalize` method. +#test("e\u{0301}".normalize(form: "nfc"), "é") +#test("é".normalize(form: "nfd"), "e\u{0301}") +#test("ſ\u{0301}".normalize(form: "nfkc"), "ś") +#test("ſ\u{0301}".normalize(form: "nfkd"), "s\u{0301}") + --- string-len --- // Test the `len` method. #test("Hello World!".len(), 12) From 2eef9e84e117670ea0db964a5a8addc89e0ee785 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Tue, 25 Feb 2025 15:09:52 +0100 Subject: [PATCH 034/558] Improve hints for show rule recursion depth (#5856) --- crates/typst-library/src/engine.rs | 3 ++- tests/suite/scripting/recursion.typ | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/typst-library/src/engine.rs b/crates/typst-library/src/engine.rs index 80aaef2241..43a7b46710 100644 --- a/crates/typst-library/src/engine.rs +++ b/crates/typst-library/src/engine.rs @@ -312,7 +312,8 @@ impl Route<'_> { if !self.within(Route::MAX_SHOW_RULE_DEPTH) { bail!( "maximum show rule depth exceeded"; - hint: "check whether the show rule matches its own output" + hint: "maybe a show rule matches its own output"; + hint: "maybe there are too deeply nested elements" ); } Ok(()) diff --git a/tests/suite/scripting/recursion.typ b/tests/suite/scripting/recursion.typ index 6be96c1ecf..e92b67fb75 100644 --- a/tests/suite/scripting/recursion.typ +++ b/tests/suite/scripting/recursion.typ @@ -44,18 +44,21 @@ --- recursion-via-include-in-layout --- // Test cyclic imports during layout. // Error: 2-38 maximum show rule depth exceeded -// Hint: 2-38 check whether the show rule matches its own output +// Hint: 2-38 maybe a show rule matches its own output +// Hint: 2-38 maybe there are too deeply nested elements #layout(_ => include "recursion.typ") --- recursion-show-math --- // Test recursive show rules. // Error: 22-25 maximum show rule depth exceeded -// Hint: 22-25 check whether the show rule matches its own output +// Hint: 22-25 maybe a show rule matches its own output +// Hint: 22-25 maybe there are too deeply nested elements #show math.equation: $x$ $ x $ --- recursion-show-math-realize --- // Error: 22-33 maximum show rule depth exceeded -// Hint: 22-33 check whether the show rule matches its own output +// Hint: 22-33 maybe a show rule matches its own output +// Hint: 22-33 maybe there are too deeply nested elements #show heading: it => heading[it] $ #heading[hi] $ From 8f039dd614ba518976b8b486e0a138bd6a9c660c Mon Sep 17 00:00:00 2001 From: Laurenz Date: Tue, 25 Feb 2025 15:10:01 +0100 Subject: [PATCH 035/558] Only autocomplete methods which take self (#5824) --- crates/typst-ide/src/complete.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index e3dcc442ef..e3d7771158 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -410,9 +410,17 @@ fn field_access_completions( elem.into_iter().chain(Some(ty)) }; - // Autocomplete methods from the element's or type's scope. + // Autocomplete methods from the element's or type's scope. We only complete + // those which have a `self` parameter. for (name, binding) in scopes.flat_map(|scope| scope.iter()) { - ctx.call_completion(name.clone(), binding.read()); + let Ok(func) = binding.read().clone().cast::() else { continue }; + if func + .params() + .and_then(|params| params.first()) + .is_some_and(|param| param.name == "self") + { + ctx.call_completion(name.clone(), binding.read()); + } } if let Some(scope) = value.scope() { @@ -1764,6 +1772,7 @@ mod tests { #[test] fn test_autocomplete_type_methods() { test("#\"hello\".", -1).must_include(["len", "contains"]); + test("#table().", -1).must_exclude(["cell"]); } #[test] From d6b0d68ffa4963459f52f7d774080f1f128841d4 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Tue, 25 Feb 2025 15:19:17 +0100 Subject: [PATCH 036/558] Add more methods to `direction` (#5893) --- crates/typst-library/src/layout/dir.rs | 52 ++++++++++++++++++++++++++ tests/suite/layout/dir.typ | 27 ++++++++++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/crates/typst-library/src/layout/dir.rs b/crates/typst-library/src/layout/dir.rs index 9a2e771059..699c8c4813 100644 --- a/crates/typst-library/src/layout/dir.rs +++ b/crates/typst-library/src/layout/dir.rs @@ -50,6 +50,42 @@ impl Dir { pub const TTB: Self = Self::TTB; pub const BTT: Self = Self::BTT; + /// Returns a direction from a starting point. + /// + /// ```example + /// direction.from(left) \ + /// direction.from(right) \ + /// direction.from(top) \ + /// direction.from(bottom) + /// ``` + #[func] + pub const fn from(side: Side) -> Dir { + match side { + Side::Left => Self::LTR, + Side::Right => Self::RTL, + Side::Top => Self::TTB, + Side::Bottom => Self::BTT, + } + } + + /// Returns a direction from an end point. + /// + /// ```example + /// direction.to(left) \ + /// direction.to(right) \ + /// direction.to(top) \ + /// direction.to(bottom) + /// ``` + #[func] + pub const fn to(side: Side) -> Dir { + match side { + Side::Right => Self::LTR, + Side::Left => Self::RTL, + Side::Bottom => Self::TTB, + Side::Top => Self::BTT, + } + } + /// The axis this direction belongs to, either `{"horizontal"}` or /// `{"vertical"}`. /// @@ -65,6 +101,22 @@ impl Dir { } } + /// The corresponding sign, for use in calculations. + /// + /// ```example + /// #ltr.sign() \ + /// #rtl.sign() \ + /// #ttb.sign() \ + /// #btt.sign() + /// ``` + #[func] + pub const fn sign(self) -> i64 { + match self { + Self::LTR | Self::TTB => 1, + Self::RTL | Self::BTT => -1, + } + } + /// The start point of this direction, as an alignment. /// /// ```example diff --git a/tests/suite/layout/dir.typ b/tests/suite/layout/dir.typ index 139a2285d2..e6db54da51 100644 --- a/tests/suite/layout/dir.typ +++ b/tests/suite/layout/dir.typ @@ -1,10 +1,35 @@ +--- dir-from --- +#test(direction.from(left), ltr) +#test(direction.from(right), rtl) +#test(direction.from(top), ttb) +#test(direction.from(bottom), btt) + +--- dir-from-invalid --- +// Error: 17-23 cannot convert this alignment to a side +#direction.from(center) + +--- dir-to --- +#test(direction.to(left), rtl) +#test(direction.to(right), ltr) +#test(direction.to(top), btt) +#test(direction.to(bottom), ttb) + +-- dir-to-invalid --- +// Error: 15-21 cannot convert this alignment to a side +#direction.to(center) + --- dir-axis --- -// Test direction methods. #test(ltr.axis(), "horizontal") #test(rtl.axis(), "horizontal") #test(ttb.axis(), "vertical") #test(btt.axis(), "vertical") +--- dir-sign --- +#test(ltr.sign(), 1) +#test(rtl.sign(), -1) +#test(ttb.sign(), 1) +#test(btt.sign(), -1) + --- dir-start --- #test(ltr.start(), left) #test(rtl.start(), right) From 52f1f53973414be72bf22c3253ab365f8db067df Mon Sep 17 00:00:00 2001 From: Emmanuel Lesueur <48604057+Emm54321@users.noreply.github.com> Date: Wed, 26 Feb 2025 19:07:29 +0100 Subject: [PATCH 037/558] Fix curve with multiple non-closed components. (#5963) --- crates/typst-layout/src/shapes.rs | 1 + tests/ref/curve-multiple-non-closed.png | Bin 0 -> 85 bytes tests/suite/visualize/curve.typ | 10 ++++++++++ 3 files changed, 11 insertions(+) create mode 100644 tests/ref/curve-multiple-non-closed.png diff --git a/crates/typst-layout/src/shapes.rs b/crates/typst-layout/src/shapes.rs index 21d0a518fa..7ab41e9d4c 100644 --- a/crates/typst-layout/src/shapes.rs +++ b/crates/typst-layout/src/shapes.rs @@ -284,6 +284,7 @@ impl<'a> CurveBuilder<'a> { self.last_point = point; self.last_control_from = point; self.is_started = true; + self.is_empty = true; } /// Add a line segment. diff --git a/tests/ref/curve-multiple-non-closed.png b/tests/ref/curve-multiple-non-closed.png new file mode 100644 index 0000000000000000000000000000000000000000..f4332e363f7500fbfdf1745ddb07156cd699804e GIT binary patch literal 85 zcmeAS@N?(olHy`uVBq!ia0vp^6+o=P2qYL}Co*>cDH%@}$B>F!$v^tJB-rNEH#ADl iwSQ&zYG?k0qvj00FFdmKHq?9ssrPjCb6Mw<&;$TP`50CJ literal 0 HcmV?d00001 diff --git a/tests/suite/visualize/curve.typ b/tests/suite/visualize/curve.typ index f98f634a71..14a1c0cc81 100644 --- a/tests/suite/visualize/curve.typ +++ b/tests/suite/visualize/curve.typ @@ -38,6 +38,16 @@ curve.close(mode: "smooth"), ) +--- curve-multiple-non-closed --- +#curve( + stroke: 2pt, + curve.line((20pt, 0pt)), + curve.move((0pt, 10pt)), + curve.line((20pt, 10pt)), + curve.move((0pt, 20pt)), + curve.line((20pt, 20pt)), +) + --- curve-line --- #curve( fill: purple, From cfb3b1a2709107f0f06f89ea25cabc939cec15e5 Mon Sep 17 00:00:00 2001 From: Ian Wrzesinski <133046678+wrzian@users.noreply.github.com> Date: Wed, 26 Feb 2025 15:10:36 -0500 Subject: [PATCH 038/558] Improve clarity of `ast.rs` for newcomers to the codebase (#5784) Co-authored-by: PgBiel <9021226+PgBiel@users.noreply.github.com> Co-authored-by: T0mstone <39707032+T0mstone@users.noreply.github.com> --- crates/typst-eval/src/call.rs | 8 +- crates/typst-eval/src/code.rs | 36 +- crates/typst-eval/src/markup.rs | 4 +- crates/typst-eval/src/rules.rs | 2 +- crates/typst-ide/src/complete.rs | 8 +- crates/typst-ide/src/matchers.rs | 4 +- crates/typst-ide/src/tooltip.rs | 2 +- crates/typst-syntax/src/ast.rs | 641 ++++++++++++++++++------------- crates/typst-syntax/src/node.rs | 21 - 9 files changed, 415 insertions(+), 311 deletions(-) diff --git a/crates/typst-eval/src/call.rs b/crates/typst-eval/src/call.rs index c68bef963d..1ca7b4b8fb 100644 --- a/crates/typst-eval/src/call.rs +++ b/crates/typst-eval/src/call.rs @@ -466,7 +466,7 @@ impl<'a> CapturesVisitor<'a> { } // Code and content blocks create a scope. - Some(ast::Expr::Code(_) | ast::Expr::Content(_)) => { + Some(ast::Expr::CodeBlock(_) | ast::Expr::ContentBlock(_)) => { self.internal.enter(); for child in node.children() { self.visit(child); @@ -516,7 +516,7 @@ impl<'a> CapturesVisitor<'a> { // A let expression contains a binding, but that binding is only // active after the body is evaluated. - Some(ast::Expr::Let(expr)) => { + Some(ast::Expr::LetBinding(expr)) => { if let Some(init) = expr.init() { self.visit(init.to_untyped()); } @@ -529,7 +529,7 @@ impl<'a> CapturesVisitor<'a> { // A for loop contains one or two bindings in its pattern. These are // active after the iterable is evaluated but before the body is // evaluated. - Some(ast::Expr::For(expr)) => { + Some(ast::Expr::ForLoop(expr)) => { self.visit(expr.iterable().to_untyped()); self.internal.enter(); @@ -544,7 +544,7 @@ impl<'a> CapturesVisitor<'a> { // An import contains items, but these are active only after the // path is evaluated. - Some(ast::Expr::Import(expr)) => { + Some(ast::Expr::ModuleImport(expr)) => { self.visit(expr.source().to_untyped()); if let Some(ast::Imports::Items(items)) = expr.imports() { for item in items.iter() { diff --git a/crates/typst-eval/src/code.rs b/crates/typst-eval/src/code.rs index a7b6b6f90b..9078418e48 100644 --- a/crates/typst-eval/src/code.rs +++ b/crates/typst-eval/src/code.rs @@ -30,7 +30,7 @@ fn eval_code<'a>( while let Some(expr) = exprs.next() { let span = expr.span(); let value = match expr { - ast::Expr::Set(set) => { + ast::Expr::SetRule(set) => { let styles = set.eval(vm)?; if vm.flow.is_some() { break; @@ -39,7 +39,7 @@ fn eval_code<'a>( let tail = eval_code(vm, exprs)?.display(); Value::Content(tail.styled_with_map(styles)) } - ast::Expr::Show(show) => { + ast::Expr::ShowRule(show) => { let recipe = show.eval(vm)?; if vm.flow.is_some() { break; @@ -94,9 +94,9 @@ impl Eval for ast::Expr<'_> { Self::Label(v) => v.eval(vm), Self::Ref(v) => v.eval(vm).map(Value::Content), Self::Heading(v) => v.eval(vm).map(Value::Content), - Self::List(v) => v.eval(vm).map(Value::Content), - Self::Enum(v) => v.eval(vm).map(Value::Content), - Self::Term(v) => v.eval(vm).map(Value::Content), + Self::ListItem(v) => v.eval(vm).map(Value::Content), + Self::EnumItem(v) => v.eval(vm).map(Value::Content), + Self::TermItem(v) => v.eval(vm).map(Value::Content), Self::Equation(v) => v.eval(vm).map(Value::Content), Self::Math(v) => v.eval(vm).map(Value::Content), Self::MathText(v) => v.eval(vm).map(Value::Content), @@ -116,8 +116,8 @@ impl Eval for ast::Expr<'_> { Self::Float(v) => v.eval(vm), Self::Numeric(v) => v.eval(vm), Self::Str(v) => v.eval(vm), - Self::Code(v) => v.eval(vm), - Self::Content(v) => v.eval(vm).map(Value::Content), + Self::CodeBlock(v) => v.eval(vm), + Self::ContentBlock(v) => v.eval(vm).map(Value::Content), Self::Array(v) => v.eval(vm).map(Value::Array), Self::Dict(v) => v.eval(vm).map(Value::Dict), Self::Parenthesized(v) => v.eval(vm), @@ -126,19 +126,19 @@ impl Eval for ast::Expr<'_> { Self::Closure(v) => v.eval(vm), Self::Unary(v) => v.eval(vm), Self::Binary(v) => v.eval(vm), - Self::Let(v) => v.eval(vm), - Self::DestructAssign(v) => v.eval(vm), - Self::Set(_) => bail!(forbidden("set")), - Self::Show(_) => bail!(forbidden("show")), + Self::LetBinding(v) => v.eval(vm), + Self::DestructAssignment(v) => v.eval(vm), + Self::SetRule(_) => bail!(forbidden("set")), + Self::ShowRule(_) => bail!(forbidden("show")), Self::Contextual(v) => v.eval(vm).map(Value::Content), Self::Conditional(v) => v.eval(vm), - Self::While(v) => v.eval(vm), - Self::For(v) => v.eval(vm), - Self::Import(v) => v.eval(vm), - Self::Include(v) => v.eval(vm).map(Value::Content), - Self::Break(v) => v.eval(vm), - Self::Continue(v) => v.eval(vm), - Self::Return(v) => v.eval(vm), + Self::WhileLoop(v) => v.eval(vm), + Self::ForLoop(v) => v.eval(vm), + Self::ModuleImport(v) => v.eval(vm), + Self::ModuleInclude(v) => v.eval(vm).map(Value::Content), + Self::LoopBreak(v) => v.eval(vm), + Self::LoopContinue(v) => v.eval(vm), + Self::FuncReturn(v) => v.eval(vm), }? .spanned(span); diff --git a/crates/typst-eval/src/markup.rs b/crates/typst-eval/src/markup.rs index 3a5ebe1fca..5beefa9122 100644 --- a/crates/typst-eval/src/markup.rs +++ b/crates/typst-eval/src/markup.rs @@ -33,7 +33,7 @@ fn eval_markup<'a>( while let Some(expr) = exprs.next() { match expr { - ast::Expr::Set(set) => { + ast::Expr::SetRule(set) => { let styles = set.eval(vm)?; if vm.flow.is_some() { break; @@ -41,7 +41,7 @@ fn eval_markup<'a>( seq.push(eval_markup(vm, exprs)?.styled_with_map(styles)) } - ast::Expr::Show(show) => { + ast::Expr::ShowRule(show) => { let recipe = show.eval(vm)?; if vm.flow.is_some() { break; diff --git a/crates/typst-eval/src/rules.rs b/crates/typst-eval/src/rules.rs index 646354d4ba..f4c1563f34 100644 --- a/crates/typst-eval/src/rules.rs +++ b/crates/typst-eval/src/rules.rs @@ -45,7 +45,7 @@ impl Eval for ast::ShowRule<'_> { let transform = self.transform(); let transform = match transform { - ast::Expr::Set(set) => Transformation::Style(set.eval(vm)?), + ast::Expr::SetRule(set) => Transformation::Style(set.eval(vm)?), expr => expr.eval(vm)?.cast::().at(transform.span())?, }; diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index e3d7771158..91fa53f9a5 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -517,7 +517,7 @@ fn complete_imports(ctx: &mut CompletionContext) -> bool { // "#import "path.typ": a, b, |". if_chain! { if let Some(prev) = ctx.leaf.prev_sibling(); - if let Some(ast::Expr::Import(import)) = prev.get().cast(); + if let Some(ast::Expr::ModuleImport(import)) = prev.get().cast(); if let Some(ast::Imports::Items(items)) = import.imports(); if let Some(source) = prev.children().find(|child| child.is::()); then { @@ -536,7 +536,7 @@ fn complete_imports(ctx: &mut CompletionContext) -> bool { if let Some(grand) = parent.parent(); if grand.kind() == SyntaxKind::ImportItems; if let Some(great) = grand.parent(); - if let Some(ast::Expr::Import(import)) = great.get().cast(); + if let Some(ast::Expr::ModuleImport(import)) = great.get().cast(); if let Some(ast::Imports::Items(items)) = import.imports(); if let Some(source) = great.children().find(|child| child.is::()); then { @@ -677,10 +677,10 @@ fn complete_params(ctx: &mut CompletionContext) -> bool { if let Some(args) = parent.get().cast::(); if let Some(grand) = parent.parent(); if let Some(expr) = grand.get().cast::(); - let set = matches!(expr, ast::Expr::Set(_)); + let set = matches!(expr, ast::Expr::SetRule(_)); if let Some(callee) = match expr { ast::Expr::FuncCall(call) => Some(call.callee()), - ast::Expr::Set(set) => Some(set.target()), + ast::Expr::SetRule(set) => Some(set.target()), _ => None, }; then { diff --git a/crates/typst-ide/src/matchers.rs b/crates/typst-ide/src/matchers.rs index 270d2f43c5..93fdc5dd59 100644 --- a/crates/typst-ide/src/matchers.rs +++ b/crates/typst-ide/src/matchers.rs @@ -232,7 +232,9 @@ pub fn deref_target(node: LinkedNode) -> Option> { ast::Expr::FuncCall(call) => { DerefTarget::Callee(expr_node.find(call.callee().span())?) } - ast::Expr::Set(set) => DerefTarget::Callee(expr_node.find(set.target().span())?), + ast::Expr::SetRule(set) => { + DerefTarget::Callee(expr_node.find(set.target().span())?) + } ast::Expr::Ident(_) | ast::Expr::MathIdent(_) | ast::Expr::FieldAccess(_) => { DerefTarget::VarAccess(expr_node) } diff --git a/crates/typst-ide/src/tooltip.rs b/crates/typst-ide/src/tooltip.rs index cfb977733d..cbfffe5305 100644 --- a/crates/typst-ide/src/tooltip.rs +++ b/crates/typst-ide/src/tooltip.rs @@ -201,7 +201,7 @@ fn named_param_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option(); if let Some(ast::Expr::Ident(callee)) = match expr { ast::Expr::FuncCall(call) => Some(call.callee()), - ast::Expr::Set(set) => Some(set.target()), + ast::Expr::SetRule(set) => Some(set.target()), _ => None, }; diff --git a/crates/typst-syntax/src/ast.rs b/crates/typst-syntax/src/ast.rs index 640138e775..f79e659820 100644 --- a/crates/typst-syntax/src/ast.rs +++ b/crates/typst-syntax/src/ast.rs @@ -1,6 +1,81 @@ -//! A typed layer over the untyped syntax tree. -//! -//! The AST is rooted in the [`Markup`] node. +/*! +# Abstract Syntax Tree Interface + +Typst's Abstract Syntax Tree (AST) is a lazy, typed view over the untyped +Concrete Syntax Tree (CST) and is rooted in the [`Markup`] node. + +## The AST is a View + +Most AST nodes are wrapper structs around [`SyntaxNode`] pointers. This summary +will use a running example of the [`Raw`] node type, which is declared (after +macro expansion) as: `struct Raw<'a>(&'a SyntaxNode);`. + +[`SyntaxNode`]s are generated by the parser and constitute the Concrete Syntax +Tree (CST). The CST is _concrete_ because it has the property that an in-order +tree traversal will recreate the text of the source file exactly. + +[`SyntaxNode`]s in the CST contain their [`SyntaxKind`], but don't themselves +provide access to the semantic meaning of their contents. That semantic meaning +is available through the Abstract Syntax Tree by iterating over CST nodes and +inspecting their contents. The format is prepared ahead-of-time by the parser so +that this module can unpack the abstract meaning from the CST's structure. + +Raw nodes are parsed by recognizing paired backtick delimiters, which you will +find as CST nodes with the [`RawDelim`] kind. However, the AST doesn't include +these delimiters because it _abstracts_ over the backticks. Instead, the parent +raw node will only use its child [`RawDelim`] CST nodes to determine whether the +element is a block or inline. + +## The AST is Typed + +AST nodes all implement the [`AstNode`] trait, but nodes can also implement +their own unique methods. These unique methods are the "real" interface of the +AST, and provide access to the abstract, semantic, representation of each kind +of node. For example, the [`Raw`] node provides 3 methods that specify its +abstract representation: [`Raw::lines()`] returns the raw text as an iterator of +lines, [`Raw::lang()`] provides the optionally present [`RawLang`] language tag, +and [`Raw::block()`] gives a bool for whether the raw element is a block or +inline. + +This semantic information is unavailable in the CST. Only by converting a CST +node to an AST struct will Rust let you call a method of that struct. This is a +safe interface because the only way to create an AST node outside this file is +to call [`AstNode::from_untyped`]. The `node!` macro implements `from_untyped` +by checking the node's kind before constructing it, returning `Some()` only if +the kind matches. So we know that it will have the expected children underneath, +otherwise the parser wouldn't have produced this node. + +## The AST is rooted in the [`Markup`] node + +The AST is rooted in the [`Markup`] node, which provides only one method: +[`Markup::exprs`]. This returns an iterator of the main [`Expr`] enum. [`Expr`] +is important because it contains the majority of expressions that Typst will +evaluate. Not just markup, but also math and code expressions. Not all +expression types are available from the parser at every step, but this does +decrease the amount of wrapper enums needed in the AST (and this file is long +enough already). + +Expressions also branch off into the remaining tree. You can view enums in this +file as edges on a graph: areas where the tree has paths from one type to +another (accessed through methods), then structs are the nodes of the graph, +providing methods that return enums, etc. etc. + +## The AST is Lazy + +Being lazy means that the untyped CST nodes are converted to typed AST nodes +only as the tree is traversed. If we parse a file and a raw block is contained +in a branch of an if-statement that we don't take, then we won't pay the cost of +creating an iterator over the lines or checking whether it was a block or +inline (although it will still be parsed into nodes). + +This is also a factor of the current "tree-interpreter" evaluation model. A +bytecode interpreter might instead eagerly convert the AST into bytecode, but it +would still traverse using this lazy interface. While the tree-interpreter +evaluation is straightforward and easy to add new features onto, it has to +re-traverse the AST every time a function is evaluated. A bytecode interpreter +using the lazy interface would only need to traverse each node once, improving +throughput at the cost of initial latency and development flexibility. +*/ use std::num::NonZeroUsize; use std::ops::Deref; @@ -27,8 +102,55 @@ pub trait AstNode<'a>: Sized { } } +// A generic interface for converting untyped nodes into typed AST nodes. +impl SyntaxNode { + /// Whether the node can be cast to the given AST node. + pub fn is<'a, T: AstNode<'a>>(&'a self) -> bool { + self.cast::().is_some() + } + + /// Try to convert the node to a typed AST node. + pub fn cast<'a, T: AstNode<'a>>(&'a self) -> Option { + T::from_untyped(self) + } + + /// Find the first child that can cast to the AST type `T`. + fn try_cast_first<'a, T: AstNode<'a>>(&'a self) -> Option { + self.children().find_map(Self::cast) + } + + /// Find the last child that can cast to the AST type `T`. + fn try_cast_last<'a, T: AstNode<'a>>(&'a self) -> Option { + self.children().rev().find_map(Self::cast) + } + + /// Get the first child of AST type `T` or a placeholder if none. + fn cast_first<'a, T: AstNode<'a> + Default>(&'a self) -> T { + self.try_cast_first().unwrap_or_default() + } + + /// Get the last child of AST type `T` or a placeholder if none. + fn cast_last<'a, T: AstNode<'a> + Default>(&'a self) -> T { + self.try_cast_last().unwrap_or_default() + } +} + +/// Implements [`AstNode`] for a struct whose name matches a [`SyntaxKind`] +/// variant. +/// +/// The struct becomes a wrapper around a [`SyntaxNode`] pointer, and the +/// implementation of [`AstNode::from_untyped`] checks that the pointer's kind +/// matches when converting, returning `Some` or `None` respectively. +/// +/// The generated struct is the basis for typed accessor methods for properties +/// of this AST node. For example, the [`Raw`] struct has methods for accessing +/// its content by lines, its optional language tag, and whether the raw element +/// is inline or a block. These methods are accessible only _after_ a +/// `SyntaxNode` is coerced to the `Raw` struct type (via `from_untyped`), +/// guaranteeing their implementations will work with the expected structure. macro_rules! node { - ($(#[$attr:meta])* $name:ident) => { + ($(#[$attr:meta])* struct $name:ident) => { + // Create the struct as a wrapper around a `SyntaxNode` reference. #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] #[repr(transparent)] $(#[$attr])* @@ -63,7 +185,7 @@ macro_rules! node { node! { /// The syntactical root capable of representing a full parsed document. - Markup + struct Markup } impl<'a> Markup<'a> { @@ -117,11 +239,11 @@ pub enum Expr<'a> { /// A section heading: `= Introduction`. Heading(Heading<'a>), /// An item in a bullet list: `- ...`. - List(ListItem<'a>), + ListItem(ListItem<'a>), /// An item in an enumeration (numbered list): `+ ...` or `1. ...`. - Enum(EnumItem<'a>), + EnumItem(EnumItem<'a>), /// An item in a term list: `/ Term: Details`. - Term(TermItem<'a>), + TermItem(TermItem<'a>), /// A mathematical equation: `$x$`, `$ x^2 $`. Equation(Equation<'a>), /// The contents of a mathematical equation: `x^2 + 1`. @@ -161,9 +283,9 @@ pub enum Expr<'a> { /// A quoted string: `"..."`. Str(Str<'a>), /// A code block: `{ let x = 1; x + 2 }`. - Code(CodeBlock<'a>), + CodeBlock(CodeBlock<'a>), /// A content block: `[*Hi* there!]`. - Content(ContentBlock<'a>), + ContentBlock(ContentBlock<'a>), /// A grouped expression: `(1 + 2)`. Parenthesized(Parenthesized<'a>), /// An array: `(1, "hi", 12cm)`. @@ -181,37 +303,37 @@ pub enum Expr<'a> { /// A closure: `(x, y) => z`. Closure(Closure<'a>), /// A let binding: `let x = 1`. - Let(LetBinding<'a>), + LetBinding(LetBinding<'a>), /// A destructuring assignment: `(x, y) = (1, 2)`. - DestructAssign(DestructAssignment<'a>), + DestructAssignment(DestructAssignment<'a>), /// A set rule: `set text(...)`. - Set(SetRule<'a>), + SetRule(SetRule<'a>), /// A show rule: `show heading: it => emph(it.body)`. - Show(ShowRule<'a>), + ShowRule(ShowRule<'a>), /// A contextual expression: `context text.lang`. Contextual(Contextual<'a>), /// An if-else conditional: `if x { y } else { z }`. Conditional(Conditional<'a>), /// A while loop: `while x { y }`. - While(WhileLoop<'a>), + WhileLoop(WhileLoop<'a>), /// A for loop: `for x in y { z }`. - For(ForLoop<'a>), + ForLoop(ForLoop<'a>), /// A module import: `import "utils.typ": a, b, c`. - Import(ModuleImport<'a>), + ModuleImport(ModuleImport<'a>), /// A module include: `include "chapter1.typ"`. - Include(ModuleInclude<'a>), + ModuleInclude(ModuleInclude<'a>), /// A break from a loop: `break`. - Break(LoopBreak<'a>), + LoopBreak(LoopBreak<'a>), /// A continue in a loop: `continue`. - Continue(LoopContinue<'a>), + LoopContinue(LoopContinue<'a>), /// A return from a function: `return`, `return x + 1`. - Return(FuncReturn<'a>), + FuncReturn(FuncReturn<'a>), } impl<'a> Expr<'a> { fn cast_with_space(node: &'a SyntaxNode) -> Option { match node.kind() { - SyntaxKind::Space => node.cast().map(Self::Space), + SyntaxKind::Space => Some(Self::Space(Space(node))), _ => Self::from_untyped(node), } } @@ -220,64 +342,69 @@ impl<'a> Expr<'a> { impl<'a> AstNode<'a> for Expr<'a> { fn from_untyped(node: &'a SyntaxNode) -> Option { match node.kind() { - SyntaxKind::Linebreak => node.cast().map(Self::Linebreak), - SyntaxKind::Parbreak => node.cast().map(Self::Parbreak), - SyntaxKind::Text => node.cast().map(Self::Text), - SyntaxKind::Escape => node.cast().map(Self::Escape), - SyntaxKind::Shorthand => node.cast().map(Self::Shorthand), - SyntaxKind::SmartQuote => node.cast().map(Self::SmartQuote), - SyntaxKind::Strong => node.cast().map(Self::Strong), - SyntaxKind::Emph => node.cast().map(Self::Emph), - SyntaxKind::Raw => node.cast().map(Self::Raw), - SyntaxKind::Link => node.cast().map(Self::Link), - SyntaxKind::Label => node.cast().map(Self::Label), - SyntaxKind::Ref => node.cast().map(Self::Ref), - SyntaxKind::Heading => node.cast().map(Self::Heading), - SyntaxKind::ListItem => node.cast().map(Self::List), - SyntaxKind::EnumItem => node.cast().map(Self::Enum), - SyntaxKind::TermItem => node.cast().map(Self::Term), - SyntaxKind::Equation => node.cast().map(Self::Equation), - SyntaxKind::Math => node.cast().map(Self::Math), - SyntaxKind::MathText => node.cast().map(Self::MathText), - SyntaxKind::MathIdent => node.cast().map(Self::MathIdent), - SyntaxKind::MathShorthand => node.cast().map(Self::MathShorthand), - SyntaxKind::MathAlignPoint => node.cast().map(Self::MathAlignPoint), - SyntaxKind::MathDelimited => node.cast().map(Self::MathDelimited), - SyntaxKind::MathAttach => node.cast().map(Self::MathAttach), - SyntaxKind::MathPrimes => node.cast().map(Self::MathPrimes), - SyntaxKind::MathFrac => node.cast().map(Self::MathFrac), - SyntaxKind::MathRoot => node.cast().map(Self::MathRoot), - SyntaxKind::Ident => node.cast().map(Self::Ident), - SyntaxKind::None => node.cast().map(Self::None), - SyntaxKind::Auto => node.cast().map(Self::Auto), - SyntaxKind::Bool => node.cast().map(Self::Bool), - SyntaxKind::Int => node.cast().map(Self::Int), - SyntaxKind::Float => node.cast().map(Self::Float), - SyntaxKind::Numeric => node.cast().map(Self::Numeric), - SyntaxKind::Str => node.cast().map(Self::Str), - SyntaxKind::CodeBlock => node.cast().map(Self::Code), - SyntaxKind::ContentBlock => node.cast().map(Self::Content), - SyntaxKind::Parenthesized => node.cast().map(Self::Parenthesized), - SyntaxKind::Array => node.cast().map(Self::Array), - SyntaxKind::Dict => node.cast().map(Self::Dict), - SyntaxKind::Unary => node.cast().map(Self::Unary), - SyntaxKind::Binary => node.cast().map(Self::Binary), - SyntaxKind::FieldAccess => node.cast().map(Self::FieldAccess), - SyntaxKind::FuncCall => node.cast().map(Self::FuncCall), - SyntaxKind::Closure => node.cast().map(Self::Closure), - SyntaxKind::LetBinding => node.cast().map(Self::Let), - SyntaxKind::DestructAssignment => node.cast().map(Self::DestructAssign), - SyntaxKind::SetRule => node.cast().map(Self::Set), - SyntaxKind::ShowRule => node.cast().map(Self::Show), - SyntaxKind::Contextual => node.cast().map(Self::Contextual), - SyntaxKind::Conditional => node.cast().map(Self::Conditional), - SyntaxKind::WhileLoop => node.cast().map(Self::While), - SyntaxKind::ForLoop => node.cast().map(Self::For), - SyntaxKind::ModuleImport => node.cast().map(Self::Import), - SyntaxKind::ModuleInclude => node.cast().map(Self::Include), - SyntaxKind::LoopBreak => node.cast().map(Self::Break), - SyntaxKind::LoopContinue => node.cast().map(Self::Continue), - SyntaxKind::FuncReturn => node.cast().map(Self::Return), + SyntaxKind::Space => Option::None, // Skipped unless using `cast_with_space`. + SyntaxKind::Linebreak => Some(Self::Linebreak(Linebreak(node))), + SyntaxKind::Parbreak => Some(Self::Parbreak(Parbreak(node))), + SyntaxKind::Text => Some(Self::Text(Text(node))), + SyntaxKind::Escape => Some(Self::Escape(Escape(node))), + SyntaxKind::Shorthand => Some(Self::Shorthand(Shorthand(node))), + SyntaxKind::SmartQuote => Some(Self::SmartQuote(SmartQuote(node))), + SyntaxKind::Strong => Some(Self::Strong(Strong(node))), + SyntaxKind::Emph => Some(Self::Emph(Emph(node))), + SyntaxKind::Raw => Some(Self::Raw(Raw(node))), + SyntaxKind::Link => Some(Self::Link(Link(node))), + SyntaxKind::Label => Some(Self::Label(Label(node))), + SyntaxKind::Ref => Some(Self::Ref(Ref(node))), + SyntaxKind::Heading => Some(Self::Heading(Heading(node))), + SyntaxKind::ListItem => Some(Self::ListItem(ListItem(node))), + SyntaxKind::EnumItem => Some(Self::EnumItem(EnumItem(node))), + SyntaxKind::TermItem => Some(Self::TermItem(TermItem(node))), + SyntaxKind::Equation => Some(Self::Equation(Equation(node))), + SyntaxKind::Math => Some(Self::Math(Math(node))), + SyntaxKind::MathText => Some(Self::MathText(MathText(node))), + SyntaxKind::MathIdent => Some(Self::MathIdent(MathIdent(node))), + SyntaxKind::MathShorthand => Some(Self::MathShorthand(MathShorthand(node))), + SyntaxKind::MathAlignPoint => { + Some(Self::MathAlignPoint(MathAlignPoint(node))) + } + SyntaxKind::MathDelimited => Some(Self::MathDelimited(MathDelimited(node))), + SyntaxKind::MathAttach => Some(Self::MathAttach(MathAttach(node))), + SyntaxKind::MathPrimes => Some(Self::MathPrimes(MathPrimes(node))), + SyntaxKind::MathFrac => Some(Self::MathFrac(MathFrac(node))), + SyntaxKind::MathRoot => Some(Self::MathRoot(MathRoot(node))), + SyntaxKind::Ident => Some(Self::Ident(Ident(node))), + SyntaxKind::None => Some(Self::None(None(node))), + SyntaxKind::Auto => Some(Self::Auto(Auto(node))), + SyntaxKind::Bool => Some(Self::Bool(Bool(node))), + SyntaxKind::Int => Some(Self::Int(Int(node))), + SyntaxKind::Float => Some(Self::Float(Float(node))), + SyntaxKind::Numeric => Some(Self::Numeric(Numeric(node))), + SyntaxKind::Str => Some(Self::Str(Str(node))), + SyntaxKind::CodeBlock => Some(Self::CodeBlock(CodeBlock(node))), + SyntaxKind::ContentBlock => Some(Self::ContentBlock(ContentBlock(node))), + SyntaxKind::Parenthesized => Some(Self::Parenthesized(Parenthesized(node))), + SyntaxKind::Array => Some(Self::Array(Array(node))), + SyntaxKind::Dict => Some(Self::Dict(Dict(node))), + SyntaxKind::Unary => Some(Self::Unary(Unary(node))), + SyntaxKind::Binary => Some(Self::Binary(Binary(node))), + SyntaxKind::FieldAccess => Some(Self::FieldAccess(FieldAccess(node))), + SyntaxKind::FuncCall => Some(Self::FuncCall(FuncCall(node))), + SyntaxKind::Closure => Some(Self::Closure(Closure(node))), + SyntaxKind::LetBinding => Some(Self::LetBinding(LetBinding(node))), + SyntaxKind::DestructAssignment => { + Some(Self::DestructAssignment(DestructAssignment(node))) + } + SyntaxKind::SetRule => Some(Self::SetRule(SetRule(node))), + SyntaxKind::ShowRule => Some(Self::ShowRule(ShowRule(node))), + SyntaxKind::Contextual => Some(Self::Contextual(Contextual(node))), + SyntaxKind::Conditional => Some(Self::Conditional(Conditional(node))), + SyntaxKind::WhileLoop => Some(Self::WhileLoop(WhileLoop(node))), + SyntaxKind::ForLoop => Some(Self::ForLoop(ForLoop(node))), + SyntaxKind::ModuleImport => Some(Self::ModuleImport(ModuleImport(node))), + SyntaxKind::ModuleInclude => Some(Self::ModuleInclude(ModuleInclude(node))), + SyntaxKind::LoopBreak => Some(Self::LoopBreak(LoopBreak(node))), + SyntaxKind::LoopContinue => Some(Self::LoopContinue(LoopContinue(node))), + SyntaxKind::FuncReturn => Some(Self::FuncReturn(FuncReturn(node))), _ => Option::None, } } @@ -298,9 +425,9 @@ impl<'a> AstNode<'a> for Expr<'a> { Self::Label(v) => v.to_untyped(), Self::Ref(v) => v.to_untyped(), Self::Heading(v) => v.to_untyped(), - Self::List(v) => v.to_untyped(), - Self::Enum(v) => v.to_untyped(), - Self::Term(v) => v.to_untyped(), + Self::ListItem(v) => v.to_untyped(), + Self::EnumItem(v) => v.to_untyped(), + Self::TermItem(v) => v.to_untyped(), Self::Equation(v) => v.to_untyped(), Self::Math(v) => v.to_untyped(), Self::MathText(v) => v.to_untyped(), @@ -320,8 +447,8 @@ impl<'a> AstNode<'a> for Expr<'a> { Self::Float(v) => v.to_untyped(), Self::Numeric(v) => v.to_untyped(), Self::Str(v) => v.to_untyped(), - Self::Code(v) => v.to_untyped(), - Self::Content(v) => v.to_untyped(), + Self::CodeBlock(v) => v.to_untyped(), + Self::ContentBlock(v) => v.to_untyped(), Self::Array(v) => v.to_untyped(), Self::Dict(v) => v.to_untyped(), Self::Parenthesized(v) => v.to_untyped(), @@ -330,19 +457,19 @@ impl<'a> AstNode<'a> for Expr<'a> { Self::FieldAccess(v) => v.to_untyped(), Self::FuncCall(v) => v.to_untyped(), Self::Closure(v) => v.to_untyped(), - Self::Let(v) => v.to_untyped(), - Self::DestructAssign(v) => v.to_untyped(), - Self::Set(v) => v.to_untyped(), - Self::Show(v) => v.to_untyped(), + Self::LetBinding(v) => v.to_untyped(), + Self::DestructAssignment(v) => v.to_untyped(), + Self::SetRule(v) => v.to_untyped(), + Self::ShowRule(v) => v.to_untyped(), Self::Contextual(v) => v.to_untyped(), Self::Conditional(v) => v.to_untyped(), - Self::While(v) => v.to_untyped(), - Self::For(v) => v.to_untyped(), - Self::Import(v) => v.to_untyped(), - Self::Include(v) => v.to_untyped(), - Self::Break(v) => v.to_untyped(), - Self::Continue(v) => v.to_untyped(), - Self::Return(v) => v.to_untyped(), + Self::WhileLoop(v) => v.to_untyped(), + Self::ForLoop(v) => v.to_untyped(), + Self::ModuleImport(v) => v.to_untyped(), + Self::ModuleInclude(v) => v.to_untyped(), + Self::LoopBreak(v) => v.to_untyped(), + Self::LoopContinue(v) => v.to_untyped(), + Self::FuncReturn(v) => v.to_untyped(), } } } @@ -360,25 +487,25 @@ impl Expr<'_> { | Self::Float(_) | Self::Numeric(_) | Self::Str(_) - | Self::Code(_) - | Self::Content(_) + | Self::CodeBlock(_) + | Self::ContentBlock(_) | Self::Array(_) | Self::Dict(_) | Self::Parenthesized(_) | Self::FieldAccess(_) | Self::FuncCall(_) - | Self::Let(_) - | Self::Set(_) - | Self::Show(_) + | Self::LetBinding(_) + | Self::SetRule(_) + | Self::ShowRule(_) | Self::Contextual(_) | Self::Conditional(_) - | Self::While(_) - | Self::For(_) - | Self::Import(_) - | Self::Include(_) - | Self::Break(_) - | Self::Continue(_) - | Self::Return(_) + | Self::WhileLoop(_) + | Self::ForLoop(_) + | Self::ModuleImport(_) + | Self::ModuleInclude(_) + | Self::LoopBreak(_) + | Self::LoopContinue(_) + | Self::FuncReturn(_) ) } @@ -405,7 +532,7 @@ impl Default for Expr<'_> { node! { /// Plain text without markup. - Text + struct Text } impl<'a> Text<'a> { @@ -418,22 +545,22 @@ impl<'a> Text<'a> { node! { /// Whitespace in markup or math. Has at most one newline in markup, as more /// indicate a paragraph break. - Space + struct Space } node! { /// A forced line break: `\`. - Linebreak + struct Linebreak } node! { /// A paragraph break, indicated by one or multiple blank lines. - Parbreak + struct Parbreak } node! { /// An escape sequence: `\#`, `\u{1F5FA}`. - Escape + struct Escape } impl Escape<'_> { @@ -456,7 +583,7 @@ impl Escape<'_> { node! { /// A shorthand for a unicode codepoint. For example, `~` for a non-breaking /// space or `-?` for a soft hyphen. - Shorthand + struct Shorthand } impl Shorthand<'_> { @@ -482,7 +609,7 @@ impl Shorthand<'_> { node! { /// A smart quote: `'` or `"`. - SmartQuote + struct SmartQuote } impl SmartQuote<'_> { @@ -494,31 +621,31 @@ impl SmartQuote<'_> { node! { /// Strong content: `*Strong*`. - Strong + struct Strong } impl<'a> Strong<'a> { /// The contents of the strong node. pub fn body(self) -> Markup<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } } node! { /// Emphasized content: `_Emphasized_`. - Emph + struct Emph } impl<'a> Emph<'a> { /// The contents of the emphasis node. pub fn body(self) -> Markup<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } } node! { /// Raw text with optional syntax highlighting: `` `...` ``. - Raw + struct Raw } impl<'a> Raw<'a> { @@ -530,18 +657,18 @@ impl<'a> Raw<'a> { /// An optional identifier specifying the language to syntax-highlight in. pub fn lang(self) -> Option> { // Only blocky literals are supposed to contain a language. - let delim: RawDelim = self.0.cast_first_match()?; + let delim: RawDelim = self.0.try_cast_first()?; if delim.0.len() < 3 { return Option::None; } - self.0.cast_first_match() + self.0.try_cast_first() } /// Whether the raw text should be displayed in a separate block. pub fn block(self) -> bool { self.0 - .cast_first_match() + .try_cast_first() .is_some_and(|delim: RawDelim| delim.0.len() >= 3) && self.0.children().any(|e| { e.kind() == SyntaxKind::RawTrimmed && e.text().chars().any(is_newline) @@ -551,7 +678,7 @@ impl<'a> Raw<'a> { node! { /// A language tag at the start of raw element: ``typ ``. - RawLang + struct RawLang } impl<'a> RawLang<'a> { @@ -563,12 +690,12 @@ impl<'a> RawLang<'a> { node! { /// A raw delimiter in single or 3+ backticks: `` ` ``. - RawDelim + struct RawDelim } node! { /// A hyperlink: `https://typst.org`. - Link + struct Link } impl<'a> Link<'a> { @@ -580,7 +707,7 @@ impl<'a> Link<'a> { node! { /// A label: ``. - Label + struct Label } impl<'a> Label<'a> { @@ -592,7 +719,7 @@ impl<'a> Label<'a> { node! { /// A reference: `@target`, `@target[..]`. - Ref + struct Ref } impl<'a> Ref<'a> { @@ -607,19 +734,19 @@ impl<'a> Ref<'a> { /// Get the supplement. pub fn supplement(self) -> Option> { - self.0.cast_last_match() + self.0.try_cast_last() } } node! { /// A section heading: `= Introduction`. - Heading + struct Heading } impl<'a> Heading<'a> { /// The contents of the heading. pub fn body(self) -> Markup<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } /// The section depth (number of equals signs). @@ -634,19 +761,19 @@ impl<'a> Heading<'a> { node! { /// An item in a bullet list: `- ...`. - ListItem + struct ListItem } impl<'a> ListItem<'a> { /// The contents of the list item. pub fn body(self) -> Markup<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } } node! { /// An item in an enumeration (numbered list): `+ ...` or `1. ...`. - EnumItem + struct EnumItem } impl<'a> EnumItem<'a> { @@ -660,36 +787,36 @@ impl<'a> EnumItem<'a> { /// The contents of the list item. pub fn body(self) -> Markup<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } } node! { /// An item in a term list: `/ Term: Details`. - TermItem + struct TermItem } impl<'a> TermItem<'a> { /// The term described by the item. pub fn term(self) -> Markup<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } /// The description of the term. pub fn description(self) -> Markup<'a> { - self.0.cast_last_match().unwrap_or_default() + self.0.cast_last() } } node! { /// A mathematical equation: `$x$`, `$ x^2 $`. - Equation + struct Equation } impl<'a> Equation<'a> { /// The contained math. pub fn body(self) -> Math<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } /// Whether the equation should be displayed as a separate block. @@ -703,7 +830,7 @@ impl<'a> Equation<'a> { node! { /// The contents of a mathematical equation: `x^2 + 1`. - Math + struct Math } impl<'a> Math<'a> { @@ -715,7 +842,7 @@ impl<'a> Math<'a> { node! { /// A lone text fragment in math: `x`, `25`, `3.1415`, `=`, `[`. - MathText + struct MathText } /// The underlying text kind. @@ -743,7 +870,7 @@ impl<'a> MathText<'a> { node! { /// An identifier in math: `pi`. - MathIdent + struct MathIdent } impl<'a> MathIdent<'a> { @@ -770,7 +897,7 @@ impl Deref for MathIdent<'_> { node! { /// A shorthand for a unicode codepoint in math: `a <= b`. - MathShorthand + struct MathShorthand } impl MathShorthand<'_> { @@ -828,40 +955,40 @@ impl MathShorthand<'_> { node! { /// An alignment point in math: `&`. - MathAlignPoint + struct MathAlignPoint } node! { /// Matched delimiters in math: `[x + y]`. - MathDelimited + struct MathDelimited } impl<'a> MathDelimited<'a> { /// The opening delimiter. pub fn open(self) -> Expr<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } /// The contents, including the delimiters. pub fn body(self) -> Math<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } /// The closing delimiter. pub fn close(self) -> Expr<'a> { - self.0.cast_last_match().unwrap_or_default() + self.0.cast_last() } } node! { /// A base with optional attachments in math: `a_1^2`. - MathAttach + struct MathAttach } impl<'a> MathAttach<'a> { /// The base, to which things are attached. pub fn base(self) -> Expr<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } /// The bottom attachment. @@ -892,7 +1019,7 @@ impl<'a> MathAttach<'a> { node! { /// Grouped primes in math: `a'''`. - MathPrimes + struct MathPrimes } impl MathPrimes<'_> { @@ -907,24 +1034,24 @@ impl MathPrimes<'_> { node! { /// A fraction in math: `x/2` - MathFrac + struct MathFrac } impl<'a> MathFrac<'a> { /// The numerator. pub fn num(self) -> Expr<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } /// The denominator. pub fn denom(self) -> Expr<'a> { - self.0.cast_last_match().unwrap_or_default() + self.0.cast_last() } } node! { /// A root in math: `√x`, `∛x` or `∜x`. - MathRoot + struct MathRoot } impl<'a> MathRoot<'a> { @@ -940,13 +1067,13 @@ impl<'a> MathRoot<'a> { /// The radicand. pub fn radicand(self) -> Expr<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } } node! { /// An identifier: `it`. - Ident + struct Ident } impl<'a> Ident<'a> { @@ -973,17 +1100,17 @@ impl Deref for Ident<'_> { node! { /// The `none` literal. - None + struct None } node! { /// The `auto` literal. - Auto + struct Auto } node! { /// A boolean: `true`, `false`. - Bool + struct Bool } impl Bool<'_> { @@ -995,7 +1122,7 @@ impl Bool<'_> { node! { /// An integer: `120`. - Int + struct Int } impl Int<'_> { @@ -1017,7 +1144,7 @@ impl Int<'_> { node! { /// A floating-point number: `1.2`, `10e-4`. - Float + struct Float } impl Float<'_> { @@ -1029,7 +1156,7 @@ impl Float<'_> { node! { /// A numeric value with a unit: `12pt`, `3cm`, `2em`, `90deg`, `50%`. - Numeric + struct Numeric } impl Numeric<'_> { @@ -1086,7 +1213,7 @@ pub enum Unit { node! { /// A quoted string: `"..."`. - Str + struct Str } impl Str<'_> { @@ -1136,19 +1263,19 @@ impl Str<'_> { node! { /// A code block: `{ let x = 1; x + 2 }`. - CodeBlock + struct CodeBlock } impl<'a> CodeBlock<'a> { /// The contained code. pub fn body(self) -> Code<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } } node! { /// The body of a code block. - Code + struct Code } impl<'a> Code<'a> { @@ -1160,19 +1287,19 @@ impl<'a> Code<'a> { node! { /// A content block: `[*Hi* there!]`. - ContentBlock + struct ContentBlock } impl<'a> ContentBlock<'a> { /// The contained markup. pub fn body(self) -> Markup<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } } node! { /// A grouped expression: `(1 + 2)`. - Parenthesized + struct Parenthesized } impl<'a> Parenthesized<'a> { @@ -1180,20 +1307,20 @@ impl<'a> Parenthesized<'a> { /// /// Should only be accessed if this is contained in an `Expr`. pub fn expr(self) -> Expr<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } /// The wrapped pattern. /// /// Should only be accessed if this is contained in a `Pattern`. pub fn pattern(self) -> Pattern<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } } node! { /// An array: `(1, "hi", 12cm)`. - Array + struct Array } impl<'a> Array<'a> { @@ -1215,7 +1342,7 @@ pub enum ArrayItem<'a> { impl<'a> AstNode<'a> for ArrayItem<'a> { fn from_untyped(node: &'a SyntaxNode) -> Option { match node.kind() { - SyntaxKind::Spread => node.cast().map(Self::Spread), + SyntaxKind::Spread => Some(Self::Spread(Spread(node))), _ => node.cast().map(Self::Pos), } } @@ -1230,7 +1357,7 @@ impl<'a> AstNode<'a> for ArrayItem<'a> { node! { /// A dictionary: `(thickness: 3pt, dash: "solid")`. - Dict + struct Dict } impl<'a> Dict<'a> { @@ -1254,9 +1381,9 @@ pub enum DictItem<'a> { impl<'a> AstNode<'a> for DictItem<'a> { fn from_untyped(node: &'a SyntaxNode) -> Option { match node.kind() { - SyntaxKind::Named => node.cast().map(Self::Named), - SyntaxKind::Keyed => node.cast().map(Self::Keyed), - SyntaxKind::Spread => node.cast().map(Self::Spread), + SyntaxKind::Named => Some(Self::Named(Named(node))), + SyntaxKind::Keyed => Some(Self::Keyed(Keyed(node))), + SyntaxKind::Spread => Some(Self::Spread(Spread(node))), _ => Option::None, } } @@ -1272,13 +1399,13 @@ impl<'a> AstNode<'a> for DictItem<'a> { node! { /// A named pair: `thickness: 3pt`. - Named + struct Named } impl<'a> Named<'a> { /// The name: `thickness`. pub fn name(self) -> Ident<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } /// The right-hand side of the pair: `3pt`. @@ -1286,7 +1413,7 @@ impl<'a> Named<'a> { /// This should only be accessed if this `Named` is contained in a /// `DictItem`, `Arg`, or `Param`. pub fn expr(self) -> Expr<'a> { - self.0.cast_last_match().unwrap_or_default() + self.0.cast_last() } /// The right-hand side of the pair as a pattern. @@ -1294,19 +1421,19 @@ impl<'a> Named<'a> { /// This should only be accessed if this `Named` is contained in a /// `Destructuring`. pub fn pattern(self) -> Pattern<'a> { - self.0.cast_last_match().unwrap_or_default() + self.0.cast_last() } } node! { /// A keyed pair: `"spacy key": true`. - Keyed + struct Keyed } impl<'a> Keyed<'a> { /// The key: `"spacy key"`. pub fn key(self) -> Expr<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } /// The right-hand side of the pair: `true`. @@ -1314,13 +1441,13 @@ impl<'a> Keyed<'a> { /// This should only be accessed if this `Keyed` is contained in a /// `DictItem`. pub fn expr(self) -> Expr<'a> { - self.0.cast_last_match().unwrap_or_default() + self.0.cast_last() } } node! { /// A spread: `..x` or `..x.at(0)`. - Spread + struct Spread } impl<'a> Spread<'a> { @@ -1329,7 +1456,7 @@ impl<'a> Spread<'a> { /// This should only be accessed if this `Spread` is contained in an /// `ArrayItem`, `DictItem`, or `Arg`. pub fn expr(self) -> Expr<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } /// The sink identifier, if present. @@ -1337,7 +1464,7 @@ impl<'a> Spread<'a> { /// This should only be accessed if this `Spread` is contained in a /// `Param` or binding `DestructuringItem`. pub fn sink_ident(self) -> Option> { - self.0.cast_first_match() + self.0.try_cast_first() } /// The sink expressions, if present. @@ -1345,13 +1472,13 @@ impl<'a> Spread<'a> { /// This should only be accessed if this `Spread` is contained in a /// `DestructuringItem`. pub fn sink_expr(self) -> Option> { - self.0.cast_first_match() + self.0.try_cast_first() } } node! { /// A unary operation: `-x`. - Unary + struct Unary } impl<'a> Unary<'a> { @@ -1365,7 +1492,7 @@ impl<'a> Unary<'a> { /// The expression to operate on: `x`. pub fn expr(self) -> Expr<'a> { - self.0.cast_last_match().unwrap_or_default() + self.0.cast_last() } } @@ -1411,7 +1538,7 @@ impl UnOp { node! { /// A binary operation: `a + b`. - Binary + struct Binary } impl<'a> Binary<'a> { @@ -1433,12 +1560,12 @@ impl<'a> Binary<'a> { /// The left-hand side of the operation: `a`. pub fn lhs(self) -> Expr<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } /// The right-hand side of the operation: `b`. pub fn rhs(self) -> Expr<'a> { - self.0.cast_last_match().unwrap_or_default() + self.0.cast_last() } } @@ -1598,41 +1725,41 @@ pub enum Assoc { node! { /// A field access: `properties.age`. - FieldAccess + struct FieldAccess } impl<'a> FieldAccess<'a> { /// The expression to access the field on. pub fn target(self) -> Expr<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } /// The name of the field. pub fn field(self) -> Ident<'a> { - self.0.cast_last_match().unwrap_or_default() + self.0.cast_last() } } node! { /// An invocation of a function or method: `f(x, y)`. - FuncCall + struct FuncCall } impl<'a> FuncCall<'a> { /// The function to call. pub fn callee(self) -> Expr<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } /// The arguments to the function. pub fn args(self) -> Args<'a> { - self.0.cast_last_match().unwrap_or_default() + self.0.cast_last() } } node! { /// A function call's argument list: `(12pt, y)`. - Args + struct Args } impl<'a> Args<'a> { @@ -1666,8 +1793,8 @@ pub enum Arg<'a> { impl<'a> AstNode<'a> for Arg<'a> { fn from_untyped(node: &'a SyntaxNode) -> Option { match node.kind() { - SyntaxKind::Named => node.cast().map(Self::Named), - SyntaxKind::Spread => node.cast().map(Self::Spread), + SyntaxKind::Named => Some(Self::Named(Named(node))), + SyntaxKind::Spread => Some(Self::Spread(Spread(node))), _ => node.cast().map(Self::Pos), } } @@ -1683,7 +1810,7 @@ impl<'a> AstNode<'a> for Arg<'a> { node! { /// A closure: `(x, y) => z`. - Closure + struct Closure } impl<'a> Closure<'a> { @@ -1696,18 +1823,18 @@ impl<'a> Closure<'a> { /// The parameter bindings. pub fn params(self) -> Params<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } /// The body of the closure. pub fn body(self) -> Expr<'a> { - self.0.cast_last_match().unwrap_or_default() + self.0.cast_last() } } node! { /// A closure's parameters: `(x, y)`. - Params + struct Params } impl<'a> Params<'a> { @@ -1731,8 +1858,8 @@ pub enum Param<'a> { impl<'a> AstNode<'a> for Param<'a> { fn from_untyped(node: &'a SyntaxNode) -> Option { match node.kind() { - SyntaxKind::Named => node.cast().map(Self::Named), - SyntaxKind::Spread => node.cast().map(Self::Spread), + SyntaxKind::Named => Some(Self::Named(Named(node))), + SyntaxKind::Spread => Some(Self::Spread(Spread(node))), _ => node.cast().map(Self::Pos), } } @@ -1762,9 +1889,9 @@ pub enum Pattern<'a> { impl<'a> AstNode<'a> for Pattern<'a> { fn from_untyped(node: &'a SyntaxNode) -> Option { match node.kind() { - SyntaxKind::Underscore => node.cast().map(Self::Placeholder), - SyntaxKind::Parenthesized => node.cast().map(Self::Parenthesized), - SyntaxKind::Destructuring => node.cast().map(Self::Destructuring), + SyntaxKind::Underscore => Some(Self::Placeholder(Underscore(node))), + SyntaxKind::Parenthesized => Some(Self::Parenthesized(Parenthesized(node))), + SyntaxKind::Destructuring => Some(Self::Destructuring(Destructuring(node))), _ => node.cast().map(Self::Normal), } } @@ -1799,12 +1926,12 @@ impl Default for Pattern<'_> { node! { /// An underscore: `_` - Underscore + struct Underscore } node! { /// A destructuring pattern: `x` or `(x, _, ..y)`. - Destructuring + struct Destructuring } impl<'a> Destructuring<'a> { @@ -1841,8 +1968,8 @@ pub enum DestructuringItem<'a> { impl<'a> AstNode<'a> for DestructuringItem<'a> { fn from_untyped(node: &'a SyntaxNode) -> Option { match node.kind() { - SyntaxKind::Named => node.cast().map(Self::Named), - SyntaxKind::Spread => node.cast().map(Self::Spread), + SyntaxKind::Named => Some(Self::Named(Named(node))), + SyntaxKind::Spread => Some(Self::Spread(Spread(node))), _ => node.cast().map(Self::Pattern), } } @@ -1858,7 +1985,7 @@ impl<'a> AstNode<'a> for DestructuringItem<'a> { node! { /// A let binding: `let x = 1`. - LetBinding + struct LetBinding } /// The kind of a let binding, either a normal one or a closure. @@ -1883,11 +2010,11 @@ impl<'a> LetBindingKind<'a> { impl<'a> LetBinding<'a> { /// The kind of the let binding. pub fn kind(self) -> LetBindingKind<'a> { - match self.0.cast_first_match::() { - Some(Pattern::Normal(Expr::Closure(closure))) => { + match self.0.cast_first() { + Pattern::Normal(Expr::Closure(closure)) => { LetBindingKind::Closure(closure.name().unwrap_or_default()) } - pattern => LetBindingKind::Normal(pattern.unwrap_or_default()), + pattern => LetBindingKind::Normal(pattern), } } @@ -1897,43 +2024,43 @@ impl<'a> LetBinding<'a> { LetBindingKind::Normal(Pattern::Normal(_) | Pattern::Parenthesized(_)) => { self.0.children().filter_map(SyntaxNode::cast).nth(1) } - LetBindingKind::Normal(_) => self.0.cast_first_match(), - LetBindingKind::Closure(_) => self.0.cast_first_match(), + LetBindingKind::Normal(_) => self.0.try_cast_first(), + LetBindingKind::Closure(_) => self.0.try_cast_first(), } } } node! { /// An assignment expression `(x, y) = (1, 2)`. - DestructAssignment + struct DestructAssignment } impl<'a> DestructAssignment<'a> { /// The pattern of the assignment. pub fn pattern(self) -> Pattern<'a> { - self.0.cast_first_match::().unwrap_or_default() + self.0.cast_first() } /// The expression that is assigned. pub fn value(self) -> Expr<'a> { - self.0.cast_last_match().unwrap_or_default() + self.0.cast_last() } } node! { /// A set rule: `set text(...)`. - SetRule + struct SetRule } impl<'a> SetRule<'a> { /// The function to set style properties for. pub fn target(self) -> Expr<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } /// The style properties to set. pub fn args(self) -> Args<'a> { - self.0.cast_last_match().unwrap_or_default() + self.0.cast_last() } /// A condition under which the set rule applies. @@ -1947,7 +2074,7 @@ impl<'a> SetRule<'a> { node! { /// A show rule: `show heading: it => emph(it.body)`. - ShowRule + struct ShowRule } impl<'a> ShowRule<'a> { @@ -1962,31 +2089,31 @@ impl<'a> ShowRule<'a> { /// The transformation recipe. pub fn transform(self) -> Expr<'a> { - self.0.cast_last_match().unwrap_or_default() + self.0.cast_last() } } node! { /// A contextual expression: `context text.lang`. - Contextual + struct Contextual } impl<'a> Contextual<'a> { /// The expression which depends on the context. pub fn body(self) -> Expr<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } } node! { /// An if-else conditional: `if x { y } else { z }`. - Conditional + struct Conditional } impl<'a> Conditional<'a> { /// The condition which selects the body to evaluate. pub fn condition(self) -> Expr<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } /// The expression to evaluate if the condition is true. @@ -2006,30 +2133,30 @@ impl<'a> Conditional<'a> { node! { /// A while loop: `while x { y }`. - WhileLoop + struct WhileLoop } impl<'a> WhileLoop<'a> { /// The condition which selects whether to evaluate the body. pub fn condition(self) -> Expr<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } /// The expression to evaluate while the condition is true. pub fn body(self) -> Expr<'a> { - self.0.cast_last_match().unwrap_or_default() + self.0.cast_last() } } node! { /// A for loop: `for x in y { z }`. - ForLoop + struct ForLoop } impl<'a> ForLoop<'a> { /// The pattern to assign to. pub fn pattern(self) -> Pattern<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } /// The expression to iterate over. @@ -2043,19 +2170,19 @@ impl<'a> ForLoop<'a> { /// The expression to evaluate for each iteration. pub fn body(self) -> Expr<'a> { - self.0.cast_last_match().unwrap_or_default() + self.0.cast_last() } } node! { /// A module import: `import "utils.typ": a, b, c`. - ModuleImport + struct ModuleImport } impl<'a> ModuleImport<'a> { /// The module or path from which the items should be imported. pub fn source(self) -> Expr<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } /// The items to be imported. @@ -2135,7 +2262,7 @@ pub enum Imports<'a> { node! { /// Items to import from a module: `a, b, c`. - ImportItems + struct ImportItems } impl<'a> ImportItems<'a> { @@ -2151,7 +2278,7 @@ impl<'a> ImportItems<'a> { node! { /// A path to a submodule's imported name: `a.b.c`. - ImportItemPath + struct ImportItemPath } impl<'a> ImportItemPath<'a> { @@ -2162,7 +2289,7 @@ impl<'a> ImportItemPath<'a> { /// The name of the imported item. This is the last segment in the path. pub fn name(self) -> Ident<'a> { - self.iter().last().unwrap_or_default() + self.0.cast_last() } } @@ -2207,13 +2334,13 @@ impl<'a> ImportItem<'a> { node! { /// A renamed import item: `a as d` - RenamedImportItem + struct RenamedImportItem } impl<'a> RenamedImportItem<'a> { /// The path to the imported item. pub fn path(self) -> ImportItemPath<'a> { - self.0.cast_first_match().unwrap_or_default() + self.0.cast_first() } /// The original name of the imported item (`a` in `a as d` or `c.b.a as d`). @@ -2223,45 +2350,41 @@ impl<'a> RenamedImportItem<'a> { /// The new name of the imported item (`d` in `a as d`). pub fn new_name(self) -> Ident<'a> { - self.0 - .children() - .filter_map(SyntaxNode::cast) - .last() - .unwrap_or_default() + self.0.cast_last() } } node! { /// A module include: `include "chapter1.typ"`. - ModuleInclude + struct ModuleInclude } impl<'a> ModuleInclude<'a> { /// The module or path from which the content should be included. pub fn source(self) -> Expr<'a> { - self.0.cast_last_match().unwrap_or_default() + self.0.cast_last() } } node! { /// A break from a loop: `break`. - LoopBreak + struct LoopBreak } node! { /// A continue in a loop: `continue`. - LoopContinue + struct LoopContinue } node! { /// A return from a function: `return`, `return x + 1`. - FuncReturn + struct FuncReturn } impl<'a> FuncReturn<'a> { /// The expression to return. pub fn body(self) -> Option> { - self.0.cast_last_match() + self.0.try_cast_last() } } diff --git a/crates/typst-syntax/src/node.rs b/crates/typst-syntax/src/node.rs index fde2eaca0f..948657ca45 100644 --- a/crates/typst-syntax/src/node.rs +++ b/crates/typst-syntax/src/node.rs @@ -5,7 +5,6 @@ use std::sync::Arc; use ecow::{eco_format, eco_vec, EcoString, EcoVec}; -use crate::ast::AstNode; use crate::{FileId, Span, SyntaxKind}; /// A node in the untyped syntax tree. @@ -119,26 +118,6 @@ impl SyntaxNode { } } - /// Whether the node can be cast to the given AST node. - pub fn is<'a, T: AstNode<'a>>(&'a self) -> bool { - self.cast::().is_some() - } - - /// Try to convert the node to a typed AST node. - pub fn cast<'a, T: AstNode<'a>>(&'a self) -> Option { - T::from_untyped(self) - } - - /// Cast the first child that can cast to the AST type `T`. - pub fn cast_first_match<'a, T: AstNode<'a>>(&'a self) -> Option { - self.children().find_map(Self::cast) - } - - /// Cast the last child that can cast to the AST type `T`. - pub fn cast_last_match<'a, T: AstNode<'a>>(&'a self) -> Option { - self.children().rev().find_map(Self::cast) - } - /// Whether the node or its children contain an error. pub fn erroneous(&self) -> bool { match &self.0 { From 66679920b25a80bf106148b59642fbae166e0d7a Mon Sep 17 00:00:00 2001 From: Tijme <68817281+7ijme@users.noreply.github.com> Date: Mon, 3 Mar 2025 10:32:06 +0100 Subject: [PATCH 039/558] Fix docs example with type/string comparison (#5987) --- crates/typst-library/src/loading/xml.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/typst-library/src/loading/xml.rs b/crates/typst-library/src/loading/xml.rs index daccd02fc8..e76c4e9cfa 100644 --- a/crates/typst-library/src/loading/xml.rs +++ b/crates/typst-library/src/loading/xml.rs @@ -34,14 +34,14 @@ use crate::loading::{DataSource, Load, Readable}; /// let author = find-child(elem, "author") /// let pars = find-child(elem, "content") /// -/// heading(title.children.first()) +/// [= #title.children.first()] /// text(10pt, weight: "medium")[ /// Published by /// #author.children.first() /// ] /// /// for p in pars.children { -/// if (type(p) == "dictionary") { +/// if type(p) == dictionary { /// parbreak() /// p.children.first() /// } @@ -50,7 +50,7 @@ use crate::loading::{DataSource, Load, Readable}; /// /// #let data = xml("example.xml") /// #for elem in data.first().children { -/// if (type(elem) == "dictionary") { +/// if type(elem) == dictionary { /// article(elem) /// } /// } From d4def0996235a791291bb39a570ae301feea099c Mon Sep 17 00:00:00 2001 From: F2011 <110890521+F2011@users.noreply.github.com> Date: Mon, 3 Mar 2025 21:23:29 +1000 Subject: [PATCH 040/558] Correct typo (#5971) --- docs/guides/guide-for-latex-users.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/guide-for-latex-users.md b/docs/guides/guide-for-latex-users.md index 5137ae1a9f..fffa6c5212 100644 --- a/docs/guides/guide-for-latex-users.md +++ b/docs/guides/guide-for-latex-users.md @@ -447,7 +447,7 @@ document. To let a function style your whole document, the show rule processes everything that comes after it and calls the function specified after the colon with the result as an argument. The `.with` part is a _method_ that takes the `conf` -function and pre-configures some if its arguments before passing it on to the +function and pre-configures some of its arguments before passing it on to the show rule. From bf0d45e2c0086ba2ae71eeebbfd26db7dfe7692f Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Mon, 3 Mar 2025 14:31:39 +0300 Subject: [PATCH 041/558] Make `array.chunks` example more readable (#5975) --- crates/typst-library/src/foundations/array.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-library/src/foundations/array.rs b/crates/typst-library/src/foundations/array.rs index aad7266bcd..e81b9e6457 100644 --- a/crates/typst-library/src/foundations/array.rs +++ b/crates/typst-library/src/foundations/array.rs @@ -751,7 +751,7 @@ impl Array { /// /// ```example /// #let array = (1, 2, 3, 4, 5, 6, 7, 8) - /// #array.chunks(3) + /// #array.chunks(3) \ /// #array.chunks(3, exact: true) /// ``` #[func] From 9a6ffbc7db95eff2aedd8028b8969a744717aaa4 Mon Sep 17 00:00:00 2001 From: andis854 <123587604+andis854@users.noreply.github.com> Date: Mon, 3 Mar 2025 12:40:58 +0100 Subject: [PATCH 042/558] Added snap to installation instructions (#5984) --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a5d20d2e65..41f4651523 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,9 @@ Typst's CLI is available from different sources: - You can install Typst through different package managers. Note that the versions in the package managers might lag behind the latest release. - - Linux: View [Typst on Repology][repology] + - Linux: + - View [Typst on Repology][repology] + - View [Typst's Snap][snap] - macOS: `brew install typst` - Windows: `winget install --id Typst.Typst` @@ -254,3 +256,4 @@ instant preview. To achieve these goals, we follow three core design principles: [contributing]: https://github.com/typst/typst/blob/main/CONTRIBUTING.md [packages]: https://github.com/typst/packages/ [`comemo`]: https://github.com/typst/comemo/ +[snap]: https://snapcraft.io/typst From 8820a00beb08b7253a99a7cf66bb752cd181bb03 Mon Sep 17 00:00:00 2001 From: 3w36zj6 <52315048+3w36zj6@users.noreply.github.com> Date: Mon, 3 Mar 2025 20:50:47 +0900 Subject: [PATCH 043/558] Respect `quotes: false` in inline quote (#5991) Co-authored-by: Laurenz --- crates/typst-library/src/model/quote.rs | 2 +- .../ref/issue-5536-quote-inline-quotes-false.png | Bin 0 -> 389 bytes tests/suite/model/quote.typ | 3 +++ 3 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 tests/ref/issue-5536-quote-inline-quotes-false.png diff --git a/crates/typst-library/src/model/quote.rs b/crates/typst-library/src/model/quote.rs index 919ab12c74..cd45eec8e2 100644 --- a/crates/typst-library/src/model/quote.rs +++ b/crates/typst-library/src/model/quote.rs @@ -161,7 +161,7 @@ impl Show for Packed { let block = self.block(styles); let html = TargetElem::target_in(styles).is_html(); - if self.quotes(styles) == Smart::Custom(true) || !block { + if self.quotes(styles).unwrap_or(!block) { let quotes = SmartQuotes::get( SmartQuoteElem::quotes_in(styles), TextElem::lang_in(styles), diff --git a/tests/ref/issue-5536-quote-inline-quotes-false.png b/tests/ref/issue-5536-quote-inline-quotes-false.png new file mode 100644 index 0000000000000000000000000000000000000000..e7b29d7120b74fd980a69fb8ee6139efd8e8b88c GIT binary patch literal 389 zcmV;00eb$4P)ONM)uhjEBn0gdOb&EAX z#FoiDuO9`jFZe%q@y6$2yDO$F%DM~`{yt^8?E9Y`bLWFBzS}nO7-U9}8)bamw+aHC2;>7#r+*#BL>OT_y|75>3{%|Gn zzt^q*i!%N{{vUoG=$d_5K*{`>-}Em{srX-b_@?iFr&Ir}e@&k7|NkMXSiB- Date: Mon, 3 Mar 2025 14:10:58 +0100 Subject: [PATCH 044/558] Run tests on 32-bit via Ubuntu multilib (#5937) Co-authored-by: Laurenz --- .github/workflows/ci.yml | 24 +++++++++++++++++++----- tests/suite/model/numbering.typ | 1 - 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f0ada9f9f..41f17d1371 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ env: RUSTFLAGS: "-Dwarnings" RUSTDOCFLAGS: "-Dwarnings" TYPST_TESTS_EXTENDED: true + PKG_CONFIG_i686-unknown-linux-gnu: /usr/bin/i686-linux-gnu-pkgconf jobs: # This allows us to have one branch protection rule for the full test matrix. @@ -27,30 +28,43 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest] + bits: [64] + include: + - os: ubuntu-latest + bits: 32 runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + - if: startsWith(matrix.os, 'ubuntu-') && matrix.bits == 32 + run: | + sudo dpkg --add-architecture i386 + sudo apt update + sudo apt install -y gcc-multilib libssl-dev:i386 pkg-config:i386 - uses: dtolnay/rust-toolchain@1.85.0 + with: + targets: ${{ matrix.bits == 32 && 'i686-unknown-linux-gnu' || '' }} - uses: Swatinem/rust-cache@v2 - - run: cargo test --workspace --no-run - - run: cargo test --workspace --no-fail-fast + with: + key: ${{ matrix.bits }} + - run: cargo test --workspace --no-run ${{ matrix.bits == 32 && '--target i686-unknown-linux-gnu' || '' }} + - run: cargo test --workspace --no-fail-fast ${{ matrix.bits == 32 && '--target i686-unknown-linux-gnu' || '' }} - name: Upload rendered test output if: failure() uses: actions/upload-artifact@v4 with: - name: tests-rendered-${{ matrix.os }} + name: tests-rendered-${{ matrix.os }}-${{ matrix.bits }} path: tests/store/render/** retention-days: 3 - name: Update test artifacts if: failure() run: | - cargo test --workspace --test tests -- --update + cargo test --workspace --test tests ${{ matrix.bits == 32 && '--target i686-unknown-linux-gnu' || '' }} -- --update echo 'updated_artifacts=1' >> "$GITHUB_ENV" - name: Upload updated reference output (for use if the test changes are desired) if: failure() && env.updated_artifacts uses: actions/upload-artifact@v4 with: - name: tests-updated-${{ matrix.os }} + name: tests-updated-${{ matrix.os }}-${{ matrix.bits }} path: tests/ref/** retention-days: 3 diff --git a/tests/suite/model/numbering.typ b/tests/suite/model/numbering.typ index 6af989ff1d..ccd7cfc18e 100644 --- a/tests/suite/model/numbering.typ +++ b/tests/suite/model/numbering.typ @@ -49,7 +49,6 @@ 2000000001, "βΜκʹ, αʹ", 2000010001, "βΜκʹ, αΜαʹ, αʹ", 2056839184, "βΜκʹ, αΜ͵εχπγ, ͵θρπδ", - 12312398676, "βΜρκγʹ, αΜ͵ασλθ, ͵ηχοϛ", ) #t( pat: sym.Alpha, From 6271cdceae146efe75942ebde7712a942627c42f Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Tue, 4 Mar 2025 12:33:39 +0300 Subject: [PATCH 045/558] Fix debug implementation of Recipe (#5997) --- crates/typst-library/src/foundations/styles.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/typst-library/src/foundations/styles.rs b/crates/typst-library/src/foundations/styles.rs index 983803300f..d124f2c879 100644 --- a/crates/typst-library/src/foundations/styles.rs +++ b/crates/typst-library/src/foundations/styles.rs @@ -471,7 +471,8 @@ impl Debug for Recipe { selector.fmt(f)?; f.write_str(", ")?; } - self.transform.fmt(f) + self.transform.fmt(f)?; + f.write_str(")") } } From e1a9166e1d6a24076796efaf4eec073567bfb037 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Fri, 7 Mar 2025 09:22:42 +0100 Subject: [PATCH 046/558] Hotfix for labels on symbols (#6015) --- crates/typst-realize/src/lib.rs | 5 ++++- tests/ref/issue-5930-symbol-label.png | Bin 0 -> 243 bytes tests/suite/symbols/symbol.typ | 4 ++++ 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 tests/ref/issue-5930-symbol-label.png diff --git a/crates/typst-realize/src/lib.rs b/crates/typst-realize/src/lib.rs index 50685a962f..151ae76ba1 100644 --- a/crates/typst-realize/src/lib.rs +++ b/crates/typst-realize/src/lib.rs @@ -326,7 +326,10 @@ fn visit_math_rules<'a>( // Symbols in non-math content transparently convert to `TextElem` so we // don't have to handle them in non-math layout. if let Some(elem) = content.to_packed::() { - let text = TextElem::packed(elem.text).spanned(elem.span()); + let mut text = TextElem::packed(elem.text).spanned(elem.span()); + if let Some(label) = elem.label() { + text.set_label(label); + } visit(s, s.store(text), styles)?; return Ok(true); } diff --git a/tests/ref/issue-5930-symbol-label.png b/tests/ref/issue-5930-symbol-label.png new file mode 100644 index 0000000000000000000000000000000000000000..e8127aa0cc494f76def5a178a1985e2ceb294f94 GIT binary patch literal 243 zcmV@g#X!k|JZr|%uUb4ul2)7>$^n8xuD{*KiR4}*s40knJ~PO zEV76vgJ>{#SQT(i1!hG6U}$ZP0001HNklEz3` t-N$in$PcO^<-AY_xoBX~AE|Thn+MaJ1Fvqfmev3O002ovPDHLkV1n1FcTE5Q literal 0 HcmV?d00001 diff --git a/tests/suite/symbols/symbol.typ b/tests/suite/symbols/symbol.typ index 6d2513c1fb..5bc2cafae0 100644 --- a/tests/suite/symbols/symbol.typ +++ b/tests/suite/symbols/symbol.typ @@ -151,3 +151,7 @@ --- symbol-sect-deprecated --- // Warning: 5-9 `sect` is deprecated, use `inter` instead $ A sect B = A inter B $ + +--- issue-5930-symbol-label --- +#emoji.face +#context test(query().first().text, "😀") From 99b7d2898e802356c66fed01179d42cab9198617 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Fri, 7 Mar 2025 09:47:56 +0100 Subject: [PATCH 047/558] Replace `par` function call in tutorial (#6023) --- docs/tutorial/2-formatting.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/tutorial/2-formatting.md b/docs/tutorial/2-formatting.md index fabb544f4c..a8c72cefe0 100644 --- a/docs/tutorial/2-formatting.md +++ b/docs/tutorial/2-formatting.md @@ -13,11 +13,11 @@ your report using Typst's styling system. As we have seen in the previous chapter, Typst has functions that _insert_ content (e.g. the [`image`] function) and others that _manipulate_ content that they received as arguments (e.g. the [`align`] function). The first impulse you -might have when you want, for example, to justify the report, could be to look +might have when you want, for example, to change the font, could be to look for a function that does that and wrap the complete document in it. ```example -#par(justify: true)[ +#text(font: "New Computer Modern")[ = Background In the case of glaciers, fluid dynamics principles can be used @@ -37,9 +37,9 @@ do in Typst, there is special syntax for it: Instead of putting the content inside of the argument list, you can write it in square brackets directly after the normal arguments, saving on punctuation. -As seen above, that works. The [`par`] function justifies all paragraphs within -it. However, wrapping the document in countless functions and applying styles -selectively and in-situ can quickly become cumbersome. +As seen above, that works. With the [`text`] function, we can adjust the font +for all text within it. However, wrapping the document in countless functions +and applying styles selectively and in-situ can quickly become cumbersome. Fortunately, Typst has a more elegant solution. With _set rules,_ you can apply style properties to all occurrences of some kind of content. You write a set @@ -47,7 +47,9 @@ rule by entering the `{set}` keyword, followed by the name of the function whose properties you want to set, and a list of arguments in parentheses. ```example -#set par(justify: true) +#set text( + font: "New Computer Modern" +) = Background In the case of glaciers, fluid From e0b2c32a8ee6fd5bb43e7f8973972d76bdef573b Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Fri, 7 Mar 2025 10:05:16 +0100 Subject: [PATCH 048/558] Mention that `sym.ohm` was removed in the 0.13.0 changelog (#6017) Co-authored-by: Laurenz --- docs/changelog/0.13.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog/0.13.0.md b/docs/changelog/0.13.0.md index 6c2fe4275a..50e7fca728 100644 --- a/docs/changelog/0.13.0.md +++ b/docs/changelog/0.13.0.md @@ -294,7 +294,6 @@ feature flag. `errorbar.diamond.stroked`, `errorbar.diamond.filled`, `errorbar.circle.stroked`, `errorbar.circle.filled` - `numero` - - `Omega.inv` - Renamed - `ohm.inv` to `Omega.inv` - Changed codepoint @@ -308,6 +307,7 @@ feature flag. - `degree.c` in favor of `°C` (`[$upright(°C)$]` or `[$upright(degree C)$]` in math) - `degree.f` in favor of `°F` (`[$upright(°F)$]` or `[$upright(degree F)$]` in math) - `kelvin` in favor of just K (`[$upright(K)$]` in math) + - `ohm` in favor of `Omega` ## Deprecations - The [`path`] function in favor of the [`curve`] function From 476c2df312e8c80ff455a355ce1e987312444cb8 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Fri, 7 Mar 2025 10:17:11 +0100 Subject: [PATCH 049/558] Mark breaking symbol changes as breaking in 0.13.0 changelog (#6024) --- docs/changelog/0.13.0.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/changelog/0.13.0.md b/docs/changelog/0.13.0.md index 50e7fca728..1cca48aa20 100644 --- a/docs/changelog/0.13.0.md +++ b/docs/changelog/0.13.0.md @@ -294,16 +294,16 @@ feature flag. `errorbar.diamond.stroked`, `errorbar.diamond.filled`, `errorbar.circle.stroked`, `errorbar.circle.filled` - `numero` -- Renamed +- Renamed **(Breaking change)** - `ohm.inv` to `Omega.inv` -- Changed codepoint +- Changed codepoint **(Breaking change)** - `angle.l.double` from `《` to `⟪` - `angle.r.double` from `》` to `⟫` - `angstrom` from U+212B (`Å`) to U+00C5 (`Å`) - Deprecated - `sect` and all its variants in favor of `inter` - `integral.sect` in favor of `integral.inter` -- Removed +- Removed **(Breaking change)** - `degree.c` in favor of `°C` (`[$upright(°C)$]` or `[$upright(degree C)$]` in math) - `degree.f` in favor of `°F` (`[$upright(°F)$]` or `[$upright(degree F)$]` in math) - `kelvin` in favor of just K (`[$upright(K)$]` in math) From 8d3488a07df83760cafa1e17bf4ed4de415a4d69 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Fri, 7 Mar 2025 11:03:52 +0100 Subject: [PATCH 050/558] 0.13.1 changelog (#6025) --- docs/changelog/0.13.1.md | 26 ++++++++++++++++++++++++++ docs/changelog/welcome.md | 1 + docs/src/lib.rs | 1 + 3 files changed, 28 insertions(+) create mode 100644 docs/changelog/0.13.1.md diff --git a/docs/changelog/0.13.1.md b/docs/changelog/0.13.1.md new file mode 100644 index 0000000000..15bd9f6d80 --- /dev/null +++ b/docs/changelog/0.13.1.md @@ -0,0 +1,26 @@ +--- +title: 0.13.1 +description: Changes in Typst 0.13.1 +--- + +# Version 0.13.1 + +## Command Line Interface +- Fixed high CPU usage for `typst watch` on Linux. Depending on the project + size, CPU usage would spike for varying amounts of time. This bug appeared + with 0.13.0 due to a behavioral change in the inotify file watching backend. + +## HTML export +- Fixed export of tables with [gutters]($table.gutter) +- Fixed usage of `` and `` element within [context] +- Fixed querying of [metadata] next to `` and `` element + +## Visualization +- Fixed [curves]($curve) with multiple non-closed components + +## Introspection +- Fixed a regression where labelled [symbols]($symbol) could not be + [queried]($query) by label + +## Deprecations +- Fixed false positives in deprecation warnings for type/str comparisons diff --git a/docs/changelog/welcome.md b/docs/changelog/welcome.md index 8fb85f870c..7611f1c447 100644 --- a/docs/changelog/welcome.md +++ b/docs/changelog/welcome.md @@ -10,6 +10,7 @@ forward. This section documents all changes to Typst since its initial public release. ## Versions +- [Typst 0.13.1]($changelog/0.13.1) - [Typst 0.13.0]($changelog/0.13.0) - [Typst 0.12.0]($changelog/0.12.0) - [Typst 0.11.1]($changelog/0.11.1) diff --git a/docs/src/lib.rs b/docs/src/lib.rs index e9771738d7..091bb1b243 100644 --- a/docs/src/lib.rs +++ b/docs/src/lib.rs @@ -188,6 +188,7 @@ fn changelog_pages(resolver: &dyn Resolver) -> PageModel { let mut page = md_page(resolver, resolver.base(), load!("changelog/welcome.md")); let base = format!("{}changelog/", resolver.base()); page.children = vec![ + md_page(resolver, &base, load!("changelog/0.13.1.md")), md_page(resolver, &base, load!("changelog/0.13.0.md")), md_page(resolver, &base, load!("changelog/0.12.0.md")), md_page(resolver, &base, load!("changelog/0.11.1.md")), From db9a83d9fc2c9928bcfbc78ccafc2a799ccca2f0 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Fri, 7 Mar 2025 11:19:12 +0100 Subject: [PATCH 051/558] Bump version on main The tagged commit itself is on the 0.13 branch. --- Cargo.lock | 48 ++++++++++++++++++++-------------------- Cargo.toml | 38 +++++++++++++++---------------- docs/changelog/0.13.1.md | 5 ++++- 3 files changed, 47 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 86f04ee529..85698d8bb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "adler2" @@ -2735,7 +2735,7 @@ checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" [[package]] name = "typst" -version = "0.13.0" +version = "0.13.1" dependencies = [ "comemo", "ecow", @@ -2752,12 +2752,12 @@ dependencies = [ [[package]] name = "typst-assets" -version = "0.13.0" -source = "git+https://github.com/typst/typst-assets?rev=fa0f8a4#fa0f8a438cc4bc2113cc0aa3304cd68cdc2bc020" +version = "0.13.1" +source = "git+https://github.com/typst/typst-assets?rev=ab1295f#ab1295ff896444e51902e03c2669955e1d73604a" [[package]] name = "typst-cli" -version = "0.13.0" +version = "0.13.1" dependencies = [ "chrono", "clap", @@ -2802,12 +2802,12 @@ dependencies = [ [[package]] name = "typst-dev-assets" -version = "0.13.0" -source = "git+https://github.com/typst/typst-dev-assets?rev=61aebe9#61aebe9575a5abff889f76d73c7b01dc8e17e340" +version = "0.13.1" +source = "git+https://github.com/typst/typst-dev-assets?rev=9879589#9879589f4b3247b12c5e694d0d7fa86d4d8a198e" [[package]] name = "typst-docs" -version = "0.13.0" +version = "0.13.1" dependencies = [ "clap", "ecow", @@ -2830,7 +2830,7 @@ dependencies = [ [[package]] name = "typst-eval" -version = "0.13.0" +version = "0.13.1" dependencies = [ "comemo", "ecow", @@ -2848,7 +2848,7 @@ dependencies = [ [[package]] name = "typst-fuzz" -version = "0.13.0" +version = "0.13.1" dependencies = [ "comemo", "libfuzzer-sys", @@ -2860,7 +2860,7 @@ dependencies = [ [[package]] name = "typst-html" -version = "0.13.0" +version = "0.13.1" dependencies = [ "comemo", "ecow", @@ -2874,7 +2874,7 @@ dependencies = [ [[package]] name = "typst-ide" -version = "0.13.0" +version = "0.13.1" dependencies = [ "comemo", "ecow", @@ -2891,7 +2891,7 @@ dependencies = [ [[package]] name = "typst-kit" -version = "0.13.0" +version = "0.13.1" dependencies = [ "dirs", "ecow", @@ -2914,7 +2914,7 @@ dependencies = [ [[package]] name = "typst-layout" -version = "0.13.0" +version = "0.13.1" dependencies = [ "az", "bumpalo", @@ -2944,7 +2944,7 @@ dependencies = [ [[package]] name = "typst-library" -version = "0.13.0" +version = "0.13.1" dependencies = [ "az", "bitflags 2.8.0", @@ -3005,7 +3005,7 @@ dependencies = [ [[package]] name = "typst-macros" -version = "0.13.0" +version = "0.13.1" dependencies = [ "heck", "proc-macro2", @@ -3015,7 +3015,7 @@ dependencies = [ [[package]] name = "typst-pdf" -version = "0.13.0" +version = "0.13.1" dependencies = [ "arrayvec", "base64", @@ -3041,7 +3041,7 @@ dependencies = [ [[package]] name = "typst-realize" -version = "0.13.0" +version = "0.13.1" dependencies = [ "arrayvec", "bumpalo", @@ -3057,7 +3057,7 @@ dependencies = [ [[package]] name = "typst-render" -version = "0.13.0" +version = "0.13.1" dependencies = [ "bytemuck", "comemo", @@ -3073,7 +3073,7 @@ dependencies = [ [[package]] name = "typst-svg" -version = "0.13.0" +version = "0.13.1" dependencies = [ "base64", "comemo", @@ -3091,7 +3091,7 @@ dependencies = [ [[package]] name = "typst-syntax" -version = "0.13.0" +version = "0.13.1" dependencies = [ "ecow", "serde", @@ -3107,7 +3107,7 @@ dependencies = [ [[package]] name = "typst-tests" -version = "0.13.0" +version = "0.13.1" dependencies = [ "clap", "comemo", @@ -3132,7 +3132,7 @@ dependencies = [ [[package]] name = "typst-timing" -version = "0.13.0" +version = "0.13.1" dependencies = [ "parking_lot", "serde", @@ -3142,7 +3142,7 @@ dependencies = [ [[package]] name = "typst-utils" -version = "0.13.0" +version = "0.13.1" dependencies = [ "once_cell", "portable-atomic", diff --git a/Cargo.toml b/Cargo.toml index f643856e1d..0bfd928210 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ default-members = ["crates/typst-cli"] resolver = "2" [workspace.package] -version = "0.13.0" +version = "0.13.1" rust-version = "1.83" # also change in ci.yml authors = ["The Typst Project Developers"] edition = "2021" @@ -16,24 +16,24 @@ keywords = ["typst"] readme = "README.md" [workspace.dependencies] -typst = { path = "crates/typst", version = "0.13.0" } -typst-cli = { path = "crates/typst-cli", version = "0.13.0" } -typst-eval = { path = "crates/typst-eval", version = "0.13.0" } -typst-html = { path = "crates/typst-html", version = "0.13.0" } -typst-ide = { path = "crates/typst-ide", version = "0.13.0" } -typst-kit = { path = "crates/typst-kit", version = "0.13.0" } -typst-layout = { path = "crates/typst-layout", version = "0.13.0" } -typst-library = { path = "crates/typst-library", version = "0.13.0" } -typst-macros = { path = "crates/typst-macros", version = "0.13.0" } -typst-pdf = { path = "crates/typst-pdf", version = "0.13.0" } -typst-realize = { path = "crates/typst-realize", version = "0.13.0" } -typst-render = { path = "crates/typst-render", version = "0.13.0" } -typst-svg = { path = "crates/typst-svg", version = "0.13.0" } -typst-syntax = { path = "crates/typst-syntax", version = "0.13.0" } -typst-timing = { path = "crates/typst-timing", version = "0.13.0" } -typst-utils = { path = "crates/typst-utils", version = "0.13.0" } -typst-assets = { git = "https://github.com/typst/typst-assets", rev = "fa0f8a4" } -typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "61aebe9" } +typst = { path = "crates/typst", version = "0.13.1" } +typst-cli = { path = "crates/typst-cli", version = "0.13.1" } +typst-eval = { path = "crates/typst-eval", version = "0.13.1" } +typst-html = { path = "crates/typst-html", version = "0.13.1" } +typst-ide = { path = "crates/typst-ide", version = "0.13.1" } +typst-kit = { path = "crates/typst-kit", version = "0.13.1" } +typst-layout = { path = "crates/typst-layout", version = "0.13.1" } +typst-library = { path = "crates/typst-library", version = "0.13.1" } +typst-macros = { path = "crates/typst-macros", version = "0.13.1" } +typst-pdf = { path = "crates/typst-pdf", version = "0.13.1" } +typst-realize = { path = "crates/typst-realize", version = "0.13.1" } +typst-render = { path = "crates/typst-render", version = "0.13.1" } +typst-svg = { path = "crates/typst-svg", version = "0.13.1" } +typst-syntax = { path = "crates/typst-syntax", version = "0.13.1" } +typst-timing = { path = "crates/typst-timing", version = "0.13.1" } +typst-utils = { path = "crates/typst-utils", version = "0.13.1" } +typst-assets = { git = "https://github.com/typst/typst-assets", rev = "ab1295f" } +typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "9879589" } arrayvec = "0.7.4" az = "1.2" base64 = "0.22" diff --git a/docs/changelog/0.13.1.md b/docs/changelog/0.13.1.md index 15bd9f6d80..caf523e1c1 100644 --- a/docs/changelog/0.13.1.md +++ b/docs/changelog/0.13.1.md @@ -3,7 +3,7 @@ title: 0.13.1 description: Changes in Typst 0.13.1 --- -# Version 0.13.1 +# Version 0.13.1 (March 7, 2025) ## Command Line Interface - Fixed high CPU usage for `typst watch` on Linux. Depending on the project @@ -24,3 +24,6 @@ description: Changes in Typst 0.13.1 ## Deprecations - Fixed false positives in deprecation warnings for type/str comparisons + +## Contributors + From e66e190a21be9fdb62191d023362154d7c24ffa9 Mon Sep 17 00:00:00 2001 From: Ludovico Gerardi Date: Mon, 10 Mar 2025 12:39:30 +0100 Subject: [PATCH 052/558] Fix typo in docs (#6034) --- crates/typst-library/src/foundations/func.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-library/src/foundations/func.rs b/crates/typst-library/src/foundations/func.rs index 66c6b70a5c..27eb34eacb 100644 --- a/crates/typst-library/src/foundations/func.rs +++ b/crates/typst-library/src/foundations/func.rs @@ -112,7 +112,7 @@ use crate::foundations::{ /// it into another file by writing `{import "foo.typ": alert}`. /// /// # Unnamed functions { #unnamed } -/// You can also created an unnamed function without creating a binding by +/// You can also create an unnamed function without creating a binding by /// specifying a parameter list followed by `=>` and the function body. If your /// function has just one parameter, the parentheses around the parameter list /// are optional. Unnamed functions are mainly useful for show rules, but also From bd531e08dc3dbe26ac779d5730bf0814800b7de9 Mon Sep 17 00:00:00 2001 From: Caleb Maclennan Date: Mon, 10 Mar 2025 15:45:08 +0300 Subject: [PATCH 053/558] Bump `rustybuzz` (and adjacent crates) (#5407) --- Cargo.lock | 40 ++++++++++++++++++------------------ Cargo.toml | 14 ++++++------- crates/typst-pdf/src/font.rs | 2 +- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 85698d8bb2..ac08b57ee8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -772,9 +772,9 @@ dependencies = [ [[package]] name = "fontdb" -version = "0.21.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37be9fc20d966be438cd57a45767f73349477fb0f85ce86e000557f787298afb" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" dependencies = [ "fontconfig-parser", "log", @@ -1175,9 +1175,9 @@ dependencies = [ [[package]] name = "image-webp" -version = "0.1.3" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f79afb8cbee2ef20f59ccd477a218c12a93943d075b492015ecb1bb81f8ee904" +checksum = "b77d01e822461baa8409e156015a1d91735549f0f2c17691bd2d996bef238f7f" dependencies = [ "byteorder-lite", "quick-error", @@ -1804,9 +1804,9 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" [[package]] name = "pixglyph" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d15afa937836bf3d876f5a04ce28810c06045857bf46c3d0d31073b8aada5494" +checksum = "3c1106193bc18a4b840eb075ff6664c8a0b0270f0531bb12a7e9c803e53b55c5" dependencies = [ "ttf-parser", ] @@ -2048,9 +2048,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "resvg" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7314563c59c7ce31c18e23ad3dd092c37b928a0fa4e1c0a1a6504351ab411d1" +checksum = "dd43d1c474e9dadf09a8fdf22d713ba668b499b5117b9b9079500224e26b5b29" dependencies = [ "gif", "image-webp", @@ -2121,9 +2121,9 @@ checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" [[package]] name = "rustybuzz" -version = "0.18.0" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c85d1ccd519e61834798eb52c4e886e8c2d7d698dd3d6ce0b1b47eb8557f1181" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" dependencies = [ "bitflags 2.8.0", "bytemuck", @@ -2410,9 +2410,9 @@ checksum = "74f98178f34057d4d4de93d68104007c6dea4dfac930204a69ab4622daefa648" [[package]] name = "svg2pdf" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5014c9dadcf318fb7ef8c16438e95abcc9de1ae24d60d5bccc64c55100c50364" +checksum = "e50dc062439cc1a396181059c80932a6e6bd731b130e674c597c0c8874b6df22" dependencies = [ "fontdb", "image", @@ -2709,9 +2709,9 @@ dependencies = [ [[package]] name = "ttf-parser" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5be21190ff5d38e8b4a2d3b6a3ae57f612cc39c96e83cedeaf7abc338a8bac4a" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" dependencies = [ "core_maths", ] @@ -3185,15 +3185,15 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-bidi-mirroring" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64af057ad7466495ca113126be61838d8af947f41d93a949980b2389a118082f" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" [[package]] name = "unicode-ccc" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "260bc6647b3893a9a90668360803a15f96b85a5257b1c3a0c3daf6ae2496de42" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" [[package]] name = "unicode-ident" @@ -3288,9 +3288,9 @@ dependencies = [ [[package]] name = "usvg" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6803057b5cbb426e9fb8ce2216f3a9b4ca1dd2c705ba3cbebc13006e437735fd" +checksum = "2ac8e0e3e4696253dc06167990b3fe9a2668ab66270adf949a464db4088cb354" dependencies = [ "base64", "data-url", diff --git a/Cargo.toml b/Cargo.toml index 0bfd928210..40abaaca7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,7 +56,7 @@ dirs = "6" ecow = { version = "0.2", features = ["serde"] } env_proxy = "0.4" flate2 = "1" -fontdb = { version = "0.21", default-features = false } +fontdb = { version = "0.23", default-features = false } fs_extra = "1.3" hayagriva = "0.8.1" heck = "0.5" @@ -86,7 +86,7 @@ parking_lot = "0.12.1" pathdiff = "0.2" pdf-writer = "0.12.1" phf = { version = "0.11", features = ["macros"] } -pixglyph = "0.5.1" +pixglyph = "0.6" png = "0.17" portable-atomic = "1.6" proc-macro2 = "1" @@ -96,10 +96,10 @@ quote = "1" rayon = "1.7.0" regex = "1" regex-syntax = "0.8" -resvg = { version = "0.43", default-features = false, features = ["raster-images"] } +resvg = { version = "0.45", default-features = false, features = ["raster-images"] } roxmltree = "0.20" rust_decimal = { version = "1.36.0", default-features = false, features = ["maths"] } -rustybuzz = "0.18" +rustybuzz = "0.20" same-file = "1" self-replace = "1.3.7" semver = "1" @@ -112,7 +112,7 @@ siphasher = "1" smallvec = { version = "1.11.1", features = ["union", "const_generics", "const_new"] } stacker = "0.1.15" subsetter = "0.2" -svg2pdf = "0.12" +svg2pdf = "0.13" syn = { version = "2", features = ["full", "extra-traits"] } syntect = { version = "5", default-features = false, features = ["parsing", "regex-fancy", "plist-load", "yaml-load"] } tar = "0.4" @@ -122,7 +122,7 @@ 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" +ttf-parser = "0.25.0" two-face = { version = "0.4.3", default-features = false, features = ["syntect-fancy"] } typed-arena = "2" unicode-bidi = "0.3.18" @@ -133,7 +133,7 @@ unicode-normalization = "0.1.24" unicode-segmentation = "1" unscanny = "0.1" ureq = { version = "2", default-features = false, features = ["native-tls", "gzip", "json"] } -usvg = { version = "0.43", default-features = false, features = ["text"] } +usvg = { version = "0.45", default-features = false, features = ["text"] } walkdir = "2" wasmi = "0.40.0" web-sys = "0.3" diff --git a/crates/typst-pdf/src/font.rs b/crates/typst-pdf/src/font.rs index 93d75e50e3..f2df2ac92a 100644 --- a/crates/typst-pdf/src/font.rs +++ b/crates/typst-pdf/src/font.rs @@ -180,7 +180,7 @@ pub fn write_font_descriptor<'a>( font.to_em(global_bbox.y_max).to_font_units(), ); - let italic_angle = ttf.italic_angle().unwrap_or(0.0); + let italic_angle = ttf.italic_angle(); let ascender = metrics.ascender.to_font_units(); let descender = metrics.descender.to_font_units(); let cap_height = metrics.cap_height.to_font_units(); From 3650859ae8823f47c9f50db6ad5ed52a0477bf15 Mon Sep 17 00:00:00 2001 From: evie <50974538+mi2ebi@users.noreply.github.com> Date: Tue, 11 Mar 2025 03:00:53 -0700 Subject: [PATCH 054/558] Fix `cargo clippy` warnings (mostly about `.repeat.take` and `.next_back`) (#6038) --- crates/typst-ide/src/analyze.rs | 2 +- crates/typst-layout/src/flow/compose.rs | 2 +- crates/typst-layout/src/grid/layouter.rs | 2 +- crates/typst-layout/src/math/stretch.rs | 2 +- crates/typst-library/src/foundations/cast.rs | 2 +- crates/typst-library/src/model/numbering.rs | 2 +- crates/typst-library/src/visualize/gradient.rs | 3 +-- crates/typst-render/src/shape.rs | 8 +++++--- 8 files changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/typst-ide/src/analyze.rs b/crates/typst-ide/src/analyze.rs index 7ee83e7097..c493da81a4 100644 --- a/crates/typst-ide/src/analyze.rs +++ b/crates/typst-ide/src/analyze.rs @@ -26,7 +26,7 @@ pub fn analyze_expr( ast::Expr::Str(v) => Value::Str(v.get().into()), _ => { if node.kind() == SyntaxKind::Contextual { - if let Some(child) = node.children().last() { + if let Some(child) = node.children().next_back() { return analyze_expr(world, &child); } } diff --git a/crates/typst-layout/src/flow/compose.rs b/crates/typst-layout/src/flow/compose.rs index 76af8f650b..54dc487a38 100644 --- a/crates/typst-layout/src/flow/compose.rs +++ b/crates/typst-layout/src/flow/compose.rs @@ -115,7 +115,7 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { let column_height = regions.size.y; let backlog: Vec<_> = std::iter::once(&column_height) .chain(regions.backlog) - .flat_map(|&h| std::iter::repeat(h).take(self.config.columns.count)) + .flat_map(|&h| std::iter::repeat_n(h, self.config.columns.count)) .skip(1) .collect(); diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index af47ff72f9..dc9e2238d4 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -1469,7 +1469,7 @@ impl<'a> GridLayouter<'a> { // last height is the one for the current region. rowspan .heights - .extend(std::iter::repeat(Abs::zero()).take(amount_missing_heights)); + .extend(std::iter::repeat_n(Abs::zero(), amount_missing_heights)); // Ensure that, in this region, the rowspan will span at least // this row. diff --git a/crates/typst-layout/src/math/stretch.rs b/crates/typst-layout/src/math/stretch.rs index dafa8cbe8f..f45035e272 100644 --- a/crates/typst-layout/src/math/stretch.rs +++ b/crates/typst-layout/src/math/stretch.rs @@ -302,6 +302,6 @@ fn assemble( fn parts(assembly: GlyphAssembly, repeat: usize) -> impl Iterator + '_ { assembly.parts.into_iter().flat_map(move |part| { let count = if part.part_flags.extender() { repeat } else { 1 }; - std::iter::repeat(part).take(count) + std::iter::repeat_n(part, count) }) } diff --git a/crates/typst-library/src/foundations/cast.rs b/crates/typst-library/src/foundations/cast.rs index 38f409c670..73645491fa 100644 --- a/crates/typst-library/src/foundations/cast.rs +++ b/crates/typst-library/src/foundations/cast.rs @@ -21,7 +21,7 @@ use crate::foundations::{ /// /// Type casting works as follows: /// - [`Reflect for T`](Reflect) describes the possible Typst values for `T` -/// (for documentation and autocomplete). +/// (for documentation and autocomplete). /// - [`IntoValue for T`](IntoValue) is for conversion from `T -> Value` /// (infallible) /// - [`FromValue for T`](FromValue) is for conversion from `Value -> T` diff --git a/crates/typst-library/src/model/numbering.rs b/crates/typst-library/src/model/numbering.rs index 1505067581..ada8a3965c 100644 --- a/crates/typst-library/src/model/numbering.rs +++ b/crates/typst-library/src/model/numbering.rs @@ -394,7 +394,7 @@ impl NumberingKind { const SYMBOLS: &[char] = &['*', '†', '‡', '§', '¶', '‖']; let symbol = SYMBOLS[(n - 1) % SYMBOLS.len()]; let amount = ((n - 1) / SYMBOLS.len()) + 1; - std::iter::repeat(symbol).take(amount).collect() + std::iter::repeat_n(symbol, amount).collect() } Self::Hebrew => hebrew_numeral(n), diff --git a/crates/typst-library/src/visualize/gradient.rs b/crates/typst-library/src/visualize/gradient.rs index 1a723a9f5f..d59175a4ee 100644 --- a/crates/typst-library/src/visualize/gradient.rs +++ b/crates/typst-library/src/visualize/gradient.rs @@ -574,8 +574,7 @@ impl Gradient { } let n = repetitions.v; - let mut stops = std::iter::repeat(self.stops_ref()) - .take(n) + let mut stops = std::iter::repeat_n(self.stops_ref(), n) .enumerate() .flat_map(|(i, stops)| { let mut stops = stops diff --git a/crates/typst-render/src/shape.rs b/crates/typst-render/src/shape.rs index ba7ed6d897..9b50d5f1f4 100644 --- a/crates/typst-render/src/shape.rs +++ b/crates/typst-render/src/shape.rs @@ -69,9 +69,11 @@ pub fn render_shape(canvas: &mut sk::Pixmap, state: State, shape: &Shape) -> Opt let dash = dash.as_ref().and_then(to_sk_dash_pattern); let bbox = shape.geometry.bbox_size(); - let offset_bbox = (!matches!(shape.geometry, Geometry::Line(..))) - .then(|| offset_bounding_box(bbox, *thickness)) - .unwrap_or(bbox); + let offset_bbox = if !matches!(shape.geometry, Geometry::Line(..)) { + offset_bounding_box(bbox, *thickness) + } else { + bbox + }; let fill_transform = (!matches!(shape.geometry, Geometry::Line(..))).then(|| { From 96f695737174449cbd9efbf4954b676b9bb35056 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 11 Mar 2025 10:18:15 +0000 Subject: [PATCH 055/558] Fix `math.root` frame size (#6021) --- crates/typst-layout/src/math/root.rs | 8 +++++--- tests/ref/math-root-frame-size-index.png | Bin 0 -> 902 bytes tests/suite/math/root.typ | 6 ++++++ 3 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 tests/ref/math-root-frame-size-index.png diff --git a/crates/typst-layout/src/math/root.rs b/crates/typst-layout/src/math/root.rs index a6b5c03d0c..c7f41488ef 100644 --- a/crates/typst-layout/src/math/root.rs +++ b/crates/typst-layout/src/math/root.rs @@ -85,14 +85,15 @@ pub fn layout_root( ascent.set_max(shift_up + index.ascent()); } - let radicand_x = sqrt_offset + sqrt.width(); + let sqrt_x = sqrt_offset.max(Abs::zero()); + let radicand_x = sqrt_x + sqrt.width(); let radicand_y = ascent - radicand.ascent(); let width = radicand_x + radicand.width(); let size = Size::new(width, ascent + descent); // The extra "- thickness" comes from the fact that the sqrt is placed // in `push_frame` with respect to its top, not its baseline. - let sqrt_pos = Point::new(sqrt_offset, radicand_y - gap - thickness); + let sqrt_pos = Point::new(sqrt_x, radicand_y - gap - thickness); let line_pos = Point::new(radicand_x, radicand_y - gap - (thickness / 2.0)); let radicand_pos = Point::new(radicand_x, radicand_y); @@ -100,7 +101,8 @@ pub fn layout_root( frame.set_baseline(ascent); if let Some(index) = index { - let index_pos = Point::new(kern_before, ascent - index.ascent() - shift_up); + let index_x = -sqrt_offset.min(Abs::zero()) + kern_before; + let index_pos = Point::new(index_x, ascent - index.ascent() - shift_up); frame.push_frame(index_pos, index); } diff --git a/tests/ref/math-root-frame-size-index.png b/tests/ref/math-root-frame-size-index.png new file mode 100644 index 0000000000000000000000000000000000000000..41d4df2e9ea40429c2bc33bccec9afeb927374d9 GIT binary patch literal 902 zcmV;119|+3P)1i8`-j-*}=qYVapPY5}A+<9-Iye>Z(xM zl~(B}0~RTzPEs)|upmOI7*X&?#8TVRhm;nvwDxJ=wudKah$qu8UY_^u`?4HSYGnS{;D#1a#Z13Z08!n*r88 zzas*c%d^rT<0UE1JOsQ4dode$m!(q&;Aa3^<$?&f*+4}cYR##7;Cd##Ew+7a`3q3q zj5{YFd5g}kMZmv%;$_3I^tSmvu*09)y0jaMei%+R)&L`)V)qa>e1fc z2Z!?`SuiZcDeUolI|Cz#zv{>_#s+nI)O@E#*-!AVuF8y9xPF=HO9c;s;l5ed5Cal# zJQM-XSS{1c+2N4{WZU#$vzgMwTYy6V(_bL~j+7(0m@G-64*|Zzc8n(8gW26auJ!|q zjdfW8u&$koRrtk}k^rxg-nLTuSpE{Y{0+6h-6$Up34j|tu`)MAwi_3KcNUXdBOhTt z?FM%K+YRhf{N`?fyIRXy5#7cX6OH$!!PEDMC|S1D}D$A#-5Jy-q_ zeAVIOc$~Tus7*}XKYL?T;y`w#qi1hI9Qy({-ZS8ZI`&n|DVVXxIZ z#cU_6HUNsg4Xn1-Gyn++s8)cR6%-i`??Jl2bjdn@gn^kO=VV3^sPiheeB%&fcht}5a3%&k>Ms!v}_2LOAUTtXCV2KT#oUK z`sUa(U;t_?d}b;#EZbcz<3Qz)0OO7-Cm!eENp(AWUQGZ|`{~+@pszX@ Date: Tue, 11 Mar 2025 23:20:41 +0300 Subject: [PATCH 056/558] Fix parallel package installation (#5979) Co-authored-by: Laurenz --- Cargo.lock | 1 + Cargo.toml | 1 + crates/typst-kit/Cargo.toml | 3 +- crates/typst-kit/src/package.rs | 84 ++++++++++++++++++++++++++++++--- 4 files changed, 82 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ac08b57ee8..06dd4ab805 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2896,6 +2896,7 @@ dependencies = [ "dirs", "ecow", "env_proxy", + "fastrand", "flate2", "fontdb", "native-tls", diff --git a/Cargo.toml b/Cargo.toml index 40abaaca7a..4e0d3a26c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ ctrlc = "3.4.1" dirs = "6" ecow = { version = "0.2", features = ["serde"] } env_proxy = "0.4" +fastrand = "2.3" flate2 = "1" fontdb = { version = "0.23", default-features = false } fs_extra = "1.3" diff --git a/crates/typst-kit/Cargo.toml b/crates/typst-kit/Cargo.toml index 52aa407c32..e59127d71d 100644 --- a/crates/typst-kit/Cargo.toml +++ b/crates/typst-kit/Cargo.toml @@ -19,6 +19,7 @@ typst-utils = { workspace = true } dirs = { workspace = true, optional = true } ecow = { workspace = true } env_proxy = { workspace = true, optional = true } +fastrand = { workspace = true, optional = true } flate2 = { workspace = true, optional = true } fontdb = { workspace = true, optional = true } native-tls = { workspace = true, optional = true } @@ -43,7 +44,7 @@ fonts = ["dep:fontdb", "fontdb/memmap", "fontdb/fontconfig"] downloads = ["dep:env_proxy", "dep:native-tls", "dep:ureq", "dep:openssl"] # Add package downloading utilities, implies `downloads` -packages = ["downloads", "dep:dirs", "dep:flate2", "dep:tar"] +packages = ["downloads", "dep:dirs", "dep:flate2", "dep:tar", "dep:fastrand"] # Embeds some fonts into the binary: # - For text: Libertinus Serif, New Computer Modern diff --git a/crates/typst-kit/src/package.rs b/crates/typst-kit/src/package.rs index 172d8740af..1a1abd607c 100644 --- a/crates/typst-kit/src/package.rs +++ b/crates/typst-kit/src/package.rs @@ -1,6 +1,7 @@ //! Download and unpack packages and package indices. use std::fs; +use std::io; use std::path::{Path, PathBuf}; use ecow::eco_format; @@ -100,7 +101,7 @@ impl PackageStorage { // Download from network if it doesn't exist yet. if spec.namespace == DEFAULT_NAMESPACE { - self.download_package(spec, &dir, progress)?; + self.download_package(spec, cache_dir, progress)?; if dir.exists() { return Ok(dir); } @@ -167,7 +168,7 @@ impl PackageStorage { pub fn download_package( &self, spec: &PackageSpec, - package_dir: &Path, + cache_dir: &Path, progress: &mut dyn Progress, ) -> PackageResult<()> { assert_eq!(spec.namespace, DEFAULT_NAMESPACE); @@ -191,11 +192,52 @@ impl PackageStorage { } }; + // The directory in which the package's version lives. + let base_dir = cache_dir.join(format!("{}/{}", spec.namespace, spec.name)); + + // The place at which the specific package version will live in the end. + let package_dir = base_dir.join(format!("{}", spec.version)); + + // To prevent multiple Typst instances from interferring, we download + // into a temporary directory first and then move this directory to + // its final destination. + // + // In the `rename` function's documentation it is stated: + // > This will not work if the new name is on a different mount point. + // + // By locating the temporary directory directly next to where the + // package directory will live, we are (trying our best) making sure + // that `tempdir` and `package_dir` are on the same mount point. + let tempdir = Tempdir::create(base_dir.join(format!( + ".tmp-{}-{}", + spec.version, + fastrand::u32(..), + ))) + .map_err(|err| error("failed to create temporary package directory", err))?; + + // Decompress the archive into the temporary directory. let decompressed = flate2::read::GzDecoder::new(data.as_slice()); - tar::Archive::new(decompressed).unpack(package_dir).map_err(|err| { - fs::remove_dir_all(package_dir).ok(); - PackageError::MalformedArchive(Some(eco_format!("{err}"))) - }) + tar::Archive::new(decompressed) + .unpack(&tempdir) + .map_err(|err| PackageError::MalformedArchive(Some(eco_format!("{err}"))))?; + + // When trying to move (i.e., `rename`) the directory from one place to + // another and the target/destination directory is empty, then the + // operation will succeed (if it's atomic, or hardware doesn't fail, or + // power doesn't go off, etc.). If however the target directory is not + // empty, i.e., another instance already successfully moved the package, + // then we can safely ignore the `DirectoryNotEmpty` error. + // + // This means that we do not check the integrity of an existing moved + // package, just like we don't check the integrity if the package + // directory already existed in the first place. If situations with + // broken packages still occur even with the rename safeguard, we might + // consider more complex solutions like file locking or checksums. + match fs::rename(&tempdir, &package_dir) { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::DirectoryNotEmpty => Ok(()), + Err(err) => Err(error("failed to move downloaded package directory", err)), + } } } @@ -207,6 +249,36 @@ struct MinimalPackageInfo { version: PackageVersion, } +/// A temporary directory that is a automatically cleaned up. +struct Tempdir(PathBuf); + +impl Tempdir { + /// Creates a directory at the path and auto-cleans it. + fn create(path: PathBuf) -> io::Result { + std::fs::create_dir_all(&path)?; + Ok(Self(path)) + } +} + +impl Drop for Tempdir { + fn drop(&mut self) { + _ = fs::remove_dir_all(&self.0); + } +} + +impl AsRef for Tempdir { + fn as_ref(&self) -> &Path { + &self.0 + } +} + +/// Enriches an I/O error with a message and turns it into a +/// `PackageError::Other`. +#[cold] +fn error(message: &str, err: io::Error) -> PackageError { + PackageError::Other(Some(eco_format!("{message}: {err}"))) +} + #[cfg(test)] mod tests { use super::*; From 24b2f98bf9b8d7a8d8ad9f5f0585ab4317cd3666 Mon Sep 17 00:00:00 2001 From: Michael Fortunato Date: Wed, 12 Mar 2025 07:45:22 -0500 Subject: [PATCH 057/558] Fix typo in 4-template.md (#6047) --- docs/tutorial/4-template.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial/4-template.md b/docs/tutorial/4-template.md index 209fa55469..7542cd6e48 100644 --- a/docs/tutorial/4-template.md +++ b/docs/tutorial/4-template.md @@ -44,7 +44,7 @@ I am #amazed(color: purple)[amazed]! Templates now work by wrapping our whole document in a custom function like `amazed`. But wrapping a whole document in a giant function call would be cumbersome! Instead, we can use an "everything" show rule to achieve the same -with cleaner code. To write such a show rule, put a colon directly behind the +with cleaner code. To write such a show rule, put a colon directly after the show keyword and then provide a function. This function is given the rest of the document as a parameter. The function can then do anything with this content. Since the `amazed` function can be called with a single content argument, we can From 37bb632d2e9f1f779e15dd5c21ff1ceeadea4a17 Mon Sep 17 00:00:00 2001 From: "Kevin K." Date: Wed, 12 Mar 2025 13:45:57 +0100 Subject: [PATCH 058/558] Fix missing words and paren in docs (#6046) --- crates/typst-library/src/model/bibliography.rs | 2 +- crates/typst-library/src/model/outline.rs | 2 +- crates/typst-library/src/text/raw.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/typst-library/src/model/bibliography.rs b/crates/typst-library/src/model/bibliography.rs index a391e58040..b11c617897 100644 --- a/crates/typst-library/src/model/bibliography.rs +++ b/crates/typst-library/src/model/bibliography.rs @@ -94,7 +94,7 @@ pub struct BibliographyElem { /// - A path string to load a bibliography file from the given path. For /// more details about paths, see the [Paths section]($syntax/#paths). /// - Raw bytes from which the bibliography should be decoded. - /// - An array where each item is one the above. + /// - An array where each item is one of the above. #[required] #[parse( let sources = args.expect("sources")?; diff --git a/crates/typst-library/src/model/outline.rs b/crates/typst-library/src/model/outline.rs index 7ceb530f83..489c375e67 100644 --- a/crates/typst-library/src/model/outline.rs +++ b/crates/typst-library/src/model/outline.rs @@ -388,7 +388,7 @@ pub struct OutlineEntry { /// space between the entry's body and the page number. When using show /// rules to override outline entries, it is thus recommended to wrap the /// fill in a [`box`] with fractional width, i.e. - /// `{box(width: 1fr, it.fill}`. + /// `{box(width: 1fr, it.fill)}`. /// /// When using [`repeat`], the [`gap`]($repeat.gap) property can be useful /// to tweak the visual weight of the fill. diff --git a/crates/typst-library/src/text/raw.rs b/crates/typst-library/src/text/raw.rs index 1ce8bfc611..d5c07424d4 100644 --- a/crates/typst-library/src/text/raw.rs +++ b/crates/typst-library/src/text/raw.rs @@ -188,7 +188,7 @@ pub struct RawElem { /// - A path string to load a syntax file from the given path. For more /// details about paths, see the [Paths section]($syntax/#paths). /// - Raw bytes from which the syntax should be decoded. - /// - An array where each item is one the above. + /// - An array where each item is one of the above. /// /// ````example /// #set raw(syntaxes: "SExpressions.sublime-syntax") From 95a7e28e25be8374f8574244cc46cf42e97b937e Mon Sep 17 00:00:00 2001 From: Laurenz Date: Wed, 12 Mar 2025 13:46:03 +0100 Subject: [PATCH 059/558] Make two typst-kit functions private (#6045) --- crates/typst-kit/src/package.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/typst-kit/src/package.rs b/crates/typst-kit/src/package.rs index 1a1abd607c..584ec83c0e 100644 --- a/crates/typst-kit/src/package.rs +++ b/crates/typst-kit/src/package.rs @@ -78,7 +78,8 @@ impl PackageStorage { self.package_path.as_deref() } - /// Make a package available in the on-disk. + /// Makes a package available on-disk and returns the path at which it is + /// located (will be either in the cache or package directory). pub fn prepare_package( &self, spec: &PackageSpec, @@ -111,7 +112,7 @@ impl PackageStorage { Err(PackageError::NotFound(spec.clone())) } - /// Try to determine the latest version of a package. + /// Tries to determine the latest version of a package. pub fn determine_latest_version( &self, spec: &VersionlessPackageSpec, @@ -144,7 +145,7 @@ impl PackageStorage { } /// Download the package index. The result of this is cached for efficiency. - pub fn download_index(&self) -> StrResult<&[serde_json::Value]> { + fn download_index(&self) -> StrResult<&[serde_json::Value]> { self.index .get_or_try_init(|| { let url = format!("{DEFAULT_REGISTRY}/{DEFAULT_NAMESPACE}/index.json"); @@ -165,7 +166,7 @@ impl PackageStorage { /// /// # Panics /// Panics if the package spec namespace isn't `DEFAULT_NAMESPACE`. - pub fn download_package( + fn download_package( &self, spec: &PackageSpec, cache_dir: &Path, From 1b2714e1a758d6ee0f9471fd1e49cb02f6d8cde4 Mon Sep 17 00:00:00 2001 From: Wolf-SO Date: Wed, 12 Mar 2025 19:29:35 +0100 Subject: [PATCH 060/558] Update 1-writing.md to improve readability (#6040) --- docs/tutorial/1-writing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial/1-writing.md b/docs/tutorial/1-writing.md index 5a9fdd4f79..acc257830f 100644 --- a/docs/tutorial/1-writing.md +++ b/docs/tutorial/1-writing.md @@ -172,7 +172,7 @@ nothing else. For example, the image function expects a path to an image file. It would not make sense to pass, e.g., a paragraph of text or another image as the image's path parameter. That's why only strings are allowed here. -On the contrary, strings work wherever content is expected because text is a +In contrast, strings work wherever content is expected because text is a valid kind of content. From 91956d1f035b79d1e84318b62cce24659bb3d14d Mon Sep 17 00:00:00 2001 From: Ian Wrzesinski <133046678+wrzian@users.noreply.github.com> Date: Mon, 24 Mar 2025 14:07:19 -0400 Subject: [PATCH 061/558] Use `std::ops::ControlFlow` in `Content::traverse` (#6053) Co-authored-by: Max Mynter --- .../typst-library/src/foundations/content.rs | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/crates/typst-library/src/foundations/content.rs b/crates/typst-library/src/foundations/content.rs index 76cd6a2225..daf6c2dd9a 100644 --- a/crates/typst-library/src/foundations/content.rs +++ b/crates/typst-library/src/foundations/content.rs @@ -3,7 +3,7 @@ use std::fmt::{self, Debug, Formatter}; use std::hash::{Hash, Hasher}; use std::iter::{self, Sum}; use std::marker::PhantomData; -use std::ops::{Add, AddAssign, Deref, DerefMut}; +use std::ops::{Add, AddAssign, ControlFlow, Deref, DerefMut}; use std::sync::Arc; use comemo::Tracked; @@ -414,10 +414,11 @@ impl Content { /// Elements produced in `show` rules will not be included in the results. pub fn query(&self, selector: Selector) -> Vec { let mut results = Vec::new(); - self.traverse(&mut |element| { + self.traverse(&mut |element| -> ControlFlow<()> { if selector.matches(&element, None) { results.push(element); } + ControlFlow::Continue(()) }); results } @@ -427,54 +428,58 @@ impl Content { /// /// Elements produced in `show` rules will not be included in the results. pub fn query_first(&self, selector: &Selector) -> Option { - let mut result = None; - self.traverse(&mut |element| { - if result.is_none() && selector.matches(&element, None) { - result = Some(element); + self.traverse(&mut |element| -> ControlFlow { + if selector.matches(&element, None) { + ControlFlow::Break(element) + } else { + ControlFlow::Continue(()) } - }); - result + }) + .break_value() } /// Extracts the plain text of this content. pub fn plain_text(&self) -> EcoString { let mut text = EcoString::new(); - self.traverse(&mut |element| { + self.traverse(&mut |element| -> ControlFlow<()> { if let Some(textable) = element.with::() { textable.plain_text(&mut text); } + ControlFlow::Continue(()) }); text } /// Traverse this content. - fn traverse(&self, f: &mut F) + fn traverse(&self, f: &mut F) -> ControlFlow where - F: FnMut(Content), + F: FnMut(Content) -> ControlFlow, { - f(self.clone()); - - self.inner - .elem - .fields() - .into_iter() - .for_each(|(_, value)| walk_value(value, f)); - /// Walks a given value to find any content that matches the selector. - fn walk_value(value: Value, f: &mut F) + /// + /// Returns early if the function gives `ControlFlow::Break`. + fn walk_value(value: Value, f: &mut F) -> ControlFlow where - F: FnMut(Content), + F: FnMut(Content) -> ControlFlow, { match value { Value::Content(content) => content.traverse(f), Value::Array(array) => { for value in array { - walk_value(value, f); + walk_value(value, f)?; } + ControlFlow::Continue(()) } - _ => {} + _ => ControlFlow::Continue(()), } } + + // Call f on the element itself before recursively iterating its fields. + f(self.clone())?; + for (_, value) in self.inner.elem.fields() { + walk_value(value, f)?; + } + ControlFlow::Continue(()) } } From 636eea18bc1c3fe2acb09e59e67f38a4a0c1b323 Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Mon, 24 Mar 2025 21:08:39 +0300 Subject: [PATCH 062/558] Expand page breaks' triggers for page(height: auto) in docs (#6081) --- crates/typst-library/src/layout/page.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/typst-library/src/layout/page.rs b/crates/typst-library/src/layout/page.rs index 0964dccd2e..af6ad642d6 100644 --- a/crates/typst-library/src/layout/page.rs +++ b/crates/typst-library/src/layout/page.rs @@ -75,9 +75,10 @@ pub struct PageElem { /// The height of the page. /// /// If this is set to `{auto}`, page breaks can only be triggered manually - /// by inserting a [page break]($pagebreak). Most examples throughout this - /// documentation use `{auto}` for the height of the page to dynamically - /// grow and shrink to fit their content. + /// by inserting a [page break]($pagebreak) or by adding another non-empty + /// page set rule. Most examples throughout this documentation use `{auto}` + /// for the height of the page to dynamically grow and shrink to fit their + /// content. #[resolve] #[parse( args.named("height")? From 38213ed534d8a7cd520c0265b99a345bc2966b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20S=C3=A1nchez=20Mu=C3=B1oz?= Date: Mon, 24 Mar 2025 19:16:33 +0100 Subject: [PATCH 063/558] Use `u64` instead of `usize` to store counter and enumeration item numbers, so behavior does not vary from 64-bit to 32-bit platforms (#6026) --- crates/typst-layout/src/lists.rs | 10 ++-- .../src/introspection/counter.rs | 24 ++++----- crates/typst-library/src/layout/page.rs | 2 +- crates/typst-library/src/model/enum.rs | 6 +-- crates/typst-library/src/model/numbering.rs | 49 ++++++++---------- crates/typst-pdf/src/page.rs | 14 ++--- crates/typst-syntax/src/ast.rs | 2 +- crates/typst-syntax/src/lexer.rs | 2 +- tests/ref/enum-numbering-huge.png | Bin 0 -> 900 bytes tests/ref/page-numbering-huge.png | Bin 0 -> 913 bytes tests/src/world.rs | 2 +- tests/suite/introspection/counter.typ | 10 ++++ tests/suite/layout/page.typ | 10 ++++ tests/suite/model/enum.typ | 5 ++ tests/suite/model/numbering.typ | 1 + 15 files changed, 82 insertions(+), 55 deletions(-) create mode 100644 tests/ref/enum-numbering-huge.png create mode 100644 tests/ref/page-numbering-huge.png diff --git a/crates/typst-layout/src/lists.rs b/crates/typst-layout/src/lists.rs index f8d910abfe..974788a703 100644 --- a/crates/typst-layout/src/lists.rs +++ b/crates/typst-layout/src/lists.rs @@ -96,9 +96,13 @@ pub fn layout_enum( let mut cells = vec![]; let mut locator = locator.split(); - let mut number = - elem.start(styles) - .unwrap_or_else(|| if reversed { elem.children.len() } else { 1 }); + let mut number = elem.start(styles).unwrap_or_else(|| { + if reversed { + elem.children.len() as u64 + } else { + 1 + } + }); let mut parents = EnumElem::parents_in(styles); let full = elem.full(styles); diff --git a/crates/typst-library/src/introspection/counter.rs b/crates/typst-library/src/introspection/counter.rs index 5432df238d..772bea963d 100644 --- a/crates/typst-library/src/introspection/counter.rs +++ b/crates/typst-library/src/introspection/counter.rs @@ -229,10 +229,10 @@ impl Counter { if self.is_page() { let at_delta = engine.introspector.page(location).get().saturating_sub(at_page.get()); - at_state.step(NonZeroUsize::ONE, at_delta); + at_state.step(NonZeroUsize::ONE, at_delta as u64); let final_delta = engine.introspector.pages().get().saturating_sub(final_page.get()); - final_state.step(NonZeroUsize::ONE, final_delta); + final_state.step(NonZeroUsize::ONE, final_delta as u64); } Ok(CounterState(smallvec![at_state.first(), final_state.first()])) } @@ -250,7 +250,7 @@ impl Counter { if self.is_page() { let delta = engine.introspector.page(location).get().saturating_sub(page.get()); - state.step(NonZeroUsize::ONE, delta); + state.step(NonZeroUsize::ONE, delta as u64); } Ok(state) } @@ -319,7 +319,7 @@ impl Counter { let delta = page.get() - prev.get(); if delta > 0 { - state.step(NonZeroUsize::ONE, delta); + state.step(NonZeroUsize::ONE, delta as u64); } } @@ -500,7 +500,7 @@ impl Counter { let (mut state, page) = sequence.last().unwrap().clone(); if self.is_page() { let delta = engine.introspector.pages().get().saturating_sub(page.get()); - state.step(NonZeroUsize::ONE, delta); + state.step(NonZeroUsize::ONE, delta as u64); } Ok(state) } @@ -616,13 +616,13 @@ pub trait Count { /// Counts through elements with different levels. #[derive(Debug, Clone, PartialEq, Hash)] -pub struct CounterState(pub SmallVec<[usize; 3]>); +pub struct CounterState(pub SmallVec<[u64; 3]>); impl CounterState { /// Get the initial counter state for the key. pub fn init(page: bool) -> Self { // Special case, because pages always start at one. - Self(smallvec![usize::from(page)]) + Self(smallvec![u64::from(page)]) } /// Advance the counter and return the numbers for the given heading. @@ -645,7 +645,7 @@ impl CounterState { } /// Advance the number of the given level by the specified amount. - pub fn step(&mut self, level: NonZeroUsize, by: usize) { + pub fn step(&mut self, level: NonZeroUsize, by: u64) { let level = level.get(); while self.0.len() < level { @@ -657,7 +657,7 @@ impl CounterState { } /// Get the first number of the state. - pub fn first(&self) -> usize { + pub fn first(&self) -> u64 { self.0.first().copied().unwrap_or(1) } @@ -675,7 +675,7 @@ impl CounterState { cast! { CounterState, self => Value::Array(self.0.into_iter().map(IntoValue::into_value).collect()), - num: usize => Self(smallvec![num]), + num: u64 => Self(smallvec![num]), array: Array => Self(array .into_iter() .map(Value::cast) @@ -758,7 +758,7 @@ impl Show for Packed { #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub struct ManualPageCounter { physical: NonZeroUsize, - logical: usize, + logical: u64, } impl ManualPageCounter { @@ -773,7 +773,7 @@ impl ManualPageCounter { } /// Get the current logical page counter state. - pub fn logical(&self) -> usize { + pub fn logical(&self) -> u64 { self.logical } diff --git a/crates/typst-library/src/layout/page.rs b/crates/typst-library/src/layout/page.rs index af6ad642d6..62e25278a6 100644 --- a/crates/typst-library/src/layout/page.rs +++ b/crates/typst-library/src/layout/page.rs @@ -484,7 +484,7 @@ pub struct Page { pub supplement: Content, /// The logical page number (controlled by `counter(page)` and may thus not /// match the physical number). - pub number: usize, + pub number: u64, } impl Page { diff --git a/crates/typst-library/src/model/enum.rs b/crates/typst-library/src/model/enum.rs index a4126e72c8..2d95996ab4 100644 --- a/crates/typst-library/src/model/enum.rs +++ b/crates/typst-library/src/model/enum.rs @@ -129,7 +129,7 @@ pub struct EnumElem { /// [Ahead], /// ) /// ``` - pub start: Smart, + pub start: Smart, /// Whether to display the full numbering, including the numbers of /// all parent enumerations. @@ -217,7 +217,7 @@ pub struct EnumElem { #[internal] #[fold] #[ghost] - pub parents: SmallVec<[usize; 4]>, + pub parents: SmallVec<[u64; 4]>, } #[scope] @@ -274,7 +274,7 @@ impl Show for Packed { pub struct EnumItem { /// The item's number. #[positional] - pub number: Option, + pub number: Option, /// The item's body. #[required] diff --git a/crates/typst-library/src/model/numbering.rs b/crates/typst-library/src/model/numbering.rs index ada8a3965c..d82c3e4cdb 100644 --- a/crates/typst-library/src/model/numbering.rs +++ b/crates/typst-library/src/model/numbering.rs @@ -1,7 +1,7 @@ use std::str::FromStr; use chinese_number::{ - from_usize_to_chinese_ten_thousand as usize_to_chinese, ChineseCase, ChineseVariant, + from_u64_to_chinese_ten_thousand as u64_to_chinese, ChineseCase, ChineseVariant, }; use comemo::Tracked; use ecow::{eco_format, EcoString, EcoVec}; @@ -85,7 +85,7 @@ pub fn numbering( /// If `numbering` is a pattern and more numbers than counting symbols are /// given, the last counting symbol with its prefix is repeated. #[variadic] - numbers: Vec, + numbers: Vec, ) -> SourceResult { numbering.apply(engine, context, &numbers) } @@ -105,7 +105,7 @@ impl Numbering { &self, engine: &mut Engine, context: Tracked, - numbers: &[usize], + numbers: &[u64], ) -> SourceResult { Ok(match self { Self::Pattern(pattern) => Value::Str(pattern.apply(numbers).into()), @@ -156,7 +156,7 @@ pub struct NumberingPattern { impl NumberingPattern { /// Apply the pattern to the given number. - pub fn apply(&self, numbers: &[usize]) -> EcoString { + pub fn apply(&self, numbers: &[u64]) -> EcoString { let mut fmt = EcoString::new(); let mut numbers = numbers.iter(); @@ -185,7 +185,7 @@ impl NumberingPattern { } /// Apply only the k-th segment of the pattern to a number. - pub fn apply_kth(&self, k: usize, number: usize) -> EcoString { + pub fn apply_kth(&self, k: usize, number: u64) -> EcoString { let mut fmt = EcoString::new(); if let Some((prefix, _)) = self.pieces.first() { fmt.push_str(prefix); @@ -379,7 +379,7 @@ impl NumberingKind { } /// Apply the numbering to the given number. - pub fn apply(self, n: usize) -> EcoString { + pub fn apply(self, n: u64) -> EcoString { match self { Self::Arabic => eco_format!("{n}"), Self::LowerRoman => roman_numeral(n, Case::Lower), @@ -392,9 +392,10 @@ impl NumberingKind { } const SYMBOLS: &[char] = &['*', '†', '‡', '§', '¶', '‖']; - let symbol = SYMBOLS[(n - 1) % SYMBOLS.len()]; - let amount = ((n - 1) / SYMBOLS.len()) + 1; - std::iter::repeat_n(symbol, amount).collect() + let n_symbols = SYMBOLS.len() as u64; + let symbol = SYMBOLS[((n - 1) % n_symbols) as usize]; + let amount = ((n - 1) / n_symbols) + 1; + std::iter::repeat_n(symbol, amount.try_into().unwrap()).collect() } Self::Hebrew => hebrew_numeral(n), @@ -489,18 +490,16 @@ impl NumberingKind { } Self::LowerSimplifiedChinese => { - usize_to_chinese(ChineseVariant::Simple, ChineseCase::Lower, n).into() + u64_to_chinese(ChineseVariant::Simple, ChineseCase::Lower, n).into() } Self::UpperSimplifiedChinese => { - usize_to_chinese(ChineseVariant::Simple, ChineseCase::Upper, n).into() + u64_to_chinese(ChineseVariant::Simple, ChineseCase::Upper, n).into() } Self::LowerTraditionalChinese => { - usize_to_chinese(ChineseVariant::Traditional, ChineseCase::Lower, n) - .into() + u64_to_chinese(ChineseVariant::Traditional, ChineseCase::Lower, n).into() } Self::UpperTraditionalChinese => { - usize_to_chinese(ChineseVariant::Traditional, ChineseCase::Upper, n) - .into() + u64_to_chinese(ChineseVariant::Traditional, ChineseCase::Upper, n).into() } Self::EasternArabic => decimal('\u{0660}', n), @@ -512,7 +511,7 @@ impl NumberingKind { } /// Stringify an integer to a Hebrew number. -fn hebrew_numeral(mut n: usize) -> EcoString { +fn hebrew_numeral(mut n: u64) -> EcoString { if n == 0 { return '-'.into(); } @@ -566,7 +565,7 @@ fn hebrew_numeral(mut n: usize) -> EcoString { } /// Stringify an integer to a Roman numeral. -fn roman_numeral(mut n: usize, case: Case) -> EcoString { +fn roman_numeral(mut n: u64, case: Case) -> EcoString { if n == 0 { return match case { Case::Lower => 'n'.into(), @@ -622,7 +621,7 @@ fn roman_numeral(mut n: usize, case: Case) -> EcoString { /// /// [converter]: https://www.russellcottrell.com/greek/utilities/GreekNumberConverter.htm /// [numbers]: https://mathshistory.st-andrews.ac.uk/HistTopics/Greek_numbers/ -fn greek_numeral(n: usize, case: Case) -> EcoString { +fn greek_numeral(n: u64, case: Case) -> EcoString { let thousands = [ ["͵α", "͵Α"], ["͵β", "͵Β"], @@ -683,7 +682,7 @@ fn greek_numeral(n: usize, case: Case) -> EcoString { let mut decimal_digits: Vec = Vec::new(); let mut n = n; while n > 0 { - decimal_digits.push(n % 10); + decimal_digits.push((n % 10) as usize); n /= 10; } @@ -778,18 +777,16 @@ fn greek_numeral(n: usize, case: Case) -> EcoString { /// /// You might be familiar with this scheme from the way spreadsheet software /// tends to label its columns. -fn zeroless( - alphabet: [char; N_DIGITS], - mut n: usize, -) -> EcoString { +fn zeroless(alphabet: [char; N_DIGITS], mut n: u64) -> EcoString { if n == 0 { return '-'.into(); } + let n_digits = N_DIGITS as u64; let mut cs = EcoString::new(); while n > 0 { n -= 1; - cs.push(alphabet[n % N_DIGITS]); - n /= N_DIGITS; + cs.push(alphabet[(n % n_digits) as usize]); + n /= n_digits; } cs.chars().rev().collect() } @@ -797,7 +794,7 @@ fn zeroless( /// Stringify a number using a base-10 counting system with a zero digit. /// /// This function assumes that the digits occupy contiguous codepoints. -fn decimal(start: char, mut n: usize) -> EcoString { +fn decimal(start: char, mut n: u64) -> EcoString { if n == 0 { return start.into(); } diff --git a/crates/typst-pdf/src/page.rs b/crates/typst-pdf/src/page.rs index 4e95f3c70f..68125d29ab 100644 --- a/crates/typst-pdf/src/page.rs +++ b/crates/typst-pdf/src/page.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::num::NonZeroUsize; +use std::num::NonZeroU64; use ecow::EcoString; use pdf_writer::types::{ActionType, AnnotationFlags, AnnotationType, NumberingStyle}; @@ -48,7 +48,7 @@ pub fn traverse_pages( // the real (not logical) page numbers. Here, the final PDF page number // will differ, but we can at least use labels to indicate what was // the corresponding real page number in the Typst document. - (skipped_pages > 0).then(|| PdfPageLabel::arabic(i + 1)) + (skipped_pages > 0).then(|| PdfPageLabel::arabic((i + 1) as u64)) }); pages.push(Some(encoded)); } @@ -219,7 +219,7 @@ pub(crate) struct PdfPageLabel { /// /// Describes where to start counting from when setting a style. /// (Has to be greater or equal than 1) - pub offset: Option, + pub offset: Option, } /// A PDF page label number style. @@ -242,7 +242,7 @@ pub enum PdfPageLabelStyle { impl PdfPageLabel { /// Create a new `PdfNumbering` from a `Numbering` applied to a page /// number. - fn generate(numbering: &Numbering, number: usize) -> Option { + fn generate(numbering: &Numbering, number: u64) -> Option { let Numbering::Pattern(pat) = numbering else { return None; }; @@ -275,18 +275,18 @@ impl PdfPageLabel { (!prefix.is_empty()).then(|| prefix.clone()) }; - let offset = style.and(NonZeroUsize::new(number)); + let offset = style.and(NonZeroU64::new(number)); Some(PdfPageLabel { prefix, style, offset }) } /// Creates an arabic page label with the specified page number. /// For example, this will display page label `11` when given the page /// number 11. - fn arabic(number: usize) -> PdfPageLabel { + fn arabic(number: u64) -> PdfPageLabel { PdfPageLabel { prefix: None, style: Some(PdfPageLabelStyle::Arabic), - offset: NonZeroUsize::new(number), + offset: NonZeroU64::new(number), } } } diff --git a/crates/typst-syntax/src/ast.rs b/crates/typst-syntax/src/ast.rs index f79e659820..7b211bfc1a 100644 --- a/crates/typst-syntax/src/ast.rs +++ b/crates/typst-syntax/src/ast.rs @@ -778,7 +778,7 @@ node! { impl<'a> EnumItem<'a> { /// The explicit numbering, if any: `23.`. - pub fn number(self) -> Option { + pub fn number(self) -> Option { self.0.children().find_map(|node| match node.kind() { SyntaxKind::EnumMarker => node.text().trim_end_matches('.').parse().ok(), _ => Option::None, diff --git a/crates/typst-syntax/src/lexer.rs b/crates/typst-syntax/src/lexer.rs index b8f2bf25fb..ac69eb616e 100644 --- a/crates/typst-syntax/src/lexer.rs +++ b/crates/typst-syntax/src/lexer.rs @@ -480,7 +480,7 @@ impl Lexer<'_> { self.s.eat_while(char::is_ascii_digit); let read = self.s.from(start); - if self.s.eat_if('.') && self.space_or_end() && read.parse::().is_ok() { + if self.s.eat_if('.') && self.space_or_end() && read.parse::().is_ok() { return SyntaxKind::EnumMarker; } diff --git a/tests/ref/enum-numbering-huge.png b/tests/ref/enum-numbering-huge.png new file mode 100644 index 0000000000000000000000000000000000000000..b8117e0f490d8616cdf9e6a3982223c95aebb23e GIT binary patch literal 900 zcmV-~1AF|5P)pITPE1iPJQQ38U(OOa$cY)FYC4wYFIzY*^fRHR2x{XGK;e-uL z8g4s1V7W~Q7+7NhfjIdZ9?T)a1P1&1?5CaAub;g0&ig!nf8FD^_j?16sSP45!Xhlf zj|8>=W+NCFPmZZ!Wdj{4B$7}fX)C9+wBnqhrfRY>e}^M zQbV6CP9I&!MA@S;vX75eM@wc8adN2I5>6#K(EsgtA)im4>O=F?Iz;<3MLhNPFQmwh&9F3|{v`G`V-Da5#qW3)UR8 z6`7q#r(*CX7>lS;r8wE?-prj{+A=CN(%WpC-@(l!c;g{Ff3RbROEA2h)erwpKtx!C0dNA{ zHt=1VgbBSLI{tv=mKkuhguv1ZOTW#*h#^AQ?120F@#t6=f3C_d>`7E^gl0p%f&FXg zEAE0>8Fd!*Rnh~g{;4m93x5KI8L;Uy2A(jf?)}3AKb{`m*%7G z>c6VqBFFZ4=}+3xl7B0*C#_z#jh*x2+_Q zN!Bg1ZuPxT-jYe~%OQPQZ(K}@99(Ps*_%ho+RM2_G1ZR~0F!zlbepj?XrNYhhsal5jrY1i)*9|~Z@(+zx@qmc12#fHO a4F3yJa>=#fj;+`L00004WE64bZO`02)+5xV|<5-3lAvYN9%=6;3_iP?-S3 z$2kPbDgfIjE1*+&;rp%z29>=)v(gWDaUIaB7=o`+;aV^@9en~W6{&7h&d?P+v^=j3 zq#@a_%eUUb&4FCK=MJ$eyO4D!lg zDpI`0M}1du&-%2au8lk}x=sg^aKrxYW%!Lt@?buR`^DMi*o_3}&S);- znmV%qaWt#Gs?Ne$ZMqS0oERn9#kokapyLeHy#^|C(36Zus@!742NKYhW=zMOvaBDx zwCQ3)CNkmlAhu_@HPJ;U}Te##P?%u_Q&BA2yzv zCGmZ}Ua>?ywUs1vY7_UFgCn~nYWvQJ*nVB&ffhKoO4R)cQ870C;X};`tRvI2ymo%v zW%F3~NUCQylRYNw5pkD)AYP)L+(veHYZLdkarPY&wJmP(mOLeyH70%zW z?f#v9w8&`tzLh&LA^iaSlF_a$luu(+ssd_ddT_4_To{yo0jgwea6P`>T2HUa{vfLd zMj372_wrFVU%aCYEvpBL20DD}W!YK&sJvVC33j%v~bR8E2_C(}Wo-nZY?A#VXU{AGPlrISEy~)TZ zVz9@InK6SscE`+^!5$BL-Ug*d5ZKF{EG!ZP_5$PQIs}2e$npuZFtC@F_e>Dj>mX*a nAh1_j@W#(zj~O%L|HSw=-pe=f9rb$n00000NkvXXu0mjf5Bjt~ literal 0 HcmV?d00001 diff --git a/tests/src/world.rs b/tests/src/world.rs index 5c2678328b..9e0e91ad77 100644 --- a/tests/src/world.rs +++ b/tests/src/world.rs @@ -244,7 +244,7 @@ fn lines( engine: &mut Engine, context: Tracked, span: Span, - count: usize, + count: u64, #[default(Numbering::Pattern(NumberingPattern::from_str("A").unwrap()))] numbering: Numbering, ) -> SourceResult { diff --git a/tests/suite/introspection/counter.typ b/tests/suite/introspection/counter.typ index 2f095f2fb4..b0657a2ad4 100644 --- a/tests/suite/introspection/counter.typ +++ b/tests/suite/introspection/counter.typ @@ -164,3 +164,13 @@ B #context test(c.get(), (1,)) #c.step(level: 3) #context test(c.get(), (1, 0, 1)) + +--- counter-huge --- +// Test values greater than 32-bits +#let c = counter("c") +#c.update(100000000001) +#context test(c.get(), (100000000001,)) +#c.step() +#context test(c.get(), (100000000002,)) +#c.update(n => n + 2) +#context test(c.get(), (100000000004,)) diff --git a/tests/suite/layout/page.typ b/tests/suite/layout/page.typ index a35f19bb3e..4df9f9cac5 100644 --- a/tests/suite/layout/page.typ +++ b/tests/suite/layout/page.typ @@ -246,6 +246,16 @@ Look, ma, no page numbers! #set page(header: auto, footer: auto) Default page numbers now. +--- page-numbering-huge --- +#set page(margin: (bottom: 20pt, rest: 0pt)) +#let filler = lines(1) + +// Test values greater than 32-bits +#set page(numbering: "1/1") +#counter(page).update(100000000001) +#pagebreak() +#pagebreak() + --- page-marginal-style-text-set --- #set page(numbering: "1", margin: (bottom: 20pt)) #set text(red) diff --git a/tests/suite/model/enum.typ b/tests/suite/model/enum.typ index 7176b04e2c..7ee4dc20c3 100644 --- a/tests/suite/model/enum.typ +++ b/tests/suite/model/enum.typ @@ -134,6 +134,11 @@ a + 0. // Error: 22-28 invalid numbering pattern #set enum(numbering: "(())") +--- enum-numbering-huge --- +// Test values greater than 32-bits +100000000001. A ++ B + --- enum-number-align-unaffected --- // Alignment shouldn't affect number #set align(horizon) diff --git a/tests/suite/model/numbering.typ b/tests/suite/model/numbering.typ index ccd7cfc18e..6af989ff1d 100644 --- a/tests/suite/model/numbering.typ +++ b/tests/suite/model/numbering.typ @@ -49,6 +49,7 @@ 2000000001, "βΜκʹ, αʹ", 2000010001, "βΜκʹ, αΜαʹ, αʹ", 2056839184, "βΜκʹ, αΜ͵εχπγ, ͵θρπδ", + 12312398676, "βΜρκγʹ, αΜ͵ασλθ, ͵ηχοϛ", ) #t( pat: sym.Alpha, From 1e591ac8dcfb7160bd401e76b4ff39aec80db219 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 24 Mar 2025 19:17:29 +0100 Subject: [PATCH 064/558] Bump `zip` (#6091) --- Cargo.lock | 6 ++---- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 06dd4ab805..d63cec8802 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3796,18 +3796,16 @@ dependencies = [ [[package]] name = "zip" -version = "2.2.2" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae9c1ea7b3a5e1f4b922ff856a129881167511563dc219869afe3787fc0c1a45" +checksum = "27c03817464f64e23f6f37574b4fdc8cf65925b5bfd2b0f2aedf959791941f88" dependencies = [ "arbitrary", "crc32fast", "crossbeam-utils", - "displaydoc", "flate2", "indexmap 2.7.1", "memchr", - "thiserror 2.0.11", "zopfli", ] diff --git a/Cargo.toml b/Cargo.toml index 4e0d3a26c0..a14124d659 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -143,7 +143,7 @@ xmlwriter = "0.1.0" xmp-writer = "0.3.1" xz2 = { version = "0.1", features = ["static"] } yaml-front-matter = "0.1" -zip = { version = "2", default-features = false, features = ["deflate"] } +zip = { version = "2.5", default-features = false, features = ["deflate"] } [profile.dev.package."*"] opt-level = 2 From 1f1c1338785dc09a43292cf106b4a23b4e1bd86e Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Mon, 24 Mar 2025 17:42:48 -0300 Subject: [PATCH 065/558] Refactor grid header and footer resolving (#5919) --- .../typst-library/src/layout/grid/resolve.rs | 1934 ++++++++++------- tests/ref/grid-footer-expand.png | Bin 365 -> 0 bytes ...rid-footer-moved-to-bottom-of-rowspans.png | Bin 0 -> 1182 bytes ...oter-top-hlines-with-only-row-pos-cell.png | Bin 0 -> 385 bytes ...-top-hlines-with-row-and-auto-pos-cell.png | Bin 0 -> 579 bytes tests/ref/grid-header-cell-with-x.png | Bin 0 -> 419 bytes tests/ref/grid-header-expand.png | Bin 2005 -> 0 bytes ...59-column-override-stays-inside-footer.png | Bin 0 -> 674 bytes tests/suite/layout/grid/footers.typ | 159 +- tests/suite/layout/grid/headers.typ | 106 +- 10 files changed, 1437 insertions(+), 762 deletions(-) delete mode 100644 tests/ref/grid-footer-expand.png create mode 100644 tests/ref/grid-footer-moved-to-bottom-of-rowspans.png create mode 100644 tests/ref/grid-footer-top-hlines-with-only-row-pos-cell.png create mode 100644 tests/ref/grid-footer-top-hlines-with-row-and-auto-pos-cell.png create mode 100644 tests/ref/grid-header-cell-with-x.png delete mode 100644 tests/ref/grid-header-expand.png create mode 100644 tests/ref/issue-5359-column-override-stays-inside-footer.png diff --git a/crates/typst-library/src/layout/grid/resolve.rs b/crates/typst-library/src/layout/grid/resolve.rs index 08d0130dab..bad25b4744 100644 --- a/crates/typst-library/src/layout/grid/resolve.rs +++ b/crates/typst-library/src/layout/grid/resolve.rs @@ -1,4 +1,5 @@ use std::num::NonZeroUsize; +use std::ops::Range; use std::sync::Arc; use ecow::eco_format; @@ -20,6 +21,8 @@ use typst_library::Dir; use typst_syntax::Span; use typst_utils::NonZeroExt; +use crate::introspection::SplitLocator; + /// Convert a grid to a cell grid. #[typst_macros::time(span = elem.span())] pub fn grid_to_cellgrid<'a>( @@ -57,7 +60,7 @@ pub fn grid_to_cellgrid<'a>( ResolvableGridChild::Item(grid_item_to_resolvable(item, styles)) } }); - CellGrid::resolve( + resolve_cellgrid( tracks, gutter, locator, @@ -110,7 +113,7 @@ pub fn table_to_cellgrid<'a>( ResolvableGridChild::Item(table_item_to_resolvable(item, styles)) } }); - CellGrid::resolve( + resolve_cellgrid( tracks, gutter, locator, @@ -421,12 +424,14 @@ pub struct Line { } /// A repeatable grid header. Starts at the first row. +#[derive(Debug)] pub struct Header { /// The index after the last row included in this header. pub end: usize, } /// A repeatable grid footer. Stops at the last row. +#[derive(Debug)] pub struct Footer { /// The first row included in this footer. pub start: usize, @@ -652,36 +657,318 @@ impl<'a> CellGrid<'a> { Self::new_internal(tracks, gutter, vec![], vec![], None, None, entries) } - /// Resolves and positions all cells in the grid before creating it. - /// Allows them to keep track of their final properties and positions - /// and adjust their fields accordingly. - /// Cells must implement Clone as they will be owned. Additionally, they - /// must implement Default in order to fill positions in the grid which - /// weren't explicitly specified by the user with empty cells. - #[allow(clippy::too_many_arguments)] - pub fn resolve( + /// Generates the cell grid, given the tracks and resolved entries. + pub fn new_internal( tracks: Axes<&[Sizing]>, gutter: Axes<&[Sizing]>, - locator: Locator<'a>, - children: C, - fill: &Celled>, - align: &Celled>, - inset: &Celled>>>, - stroke: &ResolvedCelled>>>>, - engine: &mut Engine, - styles: StyleChain, - span: Span, - ) -> SourceResult + vlines: Vec>, + hlines: Vec>, + header: Option>, + footer: Option>, + entries: Vec>, + ) -> Self { + let mut cols = vec![]; + let mut rows = vec![]; + + // Number of content columns: Always at least one. + let num_cols = tracks.x.len().max(1); + + // Number of content rows: At least as many as given, but also at least + // as many as needed to place each item. + let num_rows = { + let len = entries.len(); + let given = tracks.y.len(); + let needed = len / num_cols + (len % num_cols).clamp(0, 1); + given.max(needed) + }; + + let has_gutter = gutter.any(|tracks| !tracks.is_empty()); + let auto = Sizing::Auto; + let zero = Sizing::Rel(Rel::zero()); + let get_or = |tracks: &[_], idx, default| { + tracks.get(idx).or(tracks.last()).copied().unwrap_or(default) + }; + + // Collect content and gutter columns. + for x in 0..num_cols { + cols.push(get_or(tracks.x, x, auto)); + if has_gutter { + cols.push(get_or(gutter.x, x, zero)); + } + } + + // Collect content and gutter rows. + for y in 0..num_rows { + rows.push(get_or(tracks.y, y, auto)); + if has_gutter { + rows.push(get_or(gutter.y, y, zero)); + } + } + + // Remove superfluous gutter tracks. + if has_gutter { + cols.pop(); + rows.pop(); + } + + Self { + cols, + rows, + entries, + vlines, + hlines, + header, + footer, + has_gutter, + } + } + + /// Get the grid entry in column `x` and row `y`. + /// + /// Returns `None` if it's a gutter cell. + #[track_caller] + pub fn entry(&self, x: usize, y: usize) -> Option<&Entry<'a>> { + assert!(x < self.cols.len()); + assert!(y < self.rows.len()); + + if self.has_gutter { + // Even columns and rows are children, odd ones are gutter. + if x % 2 == 0 && y % 2 == 0 { + let c = 1 + self.cols.len() / 2; + self.entries.get((y / 2) * c + x / 2) + } else { + None + } + } else { + let c = self.cols.len(); + self.entries.get(y * c + x) + } + } + + /// Get the content of the cell in column `x` and row `y`. + /// + /// Returns `None` if it's a gutter cell or merged position. + #[track_caller] + pub fn cell(&self, x: usize, y: usize) -> Option<&Cell<'a>> { + self.entry(x, y).and_then(Entry::as_cell) + } + + /// Returns the position of the parent cell of the grid entry at the given + /// position. It is guaranteed to have a non-gutter, non-merged cell at + /// the returned position, due to how the grid is built. + /// - If the entry at the given position is a cell, returns the given + /// position. + /// - If it is a merged cell, returns the parent cell's position. + /// - If it is a gutter cell, returns None. + #[track_caller] + pub fn parent_cell_position(&self, x: usize, y: usize) -> Option> { + self.entry(x, y).map(|entry| match entry { + Entry::Cell(_) => Axes::new(x, y), + Entry::Merged { parent } => { + let c = self.non_gutter_column_count(); + let factor = if self.has_gutter { 2 } else { 1 }; + Axes::new(factor * (*parent % c), factor * (*parent / c)) + } + }) + } + + /// Returns the position of the actual parent cell of a merged position, + /// even if the given position is gutter, in which case we return the + /// parent of the nearest adjacent content cell which could possibly span + /// the given gutter position. If the given position is not a gutter cell, + /// then this function will return the same as `parent_cell_position` would. + /// If the given position is a gutter cell, but no cell spans it, returns + /// `None`. + /// + /// This is useful for lines. A line needs to check if a cell next to it + /// has a stroke override - even at a gutter position there could be a + /// stroke override, since a cell could be merged with two cells at both + /// ends of the gutter cell (e.g. to its left and to its right), and thus + /// that cell would impose a stroke under the gutter. This function allows + /// getting the position of that cell (which spans the given gutter + /// position, if it is gutter), if it exists; otherwise returns None (it's + /// gutter and no cell spans it). + #[track_caller] + pub fn effective_parent_cell_position( + &self, + x: usize, + y: usize, + ) -> Option> { + if self.has_gutter { + // If (x, y) is a gutter cell, we skip it (skip a gutter column and + // row) to the nearest adjacent content cell, in the direction + // which merged cells grow toward (increasing x and increasing y), + // such that we can verify if that adjacent cell is merged with the + // gutter cell by checking if its parent would come before (x, y). + // Otherwise, no cell is merged with this gutter cell, and we + // return None. + self.parent_cell_position(x + x % 2, y + y % 2) + .filter(|&parent| parent.x <= x && parent.y <= y) + } else { + self.parent_cell_position(x, y) + } + } + + /// Checks if the track with the given index is gutter. + /// Does not check if the index is a valid track. + #[inline] + pub fn is_gutter_track(&self, index: usize) -> bool { + self.has_gutter && index % 2 == 1 + } + + /// Returns the effective colspan of a cell, considering the gutters it + /// might span if the grid has gutters. + #[inline] + pub fn effective_colspan_of_cell(&self, cell: &Cell) -> usize { + if self.has_gutter { + 2 * cell.colspan.get() - 1 + } else { + cell.colspan.get() + } + } + + /// Returns the effective rowspan of a cell, considering the gutters it + /// might span if the grid has gutters. + #[inline] + pub fn effective_rowspan_of_cell(&self, cell: &Cell) -> usize { + if self.has_gutter { + 2 * cell.rowspan.get() - 1 + } else { + cell.rowspan.get() + } + } + + #[inline] + pub fn non_gutter_column_count(&self) -> usize { + if self.has_gutter { + // Calculation: With gutters, we have + // 'cols = 2 * (non-gutter cols) - 1', since there is a gutter + // column between each regular column. Therefore, + // 'floor(cols / 2)' will be equal to + // 'floor(non-gutter cols - 1/2) = non-gutter-cols - 1', + // so 'non-gutter cols = 1 + floor(cols / 2)'. + 1 + self.cols.len() / 2 + } else { + self.cols.len() + } + } +} + +/// Resolves and positions all cells in the grid before creating it. +/// Allows them to keep track of their final properties and positions +/// and adjust their fields accordingly. +/// Cells must implement Clone as they will be owned. Additionally, they +/// must implement Default in order to fill positions in the grid which +/// weren't explicitly specified by the user with empty cells. +#[allow(clippy::too_many_arguments)] +pub fn resolve_cellgrid<'a, 'x, T, C, I>( + tracks: Axes<&'a [Sizing]>, + gutter: Axes<&'a [Sizing]>, + locator: Locator<'x>, + children: C, + fill: &'a Celled>, + align: &'a Celled>, + inset: &'a Celled>>>, + stroke: &'a ResolvedCelled>>>>, + engine: &'a mut Engine, + styles: StyleChain<'a>, + span: Span, +) -> SourceResult> +where + T: ResolvableCell + Default, + I: Iterator>, + C: IntoIterator>, + C::IntoIter: ExactSizeIterator, +{ + CellGridResolver { + tracks, + gutter, + locator: locator.split(), + fill, + align, + inset, + stroke, + engine, + styles, + span, + } + .resolve(children) +} + +struct CellGridResolver<'a, 'b, 'x> { + tracks: Axes<&'a [Sizing]>, + gutter: Axes<&'a [Sizing]>, + locator: SplitLocator<'x>, + fill: &'a Celled>, + align: &'a Celled>, + inset: &'a Celled>>>, + stroke: &'a ResolvedCelled>>>>, + engine: &'a mut Engine<'b>, + styles: StyleChain<'a>, + span: Span, +} + +#[derive(Debug, Clone, Copy)] +enum RowGroupKind { + Header, + Footer, +} + +impl RowGroupKind { + fn name(self) -> &'static str { + match self { + Self::Header => "header", + Self::Footer => "footer", + } + } +} + +struct RowGroupData { + /// The range of rows of cells inside this grid row group. The + /// first and last rows are guaranteed to have cells (an exception + /// is made when there is gutter, in which case the group range may + /// be expanded to include an additional gutter row when there is a + /// repeatable header or footer). This is `None` until the first + /// cell of the row group is placed, then it is continually adjusted + /// to fit the cells inside the row group. + /// + /// This stays as `None` for fully empty headers and footers. + range: Option>, + span: Span, + kind: RowGroupKind, + + /// Start of the range of indices of hlines at the top of the row group. + /// This is always the first index after the last hline before we started + /// building the row group - any upcoming hlines would appear at least at + /// this index. + /// + /// These hlines were auto-positioned and appeared before any auto-pos + /// cells, so they will appear at the first possible row (above the + /// first row spanned by the row group). + top_hlines_start: usize, + + /// End of the range of indices of hlines at the top of the row group. + /// + /// This starts as `None`, meaning that, if we stop the loop before we find + /// any auto-pos cells, all auto-pos hlines after the last hline (after the + /// index `top_hlines_start`) should be moved to the top of the row group. + /// + /// It becomes `Some(index of last hline at the top)` when an auto-pos cell + /// is found, as auto-pos hlines after any auto-pos cells appear below + /// them, not at the top of the row group. + top_hlines_end: Option, +} + +impl<'x> CellGridResolver<'_, '_, 'x> { + fn resolve(mut self, children: C) -> SourceResult> where T: ResolvableCell + Default, I: Iterator>, C: IntoIterator>, C::IntoIter: ExactSizeIterator, { - let mut locator = locator.split(); - // Number of content columns: Always at least one. - let c = tracks.x.len().max(1); + let columns = self.tracks.x.len().max(1); // Lists of lines. // Horizontal lines are only pushed later to be able to check for row @@ -695,7 +982,7 @@ impl<'a> CellGrid<'a> { // For consistency, only push vertical lines later as well. let mut pending_vlines: Vec<(Span, Line)> = vec![]; - let has_gutter = gutter.any(|tracks| !tracks.is_empty()); + let has_gutter = self.gutter.any(|tracks| !tracks.is_empty()); let mut header: Option
= None; let mut repeat_header = false; @@ -705,27 +992,6 @@ impl<'a> CellGrid<'a> { let mut footer: Option<(usize, Span, Footer)> = None; let mut repeat_footer = false; - // Resolves the breakability of a cell. Cells that span at least one - // auto-sized row or gutter are considered breakable. - let resolve_breakable = |y, rowspan| { - let auto = Sizing::Auto; - let zero = Sizing::Rel(Rel::zero()); - tracks - .y - .iter() - .chain(std::iter::repeat(tracks.y.last().unwrap_or(&auto))) - .skip(y) - .take(rowspan) - .any(|row| row == &Sizing::Auto) - || gutter - .y - .iter() - .chain(std::iter::repeat(gutter.y.last().unwrap_or(&zero))) - .skip(y) - .take(rowspan - 1) - .any(|row_gutter| row_gutter == &Sizing::Auto) - }; - // We can't just use the cell's index in the 'cells' vector to // determine its automatic position, since cells could have arbitrary // positions, so the position of a cell in 'cells' can differ from its @@ -733,532 +999,605 @@ impl<'a> CellGrid<'a> { // Therefore, we use a counter, 'auto_index', to determine the position // of the next cell with (x: auto, y: auto). It is only stepped when // a cell with (x: auto, y: auto), usually the vast majority, is found. + // + // Note that a separate counter ('local_auto_index') is used within + // headers and footers, as explained above its definition. Outside of + // those (when the table child being processed is a single cell), + // 'local_auto_index' will simply be an alias for 'auto_index', which + // will be updated after that cell is placed, if it is an + // automatically-positioned cell. let mut auto_index: usize = 0; - // We have to rebuild the grid to account for arbitrary positions. + // We have to rebuild the grid to account for fixed cell positions. + // // Create at least 'children.len()' positions, since there could be at // least 'children.len()' cells (if no explicit lines were specified), - // even though some of them might be placed in arbitrary positions and - // thus cause the grid to expand. - // Additionally, make sure we allocate up to the next multiple of 'c', - // since each row will have 'c' cells, even if the last few cells - // weren't explicitly specified by the user. - // We apply '% c' twice so that the amount of cells potentially missing - // is zero when 'children.len()' is already a multiple of 'c' (thus - // 'children.len() % c' would be zero). + // even though some of them might be placed in fixed positions and thus + // cause the grid to expand. + // + // Additionally, make sure we allocate up to the next multiple of + // 'columns', since each row will have 'columns' cells, even if the + // last few cells weren't explicitly specified by the user. let children = children.into_iter(); - let Some(child_count) = children.len().checked_add((c - children.len() % c) % c) - else { - bail!(span, "too many cells or lines were given") + let Some(child_count) = children.len().checked_next_multiple_of(columns) else { + bail!(self.span, "too many cells or lines were given") }; let mut resolved_cells: Vec> = Vec::with_capacity(child_count); for child in children { - let mut is_header = false; - let mut is_footer = false; - let mut child_start = usize::MAX; - let mut child_end = 0; - let mut child_span = Span::detached(); - let mut start_new_row = false; - let mut first_index_of_top_hlines = usize::MAX; - let mut first_index_of_non_top_hlines = usize::MAX; - - let (header_footer_items, simple_item) = match child { - ResolvableGridChild::Header { repeat, span, items, .. } => { - if header.is_some() { - bail!(span, "cannot have more than one header"); - } + self.resolve_grid_child( + columns, + &mut pending_hlines, + &mut pending_vlines, + &mut header, + &mut repeat_header, + &mut footer, + &mut repeat_footer, + &mut auto_index, + &mut resolved_cells, + child, + )?; + } - is_header = true; - child_span = span; - repeat_header = repeat; + let resolved_cells = self.fixup_cells::(resolved_cells, columns)?; - // If any cell in the header is automatically positioned, - // have it skip to the next row. This is to avoid having a - // header after a partially filled row just add cells to - // that row instead of starting a new one. - // FIXME: Revise this approach when headers can start from - // arbitrary rows. - start_new_row = true; + let row_amount = resolved_cells.len().div_ceil(columns); + let (hlines, vlines) = self.collect_lines( + pending_hlines, + pending_vlines, + has_gutter, + columns, + row_amount, + )?; - // Any hlines at the top of the header will start at this - // index. - first_index_of_top_hlines = pending_hlines.len(); + let (header, footer) = self.finalize_headers_and_footers( + has_gutter, + header, + repeat_header, + footer, + repeat_footer, + row_amount, + )?; - (Some(items), None) - } - ResolvableGridChild::Footer { repeat, span, items, .. } => { - if footer.is_some() { - bail!(span, "cannot have more than one footer"); - } + Ok(CellGrid::new_internal( + self.tracks, + self.gutter, + vlines, + hlines, + header, + footer, + resolved_cells, + )) + } - is_footer = true; - child_span = span; - repeat_footer = repeat; + /// Resolve a grid child, which can be a header, a footer (both of which + /// are row groups, and thus contain multiple grid items inside them), or + /// a grid item - a cell, an hline or a vline. + /// + /// This process consists of placing the child and any sub-items into + /// appropriate positions in the resolved grid. This is mostly relevant for + /// items without fixed positions, such that they must be placed after the + /// previous one, perhaps skipping existing cells along the way. + #[allow(clippy::too_many_arguments)] + fn resolve_grid_child( + &mut self, + columns: usize, + pending_hlines: &mut Vec<(Span, Line, bool)>, + pending_vlines: &mut Vec<(Span, Line)>, + header: &mut Option
, + repeat_header: &mut bool, + footer: &mut Option<(usize, Span, Footer)>, + repeat_footer: &mut bool, + auto_index: &mut usize, + resolved_cells: &mut Vec>>, + child: ResolvableGridChild, + ) -> SourceResult<()> + where + T: ResolvableCell + Default, + I: Iterator>, + { + // Data for the row group in this iteration. + // + // Note that cells outside headers and footers are grid children + // with a single cell inside, and thus not considered row groups, + // in which case this variable remains 'None'. + let mut row_group_data: Option = None; + + // The normal auto index should only be stepped (upon placing an + // automatically-positioned cell, to indicate the position of the + // next) outside of headers or footers, in which case the auto + // index will be updated with the local auto index. Inside headers + // and footers, however, cells can only start after the first empty + // row (as determined by 'first_available_row' below), meaning that + // the next automatically-positioned cell will be in a different + // position than it would usually be if it would be in a non-empty + // row, so we must step a local index inside headers and footers + // instead, and use a separate counter outside them. + let mut local_auto_index = *auto_index; + + // The first row in which this table group can fit. + // + // Within headers and footers, this will correspond to the first + // fully empty row available in the grid. This is because headers + // and footers always occupy entire rows, so they cannot occupy + // a non-empty row. + let mut first_available_row = 0; + + let (header_footer_items, simple_item) = match child { + ResolvableGridChild::Header { repeat, span, items, .. } => { + if header.is_some() { + bail!(span, "cannot have more than one header"); + } - // If any cell in the footer is automatically positioned, - // have it skip to the next row. This is to avoid having a - // footer after a partially filled row just add cells to - // that row instead of starting a new one. - start_new_row = true; + row_group_data = Some(RowGroupData { + range: None, + span, + kind: RowGroupKind::Header, + top_hlines_start: pending_hlines.len(), + top_hlines_end: None, + }); + + *repeat_header = repeat; - // Any hlines at the top of the footer will start at this - // index. - first_index_of_top_hlines = pending_hlines.len(); + first_available_row = + find_next_empty_row(resolved_cells, local_auto_index, columns); - (Some(items), None) + // If any cell in the header is automatically positioned, + // have it skip to the next empty row. This is to avoid + // having a header after a partially filled row just add + // cells to that row instead of starting a new one. + // + // Note that the first fully empty row is always after the + // latest auto-position cell, since each auto-position cell + // always occupies the first available position after the + // previous one. Therefore, this will be >= auto_index. + local_auto_index = first_available_row * columns; + + (Some(items), None) + } + ResolvableGridChild::Footer { repeat, span, items, .. } => { + if footer.is_some() { + bail!(span, "cannot have more than one footer"); } - ResolvableGridChild::Item(item) => (None, Some(item)), - }; - let items = header_footer_items - .into_iter() - .flatten() - .chain(simple_item.into_iter()); - for item in items { - let cell = match item { - ResolvableGridItem::HLine { - y, - start, - end, - stroke, - span, - position, - } => { - let has_auto_y = y.is_auto(); - let y = y.unwrap_or_else(|| { - // Avoid placing the hline inside consecutive - // rowspans occupying all columns, as it'd just - // disappear, at least when there's no column - // gutter. - skip_auto_index_through_fully_merged_rows( - &resolved_cells, - &mut auto_index, - c, - ); - - // When no 'y' is specified for the hline, we place - // it under the latest automatically positioned - // cell. - // The current value of the auto index is always - // the index of the latest automatically positioned - // cell placed plus one (that's what we do in - // 'resolve_cell_position'), so we subtract 1 to - // get that cell's index, and place the hline below - // its row. The exception is when the auto_index is - // 0, meaning no automatically positioned cell was - // placed yet. In that case, we place the hline at - // the top of the table. - // - // Exceptionally, the hline will be placed before - // the minimum auto index if the current auto index - // from previous iterations is smaller than the - // minimum it should have for the current grid - // child. Effectively, this means that a hline at - // the start of a header will always appear above - // that header's first row. Similarly for footers. - auto_index - .checked_sub(1) - .map_or(0, |last_auto_index| last_auto_index / c + 1) - }); - if end.is_some_and(|end| end.get() < start) { - bail!(span, "line cannot end before it starts"); - } - let line = Line { index: y, start, end, stroke, position }; - - // Since the amount of rows is dynamic, delay placing - // hlines until after all cells were placed so we can - // properly verify if they are valid. Note that we - // can't place hlines even if we already know they - // would be in a valid row, since it's possible that we - // pushed pending hlines in the same row as this one in - // previous iterations, and we need to ensure that - // hlines from previous iterations are pushed to the - // final vector of hlines first - the order of hlines - // must be kept, as this matters when determining which - // one "wins" in case of conflict. Pushing the current - // hline before we push pending hlines later would - // change their order! - pending_hlines.push((span, line, has_auto_y)); - continue; + row_group_data = Some(RowGroupData { + range: None, + span, + kind: RowGroupKind::Footer, + top_hlines_start: pending_hlines.len(), + top_hlines_end: None, + }); + + *repeat_footer = repeat; + + first_available_row = + find_next_empty_row(resolved_cells, local_auto_index, columns); + + local_auto_index = first_available_row * columns; + + (Some(items), None) + } + ResolvableGridChild::Item(item) => (None, Some(item)), + }; + + let items = header_footer_items.into_iter().flatten().chain(simple_item); + for item in items { + let cell = match item { + ResolvableGridItem::HLine { y, start, end, stroke, span, position } => { + let has_auto_y = y.is_auto(); + let y = y.unwrap_or_else(|| { + // Avoid placing the hline inside consecutive + // rowspans occupying all columns, as it'd just + // disappear, at least when there's no column + // gutter. + skip_auto_index_through_fully_merged_rows( + resolved_cells, + &mut local_auto_index, + columns, + ); + + // When no 'y' is specified for the hline, we place + // it under the latest automatically positioned + // cell. + // The current value of the auto index is always + // the index of the latest automatically positioned + // cell placed plus one (that's what we do in + // 'resolve_cell_position'), so we subtract 1 to + // get that cell's index, and place the hline below + // its row. The exception is when the auto_index is + // 0, meaning no automatically positioned cell was + // placed yet. In that case, we place the hline at + // the top of the table. + // + // Exceptionally, the hline will be placed before + // the minimum auto index if the current auto index + // from previous iterations is smaller than the + // minimum it should have for the current grid + // child. Effectively, this means that a hline at + // the start of a header will always appear above + // that header's first row. Similarly for footers. + local_auto_index + .checked_sub(1) + .map_or(0, |last_auto_index| last_auto_index / columns + 1) + }); + if end.is_some_and(|end| end.get() < start) { + bail!(span, "line cannot end before it starts"); } - ResolvableGridItem::VLine { - x, - start, - end, - stroke, - span, - position, - } => { - let x = x.unwrap_or_else(|| { - // When no 'x' is specified for the vline, we place - // it after the latest automatically positioned - // cell. - // The current value of the auto index is always - // the index of the latest automatically positioned - // cell placed plus one (that's what we do in - // 'resolve_cell_position'), so we subtract 1 to - // get that cell's index, and place the vline after - // its column. The exception is when the auto_index - // is 0, meaning no automatically positioned cell - // was placed yet. In that case, we place the vline - // to the left of the table. - // - // Exceptionally, a vline is also placed to the - // left of the table if we should start a new row - // for the next automatically positioned cell. - // For example, this means that a vline at - // the beginning of a header will be placed to its - // left rather than after the previous - // automatically positioned cell. Same for footers. - auto_index - .checked_sub(1) - .filter(|_| !start_new_row) - .map_or(0, |last_auto_index| last_auto_index % c + 1) - }); - if end.is_some_and(|end| end.get() < start) { - bail!(span, "line cannot end before it starts"); - } - let line = Line { index: x, start, end, stroke, position }; - - // For consistency with hlines, we only push vlines to - // the final vector of vlines after processing every + let line = Line { index: y, start, end, stroke, position }; + + // Since the amount of rows is dynamic, delay placing + // hlines until after all cells were placed so we can + // properly verify if they are valid. Note that we + // can't place hlines even if we already know they + // would be in a valid row, since it's possible that we + // pushed pending hlines in the same row as this one in + // previous iterations, and we need to ensure that + // hlines from previous iterations are pushed to the + // final vector of hlines first - the order of hlines + // must be kept, as this matters when determining which + // one "wins" in case of conflict. Pushing the current + // hline before we push pending hlines later would + // change their order! + pending_hlines.push((span, line, has_auto_y)); + continue; + } + ResolvableGridItem::VLine { x, start, end, stroke, span, position } => { + let x = x.unwrap_or_else(|| { + // When no 'x' is specified for the vline, we place + // it after the latest automatically positioned // cell. - pending_vlines.push((span, line)); - continue; + // The current value of the auto index is always + // the index of the latest automatically positioned + // cell placed plus one (that's what we do in + // 'resolve_cell_position'), so we subtract 1 to + // get that cell's index, and place the vline after + // its column. The exception is when the auto_index + // is 0, meaning no automatically positioned cell + // was placed yet. In that case, we place the vline + // to the left of the table. + // + // Exceptionally, a vline is also placed to the + // left of the table when specified at the start + // of a row group, such as a header or footer, that + // is, when no automatically-positioned cells have + // been specified for that group yet. + // For example, this means that a vline at + // the beginning of a header will be placed to its + // left rather than after the previous + // automatically positioned cell. Same for footers. + local_auto_index + .checked_sub(1) + .filter(|_| local_auto_index > first_available_row * columns) + .map_or(0, |last_auto_index| last_auto_index % columns + 1) + }); + if end.is_some_and(|end| end.get() < start) { + bail!(span, "line cannot end before it starts"); } - ResolvableGridItem::Cell(cell) => cell, - }; - let cell_span = cell.span(); - let colspan = cell.colspan(styles).get(); - let rowspan = cell.rowspan(styles).get(); - // Let's calculate the cell's final position based on its - // requested position. - let resolved_index = { - let cell_x = cell.x(styles); - let cell_y = cell.y(styles); - resolve_cell_position( - cell_x, - cell_y, - colspan, + let line = Line { index: x, start, end, stroke, position }; + + // For consistency with hlines, we only push vlines to + // the final vector of vlines after processing every + // cell. + pending_vlines.push((span, line)); + continue; + } + ResolvableGridItem::Cell(cell) => cell, + }; + let cell_span = cell.span(); + let colspan = cell.colspan(self.styles).get(); + let rowspan = cell.rowspan(self.styles).get(); + // Let's calculate the cell's final position based on its + // requested position. + let resolved_index = { + let cell_x = cell.x(self.styles); + let cell_y = cell.y(self.styles); + resolve_cell_position( + cell_x, + cell_y, + colspan, + rowspan, + header.as_ref(), + footer.as_ref(), + resolved_cells, + &mut local_auto_index, + first_available_row, + columns, + row_group_data.is_some(), + ) + .at(cell_span)? + }; + let x = resolved_index % columns; + let y = resolved_index / columns; + + if colspan > columns - x { + bail!( + cell_span, + "cell's colspan would cause it to exceed the available column(s)"; + hint: "try placing the cell in another position or reducing its colspan" + ) + } + + let Some(largest_index) = columns + .checked_mul(rowspan - 1) + .and_then(|full_rowspan_offset| { + resolved_index.checked_add(full_rowspan_offset) + }) + .and_then(|last_row_pos| last_row_pos.checked_add(colspan - 1)) + else { + bail!( + cell_span, + "cell would span an exceedingly large position"; + hint: "try reducing the cell's rowspan or colspan" + ) + }; + + // Cell's header or footer must expand to include the cell's + // occupied positions, if possible. + if let Some(RowGroupData { + range: group_range, kind, top_hlines_end, .. + }) = &mut row_group_data + { + *group_range = Some( + expand_row_group( + resolved_cells, + group_range.as_ref(), + *kind, + first_available_row, + y, rowspan, - &resolved_cells, - &mut auto_index, - &mut start_new_row, - c, - ) - .at(cell_span)? - }; - let x = resolved_index % c; - let y = resolved_index / c; - - if colspan > c - x { - bail!( - cell_span, - "cell's colspan would cause it to exceed the available column(s)"; - hint: "try placing the cell in another position or reducing its colspan" + columns, ) + .at(cell_span)?, + ); + + if top_hlines_end.is_none() + && local_auto_index > first_available_row * columns + { + // Auto index was moved, so upcoming auto-pos hlines should + // no longer appear at the top. + *top_hlines_end = Some(pending_hlines.len()); } + } - let Some(largest_index) = c - .checked_mul(rowspan - 1) - .and_then(|full_rowspan_offset| { - resolved_index.checked_add(full_rowspan_offset) - }) - .and_then(|last_row_pos| last_row_pos.checked_add(colspan - 1)) + // Let's resolve the cell so it can determine its own fields + // based on its final position. + let cell = self.resolve_cell(cell, x, y, rowspan, cell_span)?; + + if largest_index >= resolved_cells.len() { + // Ensure the length of the vector of resolved cells is + // always a multiple of 'columns' by pushing full rows every + // time. Here, we add enough absent positions (later + // converted to empty cells) to ensure the last row in the + // new vector length is completely filled. This is + // necessary so that those positions, even if not + // explicitly used at the end, are eventually susceptible + // to show rules and receive grid styling, as they will be + // resolved as empty cells in a second loop below. + let Some(new_len) = largest_index + .checked_add(1) + .and_then(|new_len| new_len.checked_next_multiple_of(columns)) else { - bail!( - cell_span, - "cell would span an exceedingly large position"; - hint: "try reducing the cell's rowspan or colspan" - ) + bail!(cell_span, "cell position too large") }; - // Let's resolve the cell so it can determine its own fields - // based on its final position. - let cell = cell.resolve_cell( - x, - y, - &fill.resolve(engine, styles, x, y)?, - align.resolve(engine, styles, x, y)?, - inset.resolve(engine, styles, x, y)?, - stroke.resolve(engine, styles, x, y)?, - resolve_breakable(y, rowspan), - locator.next(&cell_span), - styles, - ); - - if largest_index >= resolved_cells.len() { - // Ensure the length of the vector of resolved cells is - // always a multiple of 'c' by pushing full rows every - // time. Here, we add enough absent positions (later - // converted to empty cells) to ensure the last row in the - // new vector length is completely filled. This is - // necessary so that those positions, even if not - // explicitly used at the end, are eventually susceptible - // to show rules and receive grid styling, as they will be - // resolved as empty cells in a second loop below. - let Some(new_len) = largest_index - .checked_add(1) - .and_then(|new_len| new_len.checked_add((c - new_len % c) % c)) - else { - bail!(cell_span, "cell position too large") - }; - - // Here, the cell needs to be placed in a position which - // doesn't exist yet in the grid (out of bounds). We will - // add enough absent positions for this to be possible. - // They must be absent as no cells actually occupy them - // (they can be overridden later); however, if no cells - // occupy them as we finish building the grid, then such - // positions will be replaced by empty cells. - resolved_cells.resize_with(new_len, || None); - } + // Here, the cell needs to be placed in a position which + // doesn't exist yet in the grid (out of bounds). We will + // add enough absent positions for this to be possible. + // They must be absent as no cells actually occupy them + // (they can be overridden later); however, if no cells + // occupy them as we finish building the grid, then such + // positions will be replaced by empty cells. + resolved_cells.resize_with(new_len, || None); + } - // The vector is large enough to contain the cell, so we can - // just index it directly to access the position it will be - // placed in. However, we still need to ensure we won't try to - // place a cell where there already is one. - let slot = &mut resolved_cells[resolved_index]; - if slot.is_some() { - bail!( - cell_span, - "attempted to place a second cell at column {x}, row {y}"; - hint: "try specifying your cells in a different order" - ); - } + // The vector is large enough to contain the cell, so we can + // just index it directly to access the position it will be + // placed in. However, we still need to ensure we won't try to + // place a cell where there already is one. + let slot = &mut resolved_cells[resolved_index]; + if slot.is_some() { + bail!( + cell_span, + "attempted to place a second cell at column {x}, row {y}"; + hint: "try specifying your cells in a different order" + ); + } - *slot = Some(Entry::Cell(cell)); - - // Now, if the cell spans more than one row or column, we fill - // the spanned positions in the grid with Entry::Merged - // pointing to the original cell as its parent. - for rowspan_offset in 0..rowspan { - let spanned_y = y + rowspan_offset; - let first_row_index = resolved_index + c * rowspan_offset; - for (colspan_offset, slot) in resolved_cells[first_row_index..] - [..colspan] - .iter_mut() - .enumerate() - { - let spanned_x = x + colspan_offset; - if spanned_x == x && spanned_y == y { - // This is the parent cell. - continue; - } - if slot.is_some() { - bail!( - cell_span, - "cell would span a previously placed cell at column {spanned_x}, row {spanned_y}"; - hint: "try specifying your cells in a different order or reducing the cell's rowspan or colspan" - ) - } - *slot = Some(Entry::Merged { parent: resolved_index }); - } - } + *slot = Some(Entry::Cell(cell)); - if is_header || is_footer { - // Ensure each cell in a header or footer is fully - // contained within it. - child_start = child_start.min(y); - child_end = child_end.max(y + rowspan); - - if start_new_row && child_start <= auto_index.div_ceil(c) { - // No need to start a new row as we already include - // the row of the next automatically positioned cell in - // the header or footer. - start_new_row = false; + // Now, if the cell spans more than one row or column, we fill + // the spanned positions in the grid with Entry::Merged + // pointing to the original cell as its parent. + for rowspan_offset in 0..rowspan { + let spanned_y = y + rowspan_offset; + let first_row_index = resolved_index + columns * rowspan_offset; + for (colspan_offset, slot) in + resolved_cells[first_row_index..][..colspan].iter_mut().enumerate() + { + let spanned_x = x + colspan_offset; + if spanned_x == x && spanned_y == y { + // This is the parent cell. + continue; } - - if !start_new_row { - // From now on, upcoming hlines won't be at the top of - // the child, as the first automatically positioned - // cell was placed. - first_index_of_non_top_hlines = - first_index_of_non_top_hlines.min(pending_hlines.len()); + if slot.is_some() { + bail!( + cell_span, + "cell would span a previously placed cell at column {spanned_x}, row {spanned_y}"; + hint: "try specifying your cells in a different order or reducing the cell's rowspan or colspan" + ) } + *slot = Some(Entry::Merged { parent: resolved_index }); } } + } - if (is_header || is_footer) && child_start == usize::MAX { - // Empty header/footer: consider the header/footer to be - // at the next empty row after the latest auto index. - auto_index = find_next_empty_row(&resolved_cells, auto_index, c); - child_start = auto_index.div_ceil(c); - child_end = child_start + 1; + if let Some(row_group) = row_group_data { + let group_range = match row_group.range { + Some(group_range) => group_range, - if resolved_cells.len() <= c * child_start { - // Ensure the automatically chosen row actually exists. - resolved_cells.resize_with(c * (child_start + 1), || None); - } - } + None => { + // Empty header/footer: consider the header/footer to be + // at the next empty row after the latest auto index. + local_auto_index = first_available_row * columns; + let group_start = first_available_row; + let group_end = group_start + 1; - if is_header { - if child_start != 0 { - bail!( - child_span, - "header must start at the first row"; - hint: "remove any rows before the header" - ); - } + if resolved_cells.len() <= columns * group_start { + // Ensure the automatically chosen row actually exists. + resolved_cells.resize_with(columns * (group_start + 1), || None); + } - header = Some(Header { - // Later on, we have to correct this number in case there - // is gutter. But only once all cells have been analyzed - // and the header has fully expanded in the fixup loop - // below. - end: child_end, - }); - } + // Even though this header or footer is fully empty, we add one + // default cell to maintain the invariant that each header and + // footer has at least one 'Some(...)' cell at its first row + // and at least one at its last row (here they are the same + // row, of course). This invariant is important to ensure + // 'find_next_empty_row' will skip through any existing headers + // and footers without having to loop through them each time. + // Cells themselves, unfortunately, still have to. + assert!(resolved_cells[local_auto_index].is_none()); + resolved_cells[local_auto_index] = + Some(Entry::Cell(self.resolve_cell( + T::default(), + 0, + first_available_row, + 1, + Span::detached(), + )?)); + + group_start..group_end + } + }; - if is_footer { - // Only check if the footer is at the end later, once we know - // the final amount of rows. - footer = Some(( - child_end, - child_span, - Footer { - // Later on, we have to correct this number in case there - // is gutter, but only once all cells have been analyzed - // and the header's and footer's exact boundaries are - // known. That is because the gutter row immediately - // before the footer might not be included as part of - // the footer if it is contained within the header. - start: child_start, - }, - )); + let top_hlines_end = row_group.top_hlines_end.unwrap_or(pending_hlines.len()); + for (_, top_hline, has_auto_y) in pending_hlines + .get_mut(row_group.top_hlines_start..top_hlines_end) + .unwrap_or(&mut []) + { + if *has_auto_y { + // Move this hline to the top of the child, as it was + // placed before the first automatically positioned cell + // and had an automatic index. + top_hline.index = group_range.start; + } } - if is_header || is_footer { - let amount_hlines = pending_hlines.len(); - for (_, top_hline, has_auto_y) in pending_hlines - .get_mut( - first_index_of_top_hlines - ..first_index_of_non_top_hlines.min(amount_hlines), - ) - .unwrap_or(&mut []) - { - if *has_auto_y { - // Move this hline to the top of the child, as it was - // placed before the first automatically positioned cell - // and had an automatic index. - top_hline.index = child_start; + match row_group.kind { + RowGroupKind::Header => { + if group_range.start != 0 { + bail!( + row_group.span, + "header must start at the first row"; + hint: "remove any rows before the header" + ); } + + *header = Some(Header { + // Later on, we have to correct this number in case there + // is gutter. But only once all cells have been analyzed + // and the header has fully expanded in the fixup loop + // below. + end: group_range.end, + }); } - // Next automatically positioned cell goes under this header. - // FIXME: Consider only doing this if the header has any fully - // automatically positioned cells. Otherwise, - // `resolve_cell_position` should be smart enough to skip - // upcoming headers. - // Additionally, consider that cells with just an 'x' override - // could end up going too far back and making previous - // non-header rows into header rows (maybe they should be - // placed at the first row that is fully empty or something). - // Nothing we can do when both 'x' and 'y' were overridden, of - // course. - // None of the above are concerns for now, as headers must - // start at the first row. - auto_index = auto_index.max(c * child_end); + RowGroupKind::Footer => { + // Only check if the footer is at the end later, once we know + // the final amount of rows. + *footer = Some(( + group_range.end, + row_group.span, + Footer { + // Later on, we have to correct this number in case there + // is gutter, but only once all cells have been analyzed + // and the header's and footer's exact boundaries are + // known. That is because the gutter row immediately + // before the footer might not be included as part of + // the footer if it is contained within the header. + start: group_range.start, + }, + )); + } } + } else { + // The child was a single cell outside headers or footers. + // Therefore, 'local_auto_index' for this table child was + // simply an alias for 'auto_index', so we update it as needed. + *auto_index = local_auto_index; } - // If the user specified cells occupying less rows than the given rows, - // we shall expand the grid so that it has at least the given amount of - // rows. - let Some(expected_total_cells) = c.checked_mul(tracks.y.len()) else { - bail!(span, "too many rows were specified"); + Ok(()) + } + + /// Fixup phase (final step in cell grid generation): + /// + /// 1. Replace absent entries by resolved empty cells, producing a vector + /// of `Entry` from `Option`. + /// + /// 2. Add enough empty cells to the end of the grid such that it has at + /// least the given amount of rows (must be a multiple of `columns`, + /// and all rows before the last cell must have cells, empty or not, + /// even if the user didn't specify those cells). + /// + /// That is necessary, for example, to ensure even unspecified cells + /// can be affected by show rules and grid-wide styling. + fn fixup_cells( + &mut self, + resolved_cells: Vec>>, + columns: usize, + ) -> SourceResult>> + where + T: ResolvableCell + Default, + { + let Some(expected_total_cells) = columns.checked_mul(self.tracks.y.len()) else { + bail!(self.span, "too many rows were specified"); }; let missing_cells = expected_total_cells.saturating_sub(resolved_cells.len()); - // Fixup phase (final step in cell grid generation): - // 1. Replace absent entries by resolved empty cells, and produce a - // vector of 'Entry' from 'Option'. - // 2. Add enough empty cells to the end of the grid such that it has at - // least the given amount of rows. - // 3. If any cells were added to the header's rows after the header's - // creation, ensure the header expands enough to accommodate them - // across all of their spanned rows. Same for the footer. - // 4. If any cells before the footer try to span it, error. - let resolved_cells = resolved_cells + resolved_cells .into_iter() .chain(std::iter::repeat_with(|| None).take(missing_cells)) .enumerate() .map(|(i, cell)| { if let Some(cell) = cell { - if let Some(parent_cell) = cell.as_cell() { - if let Some(header) = &mut header - { - let y = i / c; - if y < header.end { - // Ensure the header expands enough such that - // all cells inside it, even those added later, - // are fully contained within the header. - // FIXME: check if start < y < end when start can - // be != 0. - // FIXME: when start can be != 0, decide what - // happens when a cell after the header placed - // above it tries to span the header (either - // error or expand upwards). - header.end = header.end.max(y + parent_cell.rowspan.get()); - } - } - - if let Some((end, footer_span, footer)) = &mut footer { - let x = i % c; - let y = i / c; - let cell_end = y + parent_cell.rowspan.get(); - if y < footer.start && cell_end > footer.start { - // Don't allow a cell before the footer to span - // it. Surely, we could move the footer to - // start at where this cell starts, so this is - // more of a design choice, as it's unlikely - // for the user to intentionally include a cell - // before the footer spanning it but not - // being repeated with it. - bail!( - *footer_span, - "footer would conflict with a cell placed before it at column {x} row {y}"; - hint: "try reducing that cell's rowspan or moving the footer" - ); - } - if y >= footer.start && y < *end { - // Expand the footer to include all rows - // spanned by this cell, as it is inside the - // footer. - *end = (*end).max(cell_end); - } - } - } - Ok(cell) } else { - let x = i % c; - let y = i / c; + let x = i % columns; + let y = i / columns; - // Ensure all absent entries are affected by show rules and - // grid styling by turning them into resolved empty cells. - let new_cell = T::default().resolve_cell( + Ok(Entry::Cell(self.resolve_cell( + T::default(), x, y, - &fill.resolve(engine, styles, x, y)?, - align.resolve(engine, styles, x, y)?, - inset.resolve(engine, styles, x, y)?, - stroke.resolve(engine, styles, x, y)?, - resolve_breakable(y, 1), - locator.next(&()), - styles, - ); - Ok(Entry::Cell(new_cell)) + 1, + Span::detached(), + )?)) } }) - .collect::>>()?; + .collect::>>() + } - // Populate the final lists of lines. - // For each line type (horizontal or vertical), we keep a vector for - // every group of lines with the same index. - let mut vlines: Vec> = vec![]; + /// Takes the list of pending lines and evaluates a final list of hlines + /// and vlines (in that order in the returned tuple), detecting invalid + /// line positions in the process. + /// + /// For each line type (horizontal and vertical respectively), returns a + /// vector containing one inner vector for every group of lines with the + /// same index. + /// + /// For example, an hline above the second row (y = 1) is inside the inner + /// vector at position 1 of the first vector (hlines) returned by this + /// function. + #[allow(clippy::type_complexity)] + fn collect_lines( + &self, + pending_hlines: Vec<(Span, Line, bool)>, + pending_vlines: Vec<(Span, Line)>, + has_gutter: bool, + columns: usize, + row_amount: usize, + ) -> SourceResult<(Vec>, Vec>)> { let mut hlines: Vec> = vec![]; - let row_amount = resolved_cells.len().div_ceil(c); + let mut vlines: Vec> = vec![]; for (line_span, line, _) in pending_hlines { let y = line.index; @@ -1300,33 +1639,34 @@ impl<'a> CellGrid<'a> { for (line_span, line) in pending_vlines { let x = line.index; - if x > c { + if x > columns { bail!(line_span, "cannot place vertical line at invalid column {x}"); } - if x == c && line.position == LinePosition::After { + if x == columns && line.position == LinePosition::After { bail!( line_span, - "cannot place vertical line at the 'end' position of the end border (x = {c})"; + "cannot place vertical line at the 'end' position of the end border (x = {columns})"; hint: "set the line's position to 'start' or place it at a smaller 'x' index" ); } - let line = - if line.position == LinePosition::After && (!has_gutter || x + 1 == c) { - // Just place the line before the next column if - // there's no gutter and the line should be placed - // after the one with given index. - // - // Note that placing after the last column is also the - // same as just placing on the grid's end border, even - // with gutter. - Line { - index: x + 1, - position: LinePosition::Before, - ..line - } - } else { - line - }; + let line = if line.position == LinePosition::After + && (!has_gutter || x + 1 == columns) + { + // Just place the line before the next column if + // there's no gutter and the line should be placed + // after the one with given index. + // + // Note that placing after the last column is also the + // same as just placing on the grid's end border, even + // with gutter. + Line { + index: x + 1, + position: LinePosition::Before, + ..line + } + } else { + line + }; let x = line.index; if vlines.len() <= x { @@ -1335,6 +1675,26 @@ impl<'a> CellGrid<'a> { vlines[x].push(line); } + Ok((hlines, vlines)) + } + + /// Generate the final headers and footers: + /// + /// 1. Convert gutter-ignorant to gutter-aware indices if necessary; + /// 2. Expand the header downwards (or footer upwards) to also include + /// an adjacent gutter row to be repeated alongside that header or + /// footer, if there is gutter; + /// 3. Wrap headers and footers in the correct [`Repeatable`] variant. + #[allow(clippy::type_complexity)] + fn finalize_headers_and_footers( + &self, + has_gutter: bool, + header: Option
, + repeat_header: bool, + footer: Option<(usize, Span, Footer)>, + repeat_footer: bool, + row_amount: usize, + ) -> SourceResult<(Option>, Option>)> { let header = header .map(|mut header| { // Repeat the gutter below a header (hence why we don't @@ -1392,10 +1752,6 @@ impl<'a> CellGrid<'a> { } } - if header_end.is_some_and(|header_end| header_end > footer.start) { - bail!(footer_span, "header and footer must not have common rows"); - } - Ok(footer) }) .transpose()? @@ -1407,212 +1763,207 @@ impl<'a> CellGrid<'a> { } }); - Ok(Self::new_internal( - tracks, - gutter, - vlines, - hlines, - header, - footer, - resolved_cells, - )) + Ok((header, footer)) } - /// Generates the cell grid, given the tracks and resolved entries. - pub fn new_internal( - tracks: Axes<&[Sizing]>, - gutter: Axes<&[Sizing]>, - vlines: Vec>, - hlines: Vec>, - header: Option>, - footer: Option>, - entries: Vec>, - ) -> Self { - let mut cols = vec![]; - let mut rows = vec![]; - - // Number of content columns: Always at least one. - let c = tracks.x.len().max(1); - - // Number of content rows: At least as many as given, but also at least - // as many as needed to place each item. - let r = { - let len = entries.len(); - let given = tracks.y.len(); - let needed = len / c + (len % c).clamp(0, 1); - given.max(needed) - }; - - let has_gutter = gutter.any(|tracks| !tracks.is_empty()); - let auto = Sizing::Auto; - let zero = Sizing::Rel(Rel::zero()); - let get_or = |tracks: &[_], idx, default| { - tracks.get(idx).or(tracks.last()).copied().unwrap_or(default) + /// Resolves the cell's fields based on grid-wide properties. + fn resolve_cell( + &mut self, + cell: T, + x: usize, + y: usize, + rowspan: usize, + cell_span: Span, + ) -> SourceResult> + where + T: ResolvableCell + Default, + { + // Resolve the breakability of a cell. Cells that span at least one + // auto-sized row or gutter are considered breakable. + let breakable = { + let auto = Sizing::Auto; + let zero = Sizing::Rel(Rel::zero()); + self.tracks + .y + .iter() + .chain(std::iter::repeat(self.tracks.y.last().unwrap_or(&auto))) + .skip(y) + .take(rowspan) + .any(|row| row == &Sizing::Auto) + || self + .gutter + .y + .iter() + .chain(std::iter::repeat(self.gutter.y.last().unwrap_or(&zero))) + .skip(y) + .take(rowspan - 1) + .any(|row_gutter| row_gutter == &Sizing::Auto) }; - // Collect content and gutter columns. - for x in 0..c { - cols.push(get_or(tracks.x, x, auto)); - if has_gutter { - cols.push(get_or(gutter.x, x, zero)); - } - } - - // Collect content and gutter rows. - for y in 0..r { - rows.push(get_or(tracks.y, y, auto)); - if has_gutter { - rows.push(get_or(gutter.y, y, zero)); - } - } - - // Remove superfluous gutter tracks. - if has_gutter { - cols.pop(); - rows.pop(); - } - - Self { - cols, - rows, - entries, - vlines, - hlines, - header, - footer, - has_gutter, - } + Ok(cell.resolve_cell( + x, + y, + &self.fill.resolve(self.engine, self.styles, x, y)?, + self.align.resolve(self.engine, self.styles, x, y)?, + self.inset.resolve(self.engine, self.styles, x, y)?, + self.stroke.resolve(self.engine, self.styles, x, y)?, + breakable, + self.locator.next(&cell_span), + self.styles, + )) } +} - /// Get the grid entry in column `x` and row `y`. - /// - /// Returns `None` if it's a gutter cell. - #[track_caller] - pub fn entry(&self, x: usize, y: usize) -> Option<&Entry<'a>> { - assert!(x < self.cols.len()); - assert!(y < self.rows.len()); +/// Given the existing range of a row group (header or footer), tries to expand +/// it to fit the new cell placed inside it. If the newly-expanded row group +/// would conflict with existing cells or other row groups, an error is +/// returned. Otherwise, the new `start..end` range of rows in the row group is +/// returned. +fn expand_row_group( + resolved_cells: &[Option>], + group_range: Option<&Range>, + group_kind: RowGroupKind, + first_available_row: usize, + cell_y: usize, + rowspan: usize, + columns: usize, +) -> HintedStrResult> { + // Ensure each cell in a header or footer is fully contained within it by + // expanding the header or footer towards this new cell. + let (new_group_start, new_group_end) = group_range + .map_or((cell_y, cell_y + rowspan), |r| { + (r.start.min(cell_y), r.end.max(cell_y + rowspan)) + }); - if self.has_gutter { - // Even columns and rows are children, odd ones are gutter. - if x % 2 == 0 && y % 2 == 0 { - let c = 1 + self.cols.len() / 2; - self.entries.get((y / 2) * c + x / 2) - } else { - None - } - } else { - let c = self.cols.len(); - self.entries.get(y * c + x) - } + // This check might be unnecessary with the loop below, but let's keep it + // here for full correctness. + // + // Quickly detect the case: + // y = 0 => occupied + // y = 1 => empty + // y = 2 => header + // and header tries to expand to y = 0 - invalid, as + // 'y = 1' is the earliest row it can occupy. + if new_group_start < first_available_row { + bail!( + "cell would cause {} to expand to non-empty row {}", + group_kind.name(), + first_available_row.saturating_sub(1); + hint: "try moving its cells to available rows" + ); } - /// Get the content of the cell in column `x` and row `y`. - /// - /// Returns `None` if it's a gutter cell or merged position. - #[track_caller] - pub fn cell(&self, x: usize, y: usize) -> Option<&Cell<'a>> { - self.entry(x, y).and_then(Entry::as_cell) - } + let new_rows = + group_range.map_or((new_group_start..new_group_end).chain(0..0), |r| { + // NOTE: 'r.end' is one row AFTER the row group's last row, so it + // makes sense to check it if 'new_group_end > r.end', that is, if + // the row group is going to expand. It is NOT a duplicate check, + // as we hadn't checked it before (in a previous run, it was + // 'new_group_end' at the exclusive end of the range)! + // + // NOTE: To keep types the same, we have to always return + // '(range).chain(range)', which justifies chaining an empty + // range above. + (new_group_start..r.start).chain(r.end..new_group_end) + }); - /// Returns the position of the parent cell of the grid entry at the given - /// position. It is guaranteed to have a non-gutter, non-merged cell at - /// the returned position, due to how the grid is built. - /// - If the entry at the given position is a cell, returns the given - /// position. - /// - If it is a merged cell, returns the parent cell's position. - /// - If it is a gutter cell, returns None. - #[track_caller] - pub fn parent_cell_position(&self, x: usize, y: usize) -> Option> { - self.entry(x, y).map(|entry| match entry { - Entry::Cell(_) => Axes::new(x, y), - Entry::Merged { parent } => { - let c = self.non_gutter_column_count(); - let factor = if self.has_gutter { 2 } else { 1 }; - Axes::new(factor * (*parent % c), factor * (*parent / c)) + // The check above isn't enough, however, even when the header is expanding + // upwards, as it might expand upwards towards an occupied row after the + // first empty row, e.g. + // + // y = 0 => occupied + // y = 1 => empty (first_available_row = 1) + // y = 2 => occupied + // y = 3 => header + // + // Here, we should bail if the header tries to expand upwards, regardless + // of the fact that the conflicting row (y = 2) comes after the first + // available row. + // + // Note that expanding upwards is only possible when row-positioned cells + // are specified, in one of the following cases: + // + // 1. We place e.g. 'table.cell(y: 3)' followed by 'table.cell(y: 2)' + // (earlier row => upwards); + // + // 2. We place e.g. 'table.cell(y: 3)' followed by '[a]' (auto-pos cell + // favors 'first_available_row', so the header tries to expand upwards to + // place the cell at 'y = 1' and conflicts at 'y = 2') or + // 'table.cell(x: 1)' (same deal). + // + // Of course, we also need to check for downward expansion as usual as + // there could be a non-empty row below the header, but the upward case is + // highlighted as it was checked separately before (and also to explain + // what kind of situation we are preventing with this check). + // + // Note that simply checking for non-empty rows like below not only + // prevents conflicts with top-level cells (outside of headers and + // footers), but also prevents conflicts with other headers or footers, + // since we have an invariant that even empty headers and footers must + // contain at least one 'Some(...)' position in 'resolved_cells'. More + // precisely, each header and footer has at least one 'Some(...)' cell at + // 'group_range.start' and at 'group_range.end - 1' - non-empty headers and + // footers don't span any unnecessary rows. Therefore, we don't have to + // loop over headers and footers, only check if the new rows are empty. + for new_y in new_rows { + if let Some(new_row @ [_non_empty, ..]) = resolved_cells + .get(new_y * columns..) + .map(|cells| &cells[..columns.min(cells.len())]) + { + if new_row.iter().any(Option::is_some) { + bail!( + "cell would cause {} to expand to non-empty row {new_y}", + group_kind.name(); + hint: "try moving its cells to available rows", + ) } - }) - } - - /// Returns the position of the actual parent cell of a merged position, - /// even if the given position is gutter, in which case we return the - /// parent of the nearest adjacent content cell which could possibly span - /// the given gutter position. If the given position is not a gutter cell, - /// then this function will return the same as `parent_cell_position` would. - /// If the given position is a gutter cell, but no cell spans it, returns - /// `None`. - /// - /// This is useful for lines. A line needs to check if a cell next to it - /// has a stroke override - even at a gutter position there could be a - /// stroke override, since a cell could be merged with two cells at both - /// ends of the gutter cell (e.g. to its left and to its right), and thus - /// that cell would impose a stroke under the gutter. This function allows - /// getting the position of that cell (which spans the given gutter - /// position, if it is gutter), if it exists; otherwise returns None (it's - /// gutter and no cell spans it). - #[track_caller] - pub fn effective_parent_cell_position( - &self, - x: usize, - y: usize, - ) -> Option> { - if self.has_gutter { - // If (x, y) is a gutter cell, we skip it (skip a gutter column and - // row) to the nearest adjacent content cell, in the direction - // which merged cells grow toward (increasing x and increasing y), - // such that we can verify if that adjacent cell is merged with the - // gutter cell by checking if its parent would come before (x, y). - // Otherwise, no cell is merged with this gutter cell, and we - // return None. - self.parent_cell_position(x + x % 2, y + y % 2) - .filter(|&parent| parent.x <= x && parent.y <= y) } else { - self.parent_cell_position(x, y) + // Received 'None' or an empty slice, so we are expanding the + // header or footer into new rows, which is always valid and cannot + // conflict with existing cells. (Note that we only resize + // 'resolved_cells' after this function is called, so, if this + // header or footer is at the bottom of the table so far, this loop + // will end quite early, regardless of where this cell was placed + // or of its rowspan value.) + break; } } - /// Checks if the track with the given index is gutter. - /// Does not check if the index is a valid track. - #[inline] - pub fn is_gutter_track(&self, index: usize) -> bool { - self.has_gutter && index % 2 == 1 - } + Ok(new_group_start..new_group_end) +} - /// Returns the effective colspan of a cell, considering the gutters it - /// might span if the grid has gutters. - #[inline] - pub fn effective_colspan_of_cell(&self, cell: &Cell) -> usize { - if self.has_gutter { - 2 * cell.colspan.get() - 1 - } else { - cell.colspan.get() +/// Check if a cell's fixed row would conflict with a header or footer. +fn check_for_conflicting_cell_row( + header: Option<&Header>, + footer: Option<&(usize, Span, Footer)>, + cell_y: usize, + rowspan: usize, +) -> HintedStrResult<()> { + if let Some(header) = header { + // TODO: check start (right now zero, always satisfied) + if cell_y < header.end { + bail!( + "cell would conflict with header spanning the same position"; + hint: "try moving the cell or the header" + ); } } - /// Returns the effective rowspan of a cell, considering the gutters it - /// might span if the grid has gutters. - #[inline] - pub fn effective_rowspan_of_cell(&self, cell: &Cell) -> usize { - if self.has_gutter { - 2 * cell.rowspan.get() - 1 - } else { - cell.rowspan.get() + if let Some((footer_end, _, footer)) = footer { + // NOTE: y + rowspan >, not >=, footer.start, to check if the rowspan + // enters the footer. For example, consider a rowspan of 1: if + // `y + 1 = footer.start` holds, that means `y < footer.start`, and it + // only occupies one row (`y`), so the cell is actually not in + // conflict. + if cell_y < *footer_end && cell_y + rowspan > footer.start { + bail!( + "cell would conflict with footer spanning the same position"; + hint: "try reducing the cell's rowspan or moving the footer" + ); } } - #[inline] - pub fn non_gutter_column_count(&self) -> usize { - if self.has_gutter { - // Calculation: With gutters, we have - // 'cols = 2 * (non-gutter cols) - 1', since there is a gutter - // column between each regular column. Therefore, - // 'floor(cols / 2)' will be equal to - // 'floor(non-gutter cols - 1/2) = non-gutter-cols - 1', - // so 'non-gutter cols = 1 + floor(cols / 2)'. - 1 + self.cols.len() / 2 - } else { - self.cols.len() - } - } + Ok(()) } /// Given a cell's requested x and y, the vector with the resolved cell @@ -1620,20 +1971,23 @@ impl<'a> CellGrid<'a> { /// `(auto, auto)` cell) and the amount of columns in the grid, returns the /// final index of this cell in the vector of resolved cells. /// -/// The `start_new_row` parameter is used to ensure that, if this cell is -/// fully automatically positioned, it should start a new, empty row. This is -/// useful for headers and footers, which must start at their own rows, without -/// interference from previous cells. +/// The `first_available_row` parameter is used by headers and footers to +/// indicate the first empty row available. Any rows before those should +/// not be picked by cells with `auto` row positioning, since headers and +/// footers occupy entire rows, and may not conflict with cells outside them. #[allow(clippy::too_many_arguments)] fn resolve_cell_position( cell_x: Smart, cell_y: Smart, colspan: usize, rowspan: usize, + header: Option<&Header>, + footer: Option<&(usize, Span, Footer)>, resolved_cells: &[Option], auto_index: &mut usize, - start_new_row: &mut bool, + first_available_row: usize, columns: usize, + in_row_group: bool, ) -> HintedStrResult { // Translates a (x, y) position to the equivalent index in the final cell vector. // Errors if the position would be too large. @@ -1648,29 +2002,24 @@ fn resolve_cell_position( (Smart::Auto, Smart::Auto) => { // Let's find the first available position starting from the // automatic position counter, searching in row-major order. - let mut resolved_index = *auto_index; - if *start_new_row { - resolved_index = - find_next_empty_row(resolved_cells, resolved_index, columns); - - // Next cell won't have to start a new row if we just did that, - // in principle. - *start_new_row = false; - } else { - while let Some(Some(_)) = resolved_cells.get(resolved_index) { - // Skip any non-absent cell positions (`Some(None)`) to - // determine where this cell will be placed. An out of - // bounds position (thus `None`) is also a valid new - // position (only requires expanding the vector). - resolved_index += 1; - } - } + // Note that the counter ignores any cells with fixed positions, + // but automatically-positioned cells will avoid conflicts by + // simply skipping existing cells, headers and footers. + let resolved_index = find_next_available_position::( + header, + footer, + resolved_cells, + columns, + *auto_index, + )?; // Ensure the next cell with automatic position will be // placed after this one (maybe not immediately after). // // The calculation below also affects the position of the upcoming - // automatically-positioned lines. + // automatically-positioned lines, as they are placed below + // (horizontal lines) or to the right (vertical lines) of the cell + // that would be placed at 'auto_index'. *auto_index = if colspan == columns { // The cell occupies all columns, so no cells can be placed // after it until all of its rows have been spanned. @@ -1692,24 +2041,46 @@ fn resolve_cell_position( } if let Smart::Custom(cell_y) = cell_y { // Cell has chosen its exact position. + // + // Ensure it doesn't conflict with an existing header or + // footer (but only if it isn't already in one, otherwise there + // will already be a separate check). + if !in_row_group { + check_for_conflicting_cell_row(header, footer, cell_y, rowspan)?; + } + cell_index(cell_x, cell_y) } else { // Cell has only chosen its column. // Let's find the first row which has that column available. - let mut resolved_y = 0; - while let Some(Some(_)) = - resolved_cells.get(cell_index(cell_x, resolved_y)?) - { - // Try each row until either we reach an absent position - // (`Some(None)`) or an out of bounds position (`None`), - // in which case we'd create a new row to place this cell in. - resolved_y += 1; - } - cell_index(cell_x, resolved_y) + // If in a header or footer, start searching by the first empty + // row / the header or footer's first row (specified through + // 'first_available_row'). Otherwise, start searching at the + // first row. + let initial_index = cell_index(cell_x, first_available_row)?; + + // Try each row until either we reach an absent position at the + // requested column ('Some(None)') or an out of bounds position + // ('None'), in which case we'd create a new row to place this + // cell in. + find_next_available_position::( + header, + footer, + resolved_cells, + columns, + initial_index, + ) } } // Cell has only chosen its row, not its column. (Smart::Auto, Smart::Custom(cell_y)) => { + // Ensure it doesn't conflict with an existing header or + // footer (but only if it isn't already in one, otherwise there + // will already be a separate check). + if !in_row_group { + check_for_conflicting_cell_row(header, footer, cell_y, rowspan)?; + } + // Let's find the first column which has that row available. let first_row_pos = cell_index(0, cell_y)?; let last_row_pos = first_row_pos @@ -1736,14 +2107,73 @@ fn resolve_cell_position( } } -/// Computes the index of the first cell in the next empty row in the grid, -/// starting with the given initial index. +/// Finds the first available position after the initial index in the resolved +/// grid of cells. Skips any non-absent positions (positions which already +/// have cells specified by the user) as well as any headers and footers. +#[inline] +fn find_next_available_position( + header: Option<&Header>, + footer: Option<&(usize, Span, Footer)>, + resolved_cells: &[Option>], + columns: usize, + initial_index: usize, +) -> HintedStrResult { + let mut resolved_index = initial_index; + + loop { + if let Some(Some(_)) = resolved_cells.get(resolved_index) { + // Skip any non-absent cell positions (`Some(None)`) to + // determine where this cell will be placed. An out of + // bounds position (thus `None`) is also a valid new + // position (only requires expanding the vector). + if SKIP_ROWS { + // Skip one row at a time (cell chose its column, so we don't + // change it). + resolved_index = + resolved_index.checked_add(columns).ok_or_else(|| { + HintedString::from(eco_format!("cell position too large")) + })?; + } else { + // Ensure we don't run unnecessary checks in the hot path + // (for fully automatically-positioned cells). Memory usage + // would become impractically large before this overflows. + resolved_index += 1; + } + } else if let Some(header) = + header.filter(|header| resolved_index < header.end * columns) + { + // Skip header (can't place a cell inside it from outside it). + resolved_index = header.end * columns; + + if SKIP_ROWS { + // Ensure the cell's chosen column is kept after the + // header. + resolved_index += initial_index % columns; + } + } else if let Some((footer_end, _, _)) = footer.filter(|(end, _, footer)| { + resolved_index >= footer.start * columns && resolved_index < *end * columns + }) { + // Skip footer, for the same reason. + resolved_index = *footer_end * columns; + + if SKIP_ROWS { + resolved_index += initial_index % columns; + } + } else { + return Ok(resolved_index); + } + } +} + +/// Computes the `y` of the next available empty row, given the auto index as +/// an initial index for search, since we know that there are no empty rows +/// before automatically-positioned cells, as they are placed sequentially. fn find_next_empty_row( resolved_cells: &[Option], - initial_index: usize, + auto_index: usize, columns: usize, ) -> usize { - let mut resolved_index = initial_index.next_multiple_of(columns); + let mut resolved_index = auto_index.next_multiple_of(columns); while resolved_cells .get(resolved_index..resolved_index + columns) .is_some_and(|row| row.iter().any(Option::is_some)) @@ -1752,7 +2182,7 @@ fn find_next_empty_row( resolved_index += columns; } - resolved_index + resolved_index / columns } /// Fully merged rows under the cell of latest auto index indicate rowspans diff --git a/tests/ref/grid-footer-expand.png b/tests/ref/grid-footer-expand.png deleted file mode 100644 index 6b173b0da98c6b54bcf934846505bd3948b575b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 365 zcmV-z0h0cSP)-VsVN4&Z3(G zVez5-0ZP>;@}K=47>ga(yS*3~AOD};KR^~wSTzt9A9FgC`s2$(`dj?z&82_;dtwKu zlDM8ge~bTa?s@Y6hUd)1|L7gZr_Sg7zv1`4XfvHH{;_bL&;J_<|2J3C+2Z!?uRK8( zw;Z6e#rsR=s&BiKI(5zfr0UGW1JmaERWcwJf2&xkGawfCulrv-AQl%d|35H6y~FFg zQ~7`-u9MrY+#gw8n9Hl54T#0p>VE4Fh{d&4i>CY?2#Y7R4^SMxatNs&n8cO+b^d@@ z{LgKGf_k&Ys)4cio8$ixX7NkY0kU{Q+p*Dcu2G9eEgrRaC|C>t-y~FZIbppJ00000 LNkvXXu0mjfBuBJ8 diff --git a/tests/ref/grid-footer-moved-to-bottom-of-rowspans.png b/tests/ref/grid-footer-moved-to-bottom-of-rowspans.png new file mode 100644 index 0000000000000000000000000000000000000000..d8a9c74f82b100a9ec4147e1b738f2517ac8bf6f GIT binary patch literal 1182 zcmV;P1Y!G$P)fzPkT{d;i?p|72VLw6On+h1uEJjEs!N#>W4Uipa>wZ*OlwK|#aA z!`9Z;!otG%`1q=-s%2$m=H}+VzrTlvhyRw4|6W!9dUF4Te*b%OkdTmcbaX;OLhS79 z^78W3)YQ__(tdt^`}_NPdV22e?rCXh|7BeI`ue4%rSZ$qN1Yz zfO!9adH-r*m6es0l$0+oFRQDo)6>)cii7`|lmCr}uCA{CkBFR{od1l5|8r~qT2O?9 zgr=sZ|42RX@bGJEYyY8}|4BdJ-`{t4cfi2Fd3kyNgMI&IUH?={|8Z%jr>FmQZU1az z|Av46i-iA|lK**f|5Hi-j)=RvyMls(xw*OB-QBRTu!e?)H8nNq>FNK9g8!9||9o}- zSWZVrM@vgfWMpL3)zyH2fYH&>OiWB}Zf>5Qp2^9{wzjrPN=nbq&&tZm|B8hFuBoA+ zq5p7b|A>PBtf>EQXScVv|4Baon3DgTm$gwv@;o)6fU8AF;nVFgN^z=?nPDx2g|6*AGfqJj6uP7)e=;-K1 zMn;#Hm;ZKd|8s2ra%yvPbB~XY|A>MAVOalucmIQY|B{T?*Vo+K++bi}*x1-|a&q?e z_WyKk{r&y3v$Ox9od24Y|DT%wotOWWj{l~i{{H^|Ze{<{&;LX@|4>H%@bCXdJpcLm z|DBlsqMaL+IwSx90uxC@K~#9!?c3E;8&MR;@r8Puq);4Mpil}_pziMO?#12R-QC@t zAVGt}f5yNI_wv9D1G_(1&iC!?%V)B8?wRBv0!2~O#*1&$Vf+4#8{48qgX3a#*}i<` z#)K%*U>#j<+_jq<=NAYjc)P;RP62@s_`KTR4+7g-$M`~E`#=AEAh6xve|?AOIuX3N z4J1voy?VxtIoYDYDakWzFIC)Vkws@L7g(VH!G?Mu_^_U-0|wTwsjtI)VxgfG1X~*; zV5Nhc~o%VXTw(IpyOUA33(%u#M^@1!#(nBaB7gBnF91Xe~y z1Vdn>n2@05@s8-5i-*k5G6&;UB;Ev02=g3jXkB&!c6S58F0M-2T(X5nM(5bZ$GCBLNHn;4YL;zk w)i^+BOfbO&Z@tacwD#s|r2j`z6tz`a1W(L`M#F}Q_5c6?07*qoM6N<$f_BrFEdT%j literal 0 HcmV?d00001 diff --git a/tests/ref/grid-footer-top-hlines-with-only-row-pos-cell.png b/tests/ref/grid-footer-top-hlines-with-only-row-pos-cell.png new file mode 100644 index 0000000000000000000000000000000000000000..f78e80c170ee4577cef8cdb8b8c683cc838d475c GIT binary patch literal 385 zcmV-{0e=38P)|If^Ty6nH)^?$kS0Cd?vdaS=- zTmPV%|3NnYpqsy8TZxH@`T6+8S2zxVg|{I5;?PaBz>0k4J&DIC7BFr|!92m}*HVNQsp1OlV+5N>Z=j9CZ-`7o|dg{!(YQf^WSf%zhI5H{;S zEV~@8%F+mtrUC-@TiByj?{|K}*%n#UDpeE;0`Hfj@JDN`T6CcokdTawjPUUAb8~ZqgoHCQGd(>$K|w)IPEJRGv^a95T$adWp3vUj-u3nM$H&Kq zhlkU_z?eBR>DbrHZD-O~TbO4HfoN^kh`B4l~Qd>9^|gkpUQjJrAySPn541gB?#*w}>O8=DVb-?$0aZtvWL z3;*Qk#ET1)>Hb_l-#*JAW_*8c-WSVjVBGJY$Fhi7&zsSnSXl*I6p__Uq6Y~+TW-s5 R_4)t+002ovPDHLkV1hjw8XEur literal 0 HcmV?d00001 diff --git a/tests/ref/grid-header-cell-with-x.png b/tests/ref/grid-header-cell-with-x.png new file mode 100644 index 0000000000000000000000000000000000000000..659826250b2c258fd7bf7dc9ab843fb84954035b GIT binary patch literal 419 zcmV;U0bKrxP)+Gt{NLp`Rf$ZKR?%;Hgt zsc7-JQv+o2j9d`;Z$I5FK9LV3zBd`t-(ruc&42#?x1qnq8UHIb4~WJ2{}=TRh{ZYo zv+n%=u0?l?Z?sN5^#A|N+})!k*QmwRv)GkO1OqS$Bl9?NX<~78>1zxid;^&mI&Ur?ZyB9PeTTvKAztH|7-Eb0kC+& zs(&?~@BX8+#mAfurT$nH(Be;TAAfpt>EHjC*Kgb%ExCrF#aSUMFaY0dWS(t5O)L)6 z3C95J0mwXs+R->3wRqHG3N8LPd&9?edp z7E@rcznl#QV6{NziIvgB;^S)$V}O|lka^3ljn2Z1T0Cm;sKtZ9VgN}LaL>$~n<)SQ N002ovPDHLkV1mqg%XI(% literal 0 HcmV?d00001 diff --git a/tests/ref/grid-header-expand.png b/tests/ref/grid-header-expand.png deleted file mode 100644 index d0fbd72ed23d726d44eef215fabe6c874199d52e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2005 zcmV;`2P*i9P)?)!Uu-tr7rRN#HrqX7>#EyhYZt|-%C_FFS{v(y1&gGJXKhswYEe#+3JQXv zj3^A3TwVkL5flLx5G?n|DJTPS4>Jrf!yMn$kkkpmMqq@sneUrPCjaDfIu&*2B-Z~XrT zcV0qEwl@AMbm?ABDGcp zc9oL|v67m(hE5Cl;pm89?~AAg!&XJOo|P;DLDV6UY(5dkF@FT?%x_&rvnjJfH3%sG z8v6`pQfWOR*x?img6~+|;OH62YWgPvvc*IlXMNoO{5gtQiVnwbFy$$njex4JMg*5k zkBxl>_ikC4xz96qS&pSF_FqkGvr8klYGW`kk1cjol>^vN{M@}E6ZY>Cs+K} zziGmQn2)c(Di zb(^@QPONzO15!J0oR%y0(7ji9GPkdZ(eL-IV+F7Kz+AuIHjeYZQ|fL2wEGenl;Y*W z>gfP*>J)>zT*oK7$N}0^s1#}w1%R>*x{yLAAcsf~<*4EK z-~ba6-9zo4!UrPlk{YLa>;_z~t?K?yQpw~vB_u^`&25}|!hNC8>Jl!=mW|(UxHW2c zHVBxZWkIDVsM4IS53tM!u<@4SoQeRTvEAJn%W_P~aFWc6+?ILXhta~izvvJ!sTcDi zQBc7OwC`hC3n2CT^k+Es)J^-RUkk`|%v%W<0LJ`8_@DB8c2%#@I_~aYcIlj-V$OMh z?a~eXU;1KlSq0|lo@*b?Noo4CEO@Q@!}Dp)X&bVWuZ|K{-)t|22kHI&nVCx6ogyA* zWdqzz?v!QTq_hg?%;?K%&MK-`u@^YtB|T+3bw`f4cb?4xU3#*Y><8VAU#O0(2&Kq{S;DRu-+3(A2)1f|U`U zm;1(%co6$+-s{C+u=Xt-tjWRuWYt$(I;^c*?RDW>z@!15z|nTV6Zne9Q&@oEVAgsU zmtQvTSTD&~qC5F*NqEaP*_3;?WgfN_w|iz}bxbL|)h%=`DPLN=z_I5|sR3bC;&rHr zmXLa(E#X3I{AC4o5fzYeQA<(x{u8;pShxHcvU0Z( z!FYz^sRICaU?8n046b_`wNZ%W3`#q7WN?s(U#cRp%A( z#xUV5rA6MgLj;QWSeInSIVViB_bO(F#pCAQnNO?^}@|K?c9TRFpmNslgy(%u#p-MlAEN%`Vz4!4C_ zH$|DKW6r2kh55m!yM!W{t>Y$dKp_lMUKidC5jK~eQHSP(EI77bYTLL!@|9EHM(s1x1oz|53eV8^u z*^;P_%A$RltyY;~d!-uZ5I*d!x6qo=`;UQ3gTcDDLBKm&Ebl_7n@&TVFJ@}B8Lb=F z;g*7c$6vHhhmRC*8}?BP^7H@JY<-ReeNZ<|{-`I!oz>-;lUn^T@7^2F`^uWd%=$^T zc;M1-aD!Ktu)aSms1D$oTe{lUNA}=w-pPirh^#K}(_+u81h0qEptGF=m&WBX!oNoN zaU1~LPaPfHC*v2(xz4i=0Ct+41D&^L$B$>g%wL3isUB( z3kxg~Hpd0pN+b6A@2n%;&!@h1ZL|&G5dPq^l&yiteU7AOtVbFU@FpkSC=YU)C)@aw nc)2#n)9cHJmG7Kw8*Ka!kjmohl`Q2d00000NkvXXu0mjfvAN$l diff --git a/tests/ref/issue-5359-column-override-stays-inside-footer.png b/tests/ref/issue-5359-column-override-stays-inside-footer.png new file mode 100644 index 0000000000000000000000000000000000000000..8339a4090d6cc71b8eff6890a9f9d37453dd5fdc GIT binary patch literal 674 zcmV;T0$u%yP)JGgviH_kPLjn0MN)2TI4$-6e70 z>7#r6!I#YdY!&|F=x#<{5{GBKPh8?RU|WOV=CnXL)@Xvk zVZQp^pX3dWNIqccSSXSMeKih+W7cE=8*ZY*>Ye(}qEW)Owbf@^^B(}XZ~#~9&^1wfE1?2<*{-}#yy{#8}*&a7nw zgo;~69{9MzQimqm?C86sH28+;fU>o24v4 zETLyi;WXeMRr2sa6^6nTrZA8D4VwpGPtrTzGXMYp07*qo IM6N<$g0lNP@&Et; literal 0 HcmV?d00001 diff --git a/tests/suite/layout/grid/footers.typ b/tests/suite/layout/grid/footers.typ index edbb36fb1d..f7f1deb0ac 100644 --- a/tests/suite/layout/grid/footers.typ +++ b/tests/suite/layout/grid/footers.typ @@ -83,12 +83,55 @@ grid.cell(y: 1)[c], ) ---- grid-footer-expand --- -// Ensure footer properly expands +--- grid-footer-cell-with-x --- +#grid( + columns: 2, + stroke: black, + inset: 5pt, + grid.cell(x: 1)[a], + // Error: 3-56 footer must end at the last row + grid.footer(grid.cell(x: 0)[b1], grid.cell(x: 0)[b2]), + // This should skip the footer + grid.cell(x: 1)[c] +) + +--- grid-footer-no-expand-with-col-and-row-pos-cell --- +#grid( + columns: 2, + [a], [], + [b], [], + fill: (_, y) => if calc.odd(y) { blue } else { red }, + inset: 5pt, + grid.cell(x: 1, y: 3, rowspan: 4)[b], + grid.cell(y: 2, rowspan: 2)[a], + grid.footer(), + // Error: 3-27 cell would conflict with footer spanning the same position + // Hint: 3-27 try reducing the cell's rowspan or moving the footer + grid.cell(x: 1, y: 7)[d], +) + +--- grid-footer-no-expand-with-row-pos-cell --- +#grid( + columns: 2, + [a], [], + [b], [], + fill: (_, y) => if calc.odd(y) { blue } else { red }, + inset: 5pt, + grid.cell(x: 1, y: 3, rowspan: 4)[b], + grid.cell(y: 2, rowspan: 2)[a], + grid.footer(), + // Error: 3-33 cell would conflict with footer spanning the same position + // Hint: 3-33 try reducing the cell's rowspan or moving the footer + grid.cell(y: 6, rowspan: 2)[d], +) + +--- grid-footer-moved-to-bottom-of-rowspans --- #grid( columns: 2, [a], [], [b], [], + stroke: red, + inset: 5pt, grid.cell(x: 1, y: 3, rowspan: 4)[b], grid.cell(y: 2, rowspan: 2)[a], grid.footer(), @@ -113,13 +156,13 @@ ) --- grid-footer-overlap --- -// Error: 4:3-4:19 footer would conflict with a cell placed before it at column 1 row 0 -// Hint: 4:3-4:19 try reducing that cell's rowspan or moving the footer #grid( columns: 2, grid.header(), - grid.footer([a]), - grid.cell(x: 1, y: 0, rowspan: 2)[a], + grid.footer(grid.cell(y: 2)[a]), + // Error: 3-39 cell would conflict with footer spanning the same position + // Hint: 3-39 try reducing the cell's rowspan or moving the footer + grid.cell(x: 1, y: 1, rowspan: 2)[a], ) --- grid-footer-multiple --- @@ -374,8 +417,8 @@ table.hline(stroke: red), table.vline(stroke: green), [b], + [c] ), - table.cell(x: 1, y: 3)[c] ) --- grid-footer-hline-and-vline-2 --- @@ -385,8 +428,8 @@ #table( columns: 3, inset: 1.5pt, - table.cell(y: 0)[a], table.footer( + table.cell(y: 0)[a], table.hline(stroke: red), table.hline(y: 1, stroke: aqua), table.cell(y: 0)[b], @@ -394,6 +437,38 @@ ) ) +--- grid-footer-top-hlines-with-only-row-pos-cell --- +// Top hlines should attach to the top of the footer. +#set page(margin: 2pt) +#set text(6pt) +#table( + columns: 3, + inset: 2.5pt, + table.footer( + table.hline(stroke: red), + table.vline(stroke: blue), + table.cell(x: 2, y: 2)[a], + table.hline(stroke: 3pt), + table.vline(stroke: 3pt), + ) +) + +--- grid-footer-top-hlines-with-row-and-auto-pos-cell --- +#set page(margin: 2pt) +#set text(6pt) +#table( + columns: 3, + inset: 2.5pt, + table.footer( + table.hline(stroke: red), + table.vline(stroke: blue), + table.cell(x: 2, y: 2)[a], + [b], + table.hline(stroke: 3pt), + table.vline(stroke: 3pt), + ) +) + --- grid-footer-below-rowspans --- // Footer should go below the rowspans. #set page(margin: 2pt) @@ -404,3 +479,71 @@ table.cell(rowspan: 2)[a], table.cell(rowspan: 2)[b], table.footer() ) + +--- grid-footer-row-pos-cell-inside-conflicts-with-row-before --- +#set page(margin: 2pt) +#set text(6pt) +#table( + columns: 3, + inset: 1.5pt, + table.cell(y: 0)[a], + table.footer( + table.hline(stroke: red), + table.hline(y: 1, stroke: aqua), + // Error: 5-24 cell would cause footer to expand to non-empty row 0 + // Hint: 5-24 try moving its cells to available rows + table.cell(y: 0)[b], + [c] + ) +) + +--- grid-footer-auto-pos-cell-inside-conflicts-with-row-after --- +#set page(margin: 2pt) +#set text(6pt) +#table( + columns: 2, + inset: 1.5pt, + table.cell(y: 1)[a], + table.footer( + [b], [c], + // Error: 6-7 cell would cause footer to expand to non-empty row 1 + // Hint: 6-7 try moving its cells to available rows + [d], + ), +) + +--- grid-footer-row-pos-cell-inside-conflicts-with-row-after --- +#set page(margin: 2pt) +#set text(6pt) +#table( + columns: 2, + inset: 1.5pt, + table.cell(y: 2)[a], + table.footer( + [b], [c], + // Error: 5-24 cell would cause footer to expand to non-empty row 2 + // Hint: 5-24 try moving its cells to available rows + table.cell(y: 3)[d], + ), +) + +--- grid-footer-conflicts-with-empty-header --- +#table( + columns: 2, + table.header(), + table.footer( + // Error: 5-24 cell would cause footer to expand to non-empty row 0 + // Hint: 5-24 try moving its cells to available rows + table.cell(y: 0)[a] + ), +) + +--- issue-5359-column-override-stays-inside-footer --- +#table( + columns: 3, + [Outside], + table.footer( + [A], table.cell(x: 1)[B], [C], + table.cell(x: 1)[D], + ), +) diff --git a/tests/suite/layout/grid/headers.typ b/tests/suite/layout/grid/headers.typ index cb26337651..229bce6144 100644 --- a/tests/suite/layout/grid/headers.typ +++ b/tests/suite/layout/grid/headers.typ @@ -60,6 +60,16 @@ grid.cell(y: 2)[c] ) +--- grid-header-cell-with-x --- +#grid( + columns: 2, + stroke: black, + inset: 5pt, + grid.header(grid.cell(x: 0)[b1], grid.cell(x: 0)[b2]), + // This should skip the header + grid.cell(x: 1)[c] +) + --- grid-header-last-child --- // When the header is the last grid child, it shouldn't include the gutter row // after it, because there is none. @@ -273,8 +283,7 @@ ) #context count.display() ---- grid-header-expand --- -// Ensure header expands to fit cell placed in it after its declaration +--- grid-header-no-expand-with-col-and-row-pos-cell --- #set page(height: 10em) #table( columns: 2, @@ -282,9 +291,24 @@ [a], [b], [c], ), + // Error: 3-48 cell would conflict with header spanning the same position + // Hint: 3-48 try moving the cell or the header table.cell(x: 1, y: 1, rowspan: 2, lorem(80)) ) +--- grid-header-no-expand-with-row-pos-cell --- +#set page(height: 10em) +#table( + columns: 2, + table.header( + [a], [b], + [c], + ), + // Error: 3-42 cell would conflict with header spanning the same position + // Hint: 3-42 try moving the cell or the header + table.cell(y: 1, rowspan: 2, lorem(80)) +) + --- grid-nested-with-headers --- // Nested table with header should repeat both headers #set page(height: 10em) @@ -368,3 +392,81 @@ [b] ) ) + +--- grid-header-row-pos-cell-inside-conflicts-with-row-before --- +#set page(margin: 2pt) +#set text(6pt) +#table( + columns: 3, + inset: 1.5pt, + table.cell(y: 0)[a], + table.header( + table.hline(stroke: red), + table.hline(y: 1, stroke: aqua), + // Error: 5-24 cell would cause header to expand to non-empty row 0 + // Hint: 5-24 try moving its cells to available rows + table.cell(y: 0)[b], + [c] + ) +) + +--- grid-header-row-pos-cell-inside-conflicts-with-row-before-after-first-empty-row --- +#set page(margin: 2pt) +#set text(6pt) +#table( + columns: 3, + inset: 1.5pt, + // Rows: Occupied, Empty, Occupied, Empty, Empty, ... + // Should not be able to expand header from the second Empty to the second Occupied. + table.cell(y: 0)[a], + table.cell(y: 2)[a], + table.header( + table.hline(stroke: red), + table.hline(y: 3, stroke: aqua), + // Error: 5-24 cell would cause header to expand to non-empty row 2 + // Hint: 5-24 try moving its cells to available rows + table.cell(y: 2)[b], + ) +) + +--- grid-header-auto-pos-cell-inside-conflicts-with-row-after --- +#set page(margin: 2pt) +#set text(6pt) +#table( + columns: 2, + inset: 1.5pt, + table.cell(y: 1)[a], + table.header( + [b], [c], + // Error: 6-7 cell would cause header to expand to non-empty row 1 + // Hint: 6-7 try moving its cells to available rows + [d], + ), +) + +--- grid-header-row-pos-cell-inside-conflicts-with-row-after --- +#set page(margin: 2pt) +#set text(6pt) +#table( + columns: 2, + inset: 1.5pt, + table.cell(y: 2)[a], + table.header( + [b], [c], + // Error: 5-24 cell would cause header to expand to non-empty row 2 + // Hint: 5-24 try moving its cells to available rows + table.cell(y: 3)[d], + ), +) + +--- issue-5359-column-override-stays-inside-header --- +#table( + columns: 3, + [Outside], + // Error: 1:3-4:4 header must start at the first row + // Hint: 1:3-4:4 remove any rows before the header + table.header( + [A], table.cell(x: 1)[B], [C], + table.cell(x: 1)[D], + ), +) From 838a46dbb7124125947bfdafe8ddf97810c5de47 Mon Sep 17 00:00:00 2001 From: Laurenz Stampfl <47084093+LaurenzV@users.noreply.github.com> Date: Thu, 27 Mar 2025 11:59:32 +0100 Subject: [PATCH 066/558] Test all exif rotation types and fix two of them (#6102) --- Cargo.lock | 2 +- Cargo.toml | 2 +- .../src/visualize/image/raster.rs | 4 ++-- tests/ref/image-exif-rotation.png | Bin 0 -> 1392 bytes tests/ref/issue-870-image-rotation.png | Bin 200 -> 0 bytes tests/suite/visualize/image.typ | 19 ++++++++++++------ 6 files changed, 17 insertions(+), 10 deletions(-) create mode 100644 tests/ref/image-exif-rotation.png delete mode 100644 tests/ref/issue-870-image-rotation.png diff --git a/Cargo.lock b/Cargo.lock index d63cec8802..630eade2fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2803,7 +2803,7 @@ dependencies = [ [[package]] name = "typst-dev-assets" version = "0.13.1" -source = "git+https://github.com/typst/typst-dev-assets?rev=9879589#9879589f4b3247b12c5e694d0d7fa86d4d8a198e" +source = "git+https://github.com/typst/typst-dev-assets?rev=fddbf8b#fddbf8b99506bc370ac0edcd4959add603a7fc92" [[package]] name = "typst-docs" diff --git a/Cargo.toml b/Cargo.toml index a14124d659..a732418327 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ typst-syntax = { path = "crates/typst-syntax", version = "0.13.1" } typst-timing = { path = "crates/typst-timing", version = "0.13.1" } typst-utils = { path = "crates/typst-utils", version = "0.13.1" } typst-assets = { git = "https://github.com/typst/typst-assets", rev = "ab1295f" } -typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "9879589" } +typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "fddbf8b" } arrayvec = "0.7.4" az = "1.2" base64 = "0.22" diff --git a/crates/typst-library/src/visualize/image/raster.rs b/crates/typst-library/src/visualize/image/raster.rs index 0883fe71d8..453b94066e 100644 --- a/crates/typst-library/src/visualize/image/raster.rs +++ b/crates/typst-library/src/visualize/image/raster.rs @@ -325,12 +325,12 @@ fn apply_rotation(image: &mut DynamicImage, rotation: u32) { ops::flip_horizontal_in_place(image); *image = image.rotate270(); } - 6 => *image = image.rotate90(), + 6 => *image = image.rotate270(), 7 => { ops::flip_horizontal_in_place(image); *image = image.rotate90(); } - 8 => *image = image.rotate270(), + 8 => *image = image.rotate90(), _ => {} } } diff --git a/tests/ref/image-exif-rotation.png b/tests/ref/image-exif-rotation.png new file mode 100644 index 0000000000000000000000000000000000000000..a319a5c5b446afa08c7ec2084f2d1965c1405607 GIT binary patch literal 1392 zcmV-$1&{iPP)^-HEZ{nY`bQwA_ZT&Q_1tbE48? znBbGR>9Wk*f~@4B!NDnV%1DOELW0RegV0)%%~Fl=&)@m$^7Gc@(`A|6in7Q+f!1)K z&sdS!dZ)`vh|N%o-i))pA8XQNnfdAP%S?&RRgdbo&)kHr_v7r~l(_cZ>Cjx0_~q`> zVVCmK;nZrJd)o!2e!q@xo_4L@~%~6cMA#C&2 z*m$P=_4wzk$kS(< z#>U3!v(3lH$L+z^UteF_fU8kPR8QV_!%zH`B}w-S>}l_UUhLpKSJaFEb%LJUl!+ zJQJ6q_!vmMo>&pV#3#f;67ZK@F0KVsOj24Vj_7~|*(w(qIab(R9n(r;Rzj;6SbVOK8Sx%&|7HnlOV zWjWd1%GMn|0%M%KKyKWONP6YLL$?|B{6%^Be1^S#^|Hzc7sQ!b2{)B9blpPa0|YcS5sq8i@?aV=6`)Lqr;<33EN4hrX)nl$OUAV%$*D zW1~N#uwZ*ko3zX8_4~a(pHD6ACT@>&WMo8PwBhtntd^#o0)4%%@Y1e-&9qCC^nlfB z>*?-wdsNKZZ}02uVn6n-&OUp;S5}zE-P_$`vswqjO8aF)Zr(H+g#}u9xdz#`p}*mjW&sTne~kB9P9ae+DHNrqVf z27RL{?YGGp#G#gUkPO-84+*E{TFK*cL3Zea7V$NC#ur8*W#XxdpGX!QI*D1 z4NpHjCGj-Xoisj=;B$-ar18lQpAe%g9XB(sgE&yyK|1KaRCT}dSH{1&aA_{BQM!Hj y8)R5dtes#!&iD?Qt|#`M|9*johlht};_?GLgY!0Dq{rU?0000F!$v^BJ8WOKO3hZ{* zmD@UlOU(EH<6qshG)CrK(Pu0i?9!4vqHSu`1TNf85^K{H5c{he$G@cR??2!C?wOLe z*2(Vr`~Uudl;sEh?`J=dq*m7U;eS2bx#P-eeUTDojG7Dfe);~f>%aZ~fASpN;f%@^ zSCVc;=e*C@|NsB|i_6ZhIJ#J@KguS{P>+G(z@>Nge%mrGft={+>gTe~DWM4f<3&-k diff --git a/tests/suite/visualize/image.typ b/tests/suite/visualize/image.typ index 7ce0c8c0af..9a77870af0 100644 --- a/tests/suite/visualize/image.typ +++ b/tests/suite/visualize/image.typ @@ -247,12 +247,6 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B format: "rgba8", ) ---- issue-870-image-rotation --- -// Ensure that EXIF rotation is applied. -// https://github.com/image-rs/image/issues/1045 -// File is from https://magnushoff.com/articles/jpeg-orientation/ -#image("/assets/images/f2t.jpg", width: 10pt) - --- issue-measure-image --- // Test that image measurement doesn't turn `inf / some-value` into 0pt. #context { @@ -267,3 +261,16 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B --- issue-3733-dpi-svg --- #set page(width: 200pt, height: 200pt, margin: 0pt) #image("/assets/images/relative.svg") + +--- image-exif-rotation --- +#let data = read("/assets/images/f2t.jpg", encoding: none) + +#let rotations = range(1, 9) +#let rotated(v) = image(data.slice(0, 49) + bytes((v,)) + data.slice(50), width: 10pt) + +#set page(width: auto) +#table( + columns: rotations.len(), + ..rotations.map(v => raw(str(v), lang: "typc")), + ..rotations.map(rotated) +) From b7a4382a73e495cf56350c1ba4f216d51b1864c7 Mon Sep 17 00:00:00 2001 From: Philipp Niedermayer Date: Fri, 28 Mar 2025 16:28:03 +0100 Subject: [PATCH 067/558] Fix typo (#6104) --- crates/typst-library/src/visualize/shape.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-library/src/visualize/shape.rs b/crates/typst-library/src/visualize/shape.rs index 439b4cd988..ff05be2bed 100644 --- a/crates/typst-library/src/visualize/shape.rs +++ b/crates/typst-library/src/visualize/shape.rs @@ -106,7 +106,7 @@ pub struct RectElem { pub radius: Corners>>, /// How much to pad the rectangle's content. - /// See the [box's documentation]($box.outset) for more details. + /// See the [box's documentation]($box.inset) for more details. #[resolve] #[fold] #[default(Sides::splat(Some(Abs::pt(5.0).into())))] From 20ee446ebab2fbb23246026301e26b82647369a2 Mon Sep 17 00:00:00 2001 From: frozolotl <44589151+frozolotl@users.noreply.github.com> Date: Fri, 28 Mar 2025 16:30:30 +0100 Subject: [PATCH 068/558] Fix descriptions of color maps (#6096) --- crates/typst-library/src/visualize/color.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/typst-library/src/visualize/color.rs b/crates/typst-library/src/visualize/color.rs index 20b0f5719e..24d8305cda 100644 --- a/crates/typst-library/src/visualize/color.rs +++ b/crates/typst-library/src/visualize/color.rs @@ -148,11 +148,11 @@ static TO_SRGB: LazyLock = LazyLock::new(|| { /// | `magma` | A black to purple to yellow color map. | /// | `plasma` | A purple to pink to yellow color map. | /// | `rocket` | A black to red to white color map. | -/// | `mako` | A black to teal to yellow color map. | +/// | `mako` | A black to teal to white color map. | /// | `vlag` | A light blue to white to red color map. | -/// | `icefire` | A light teal to black to yellow color map. | +/// | `icefire` | A light teal to black to orange color map. | /// | `flare` | A orange to purple color map that is perceptually uniform. | -/// | `crest` | A blue to white to red color map. | +/// | `crest` | A light green to blue color map. | /// /// Some popular presets are not included because they are not available under a /// free licence. Others, like From efdb75558f20543af39f75fb88b3bae59b20e2e8 Mon Sep 17 00:00:00 2001 From: Matt Fellenz Date: Fri, 28 Mar 2025 18:33:16 +0100 Subject: [PATCH 069/558] IDE: complete jump-to-cursor impl (#6037) --- crates/typst-ide/src/jump.rs | 152 ++++++++++++++++++-- crates/typst-library/src/visualize/curve.rs | 64 +++++++++ 2 files changed, 206 insertions(+), 10 deletions(-) diff --git a/crates/typst-ide/src/jump.rs b/crates/typst-ide/src/jump.rs index 4283354268..b29bc4a482 100644 --- a/crates/typst-ide/src/jump.rs +++ b/crates/typst-ide/src/jump.rs @@ -3,7 +3,7 @@ use std::num::NonZeroUsize; 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::visualize::{Curve, CurveItem, FillRule, Geometry}; use typst::WorldExt; use crate::IdeWorld; @@ -53,10 +53,20 @@ pub fn jump_from_click( for (mut pos, item) in frame.items().rev() { match item { FrameItem::Group(group) => { - // TODO: Handle transformation. - if let Some(span) = - jump_from_click(world, document, &group.frame, click - pos) - { + let pos = click - pos; + if let Some(clip) = &group.clip { + if !clip.contains(FillRule::NonZero, pos) { + continue; + } + } + // Realistic transforms should always be invertible. + // An example of one that isn't is a scale of 0, which would + // not be clickable anyway. + let Some(inv_transform) = group.transform.invert() else { + continue; + }; + let pos = pos.transform_inf(inv_transform); + if let Some(span) = jump_from_click(world, document, &group.frame, pos) { return Some(span); } } @@ -94,9 +104,32 @@ pub fn jump_from_click( } FrameItem::Shape(shape, span) => { - let Geometry::Rect(size) = shape.geometry else { continue }; - if is_in_rect(pos, size, click) { - return Jump::from_span(world, *span); + if shape.fill.is_some() { + let within = match &shape.geometry { + Geometry::Line(..) => false, + Geometry::Rect(size) => is_in_rect(pos, *size, click), + Geometry::Curve(curve) => { + curve.contains(shape.fill_rule, click - pos) + } + }; + if within { + return Jump::from_span(world, *span); + } + } + + if let Some(stroke) = &shape.stroke { + let within = !stroke.thickness.approx_empty() && { + // This curve is rooted at (0, 0), not `pos`. + let base_curve = match &shape.geometry { + Geometry::Line(to) => &Curve(vec![CurveItem::Line(*to)]), + Geometry::Rect(size) => &Curve::rect(*size), + Geometry::Curve(curve) => curve, + }; + base_curve.stroke_contains(stroke, click - pos) + }; + if within { + return Jump::from_span(world, *span); + } } } @@ -146,9 +179,8 @@ pub fn jump_from_cursor( fn find_in_frame(frame: &Frame, span: Span) -> Option { for (mut pos, item) in frame.items() { if let FrameItem::Group(group) = item { - // TODO: Handle transformation. if let Some(point) = find_in_frame(&group.frame, span) { - return Some(point + pos); + return Some(pos + point.transform(group.transform)); } } @@ -269,6 +301,97 @@ mod tests { test_click("$a + b$", point(28.0, 14.0), cursor(5)); } + #[test] + fn test_jump_from_click_transform_clip() { + let margin = point(10.0, 10.0); + test_click( + "#rect(width: 20pt, height: 20pt, fill: black)", + point(10.0, 10.0) + margin, + cursor(1), + ); + test_click( + "#rect(width: 60pt, height: 10pt, fill: black)", + point(5.0, 30.0) + margin, + None, + ); + test_click( + "#rotate(90deg, origin: bottom + left, rect(width: 60pt, height: 10pt, fill: black))", + point(5.0, 30.0) + margin, + cursor(38), + ); + test_click( + "#scale(x: 300%, y: 300%, origin: top + left, rect(width: 10pt, height: 10pt, fill: black))", + point(20.0, 20.0) + margin, + cursor(45), + ); + test_click( + "#box(width: 10pt, height: 10pt, clip: true, scale(x: 300%, y: 300%, \ + origin: top + left, rect(width: 10pt, height: 10pt, fill: black)))", + point(20.0, 20.0) + margin, + None, + ); + test_click( + "#box(width: 10pt, height: 10pt, clip: false, rect(width: 30pt, height: 30pt, fill: black))", + point(20.0, 20.0) + margin, + cursor(45), + ); + test_click( + "#box(width: 10pt, height: 10pt, clip: true, rect(width: 30pt, height: 30pt, fill: black))", + point(20.0, 20.0) + margin, + None, + ); + test_click( + "#rotate(90deg, origin: bottom + left)[hello world]", + point(5.0, 15.0) + margin, + cursor(40), + ); + } + + #[test] + fn test_jump_from_click_shapes() { + let margin = point(10.0, 10.0); + + test_click( + "#rect(width: 30pt, height: 30pt, fill: black)", + point(15.0, 15.0) + margin, + cursor(1), + ); + + let circle = "#circle(width: 30pt, height: 30pt, fill: black)"; + test_click(circle, point(15.0, 15.0) + margin, cursor(1)); + test_click(circle, point(1.0, 1.0) + margin, None); + + let bowtie = + "#polygon(fill: black, (0pt, 0pt), (20pt, 20pt), (20pt, 0pt), (0pt, 20pt))"; + test_click(bowtie, point(1.0, 2.0) + margin, cursor(1)); + test_click(bowtie, point(2.0, 1.0) + margin, None); + test_click(bowtie, point(19.0, 10.0) + margin, cursor(1)); + + let evenodd = r#"#polygon(fill: black, fill-rule: "even-odd", + (0pt, 10pt), (30pt, 10pt), (30pt, 20pt), (20pt, 20pt), + (20pt, 0pt), (10pt, 0pt), (10pt, 30pt), (20pt, 30pt), + (20pt, 20pt), (0pt, 20pt))"#; + test_click(evenodd, point(15.0, 15.0) + margin, None); + test_click(evenodd, point(5.0, 15.0) + margin, cursor(1)); + test_click(evenodd, point(15.0, 5.0) + margin, cursor(1)); + } + + #[test] + fn test_jump_from_click_shapes_stroke() { + let margin = point(10.0, 10.0); + + let rect = + "#place(dx: 10pt, dy: 10pt, rect(width: 10pt, height: 10pt, stroke: 5pt))"; + test_click(rect, point(15.0, 15.0) + margin, None); + test_click(rect, point(10.0, 15.0) + margin, cursor(27)); + + test_click( + "#line(angle: 45deg, length: 10pt, stroke: 2pt)", + point(2.0, 2.0) + margin, + cursor(1), + ); + } + #[test] fn test_jump_from_cursor() { let s = "*Hello* #box[ABC] World"; @@ -281,6 +404,15 @@ mod tests { test_cursor("$a + b$", -3, pos(1, 27.51, 16.83)); } + #[test] + fn test_jump_from_cursor_transform() { + test_cursor( + r#"#rotate(90deg, origin: bottom + left, [hello world])"#, + -5, + pos(1, 10.0, 16.58), + ); + } + #[test] fn test_backlink() { let s = "#footnote[Hi]"; diff --git a/crates/typst-library/src/visualize/curve.rs b/crates/typst-library/src/visualize/curve.rs index fb5151e8fa..50944a5168 100644 --- a/crates/typst-library/src/visualize/curve.rs +++ b/crates/typst-library/src/visualize/curve.rs @@ -10,6 +10,8 @@ use crate::foundations::{ use crate::layout::{Abs, Axes, BlockElem, Length, Point, Rel, Size}; use crate::visualize::{FillRule, Paint, Stroke}; +use super::FixedStroke; + /// A curve consisting of movements, lines, and Bézier segments. /// /// At any point in time, there is a conceptual pen or cursor. @@ -530,3 +532,65 @@ impl Curve { Size::new(max_x - min_x, max_y - min_y) } } + +impl Curve { + fn to_kurbo(&self) -> impl Iterator + '_ { + use kurbo::PathEl; + + self.0.iter().map(|item| match *item { + CurveItem::Move(point) => PathEl::MoveTo(point_to_kurbo(point)), + CurveItem::Line(point) => PathEl::LineTo(point_to_kurbo(point)), + CurveItem::Cubic(point, point1, point2) => PathEl::CurveTo( + point_to_kurbo(point), + point_to_kurbo(point1), + point_to_kurbo(point2), + ), + CurveItem::Close => PathEl::ClosePath, + }) + } + + /// When this curve is interpreted as a clip mask, would it contain `point`? + pub fn contains(&self, fill_rule: FillRule, needle: Point) -> bool { + let kurbo = kurbo::BezPath::from_vec(self.to_kurbo().collect()); + let windings = kurbo::Shape::winding(&kurbo, point_to_kurbo(needle)); + match fill_rule { + FillRule::NonZero => windings != 0, + FillRule::EvenOdd => windings % 2 != 0, + } + } + + /// When this curve is stroked with `stroke`, would the stroke contain + /// `point`? + pub fn stroke_contains(&self, stroke: &FixedStroke, needle: Point) -> bool { + let width = stroke.thickness.to_raw(); + let cap = match stroke.cap { + super::LineCap::Butt => kurbo::Cap::Butt, + super::LineCap::Round => kurbo::Cap::Round, + super::LineCap::Square => kurbo::Cap::Square, + }; + let join = match stroke.join { + super::LineJoin::Miter => kurbo::Join::Miter, + super::LineJoin::Round => kurbo::Join::Round, + super::LineJoin::Bevel => kurbo::Join::Bevel, + }; + let miter_limit = stroke.miter_limit.get(); + let mut style = kurbo::Stroke::new(width) + .with_caps(cap) + .with_join(join) + .with_miter_limit(miter_limit); + if let Some(dash) = &stroke.dash { + style = style.with_dashes( + dash.phase.to_raw(), + dash.array.iter().copied().map(Abs::to_raw), + ); + } + let opts = kurbo::StrokeOpts::default(); + let tolerance = 0.01; + let expanded = kurbo::stroke(self.to_kurbo(), &style, &opts, tolerance); + kurbo::Shape::contains(&expanded, point_to_kurbo(needle)) + } +} + +fn point_to_kurbo(point: Point) -> kurbo::Point { + kurbo::Point::new(point.x.to_raw(), point.y.to_raw()) +} From 758ee78ef57ebbaadacc50817620a540bcf8beeb Mon Sep 17 00:00:00 2001 From: Myriad-Dreamin <35292584+Myriad-Dreamin@users.noreply.github.com> Date: Mon, 31 Mar 2025 16:08:55 +0800 Subject: [PATCH 070/558] Make `World::font` implementations safe (#6117) --- crates/typst-cli/src/world.rs | 4 +++- crates/typst-ide/src/tests.rs | 2 +- docs/src/html.rs | 2 +- tests/src/world.rs | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs index 12e80d273a..2da03d4d5a 100644 --- a/crates/typst-cli/src/world.rs +++ b/crates/typst-cli/src/world.rs @@ -210,7 +210,9 @@ impl World for SystemWorld { } fn font(&self, index: usize) -> Option { - self.fonts[index].get() + // comemo's validation may invoke this function with an invalid index. This is + // impossible in typst-cli but possible if a custom tool mutates the fonts. + self.fonts.get(index)?.get() } fn today(&self, offset: Option) -> Option { diff --git a/crates/typst-ide/src/tests.rs b/crates/typst-ide/src/tests.rs index 6678ab8412..c6d733ca97 100644 --- a/crates/typst-ide/src/tests.rs +++ b/crates/typst-ide/src/tests.rs @@ -97,7 +97,7 @@ impl World for TestWorld { } fn font(&self, index: usize) -> Option { - Some(self.base.fonts[index].clone()) + self.base.fonts.get(index).cloned() } fn today(&self, _: Option) -> Option { diff --git a/docs/src/html.rs b/docs/src/html.rs index 9077d5c475..9c02f08e96 100644 --- a/docs/src/html.rs +++ b/docs/src/html.rs @@ -498,7 +498,7 @@ impl World for DocWorld { } fn font(&self, index: usize) -> Option { - Some(FONTS.1[index].clone()) + FONTS.1.get(index).cloned() } fn today(&self, _: Option) -> Option { diff --git a/tests/src/world.rs b/tests/src/world.rs index 9e0e91ad77..fe2bd45ea8 100644 --- a/tests/src/world.rs +++ b/tests/src/world.rs @@ -67,7 +67,7 @@ impl World for TestWorld { } fn font(&self, index: usize) -> Option { - Some(self.base.fonts[index].clone()) + self.base.fonts.get(index).cloned() } fn today(&self, _: Option) -> Option { From 326bec1f0d0fc65fb26ae4c797487d82d2b18b81 Mon Sep 17 00:00:00 2001 From: Astra3 Date: Mon, 31 Mar 2025 10:16:47 +0200 Subject: [PATCH 071/558] Correcting Czech translation in `typst-library` (#6101) --- crates/typst-library/translations/cs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-library/translations/cs.txt b/crates/typst-library/translations/cs.txt index d4688ffeee..e21ca35206 100644 --- a/crates/typst-library/translations/cs.txt +++ b/crates/typst-library/translations/cs.txt @@ -4,5 +4,5 @@ equation = Rovnice bibliography = Bibliografie heading = Kapitola outline = Obsah -raw = Seznam +raw = Výpis page = strana \ No newline at end of file From e60d3021a782c5977cf7de726682e19ae89abeb3 Mon Sep 17 00:00:00 2001 From: +merlan #flirora Date: Mon, 31 Mar 2025 04:17:37 -0400 Subject: [PATCH 072/558] Add env setting for ignore_system_fonts (#6092) --- crates/typst-cli/src/args.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index d6855d1006..76f6472760 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -361,7 +361,7 @@ pub struct FontArgs { /// Ensures system fonts won't be searched, unless explicitly included via /// `--font-path`. - #[arg(long)] + #[arg(long, env = "TYPST_IGNORE_SYSTEM_FONTS")] pub ignore_system_fonts: bool, } From 1082181a6f789b73fbc64c4ff5bc1401ad081e76 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Mon, 31 Mar 2025 11:01:01 +0200 Subject: [PATCH 073/558] Improve french smartquotes (#5976) --- crates/typst-library/src/text/smartquote.rs | 2 +- tests/ref/smartquote-disabled-temporarily.png | Bin 2781 -> 2782 bytes tests/ref/smartquote-fr.png | Bin 2344 -> 2334 bytes tests/ref/smartquote-with-embedding-chars.png | Bin 573 -> 568 bytes tests/suite/text/smartquote.typ | 2 +- 5 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/typst-library/src/text/smartquote.rs b/crates/typst-library/src/text/smartquote.rs index f457a63711..4dda689dfd 100644 --- a/crates/typst-library/src/text/smartquote.rs +++ b/crates/typst-library/src/text/smartquote.rs @@ -238,7 +238,7 @@ impl<'s> SmartQuotes<'s> { "cs" | "de" | "et" | "is" | "lt" | "lv" | "sk" | "sl" => low_high, "da" => ("‘", "’", "“", "”"), "fr" | "ru" if alternative => default, - "fr" => ("‹\u{00A0}", "\u{00A0}›", "«\u{00A0}", "\u{00A0}»"), + "fr" => ("“", "”", "«\u{202F}", "\u{202F}»"), "fi" | "sv" if alternative => ("’", "’", "»", "»"), "bs" | "fi" | "sv" => ("’", "’", "”", "”"), "it" if alternative => default, diff --git a/tests/ref/smartquote-disabled-temporarily.png b/tests/ref/smartquote-disabled-temporarily.png index 4c565c01ce1d91bfbd8b443f410c56e0b9b55360..f4d08c4d12fd5a8ecbc19d2d3d5bb252b2011386 100644 GIT binary patch delta 2774 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 delta 2773 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 b{6PN!WoyUfJv0%b00000NkvXXu0mjf8my2m diff --git a/tests/ref/smartquote-fr.png b/tests/ref/smartquote-fr.png index e28184226ac174455d34de80f4ab329c305b7bfa..6b7de7abeecd29b66aaad7467f6a787d7fb7d7a7 100644 GIT binary patch delta 2323 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 t&81yhJU5m3P_CFiB(~{ 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 diff --git a/tests/suite/text/smartquote.typ b/tests/suite/text/smartquote.typ index 4940d11b21..f2af93ceba 100644 --- a/tests/suite/text/smartquote.typ +++ b/tests/suite/text/smartquote.typ @@ -99,7 +99,7 @@ He's told some books contain questionable "example text". --- smartquote-disabled-temporarily --- // Test changing properties within text. -"She suddenly started speaking french: #text(lang: "fr")['Je suis une banane.']" Roman told me. +"She suddenly started speaking french: #text(lang: "fr", region: "CH")['Je suis une banane.']" Roman told me. Some people's thought on this would be #[#set smartquote(enabled: false); "strange."] From a64af130dc84c84442d59f322b705bded28201de Mon Sep 17 00:00:00 2001 From: +merlan #flirora Date: Mon, 31 Mar 2025 05:06:18 -0400 Subject: [PATCH 074/558] Add default parameter for array.{first, last} (#5970) --- crates/typst-library/src/foundations/array.rs | 24 ++++++++++++++----- tests/suite/foundations/array.typ | 4 ++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/crates/typst-library/src/foundations/array.rs b/crates/typst-library/src/foundations/array.rs index e81b9e6457..b647473ab2 100644 --- a/crates/typst-library/src/foundations/array.rs +++ b/crates/typst-library/src/foundations/array.rs @@ -172,17 +172,29 @@ impl Array { } /// Returns the first item in the array. May be used on the left-hand side - /// of an assignment. Fails with an error if the array is empty. + /// an assignment. Returns the default value if the array is empty + /// or fails with an error is no default value was specified. #[func] - pub fn first(&self) -> StrResult { - self.0.first().cloned().ok_or_else(array_is_empty) + pub fn first( + &self, + /// A default value to return if the array is empty. + #[named] + default: Option, + ) -> StrResult { + self.0.first().cloned().or(default).ok_or_else(array_is_empty) } /// Returns the last item in the array. May be used on the left-hand side of - /// an assignment. Fails with an error if the array is empty. + /// an assignment. Returns the default value if the array is empty + /// or fails with an error is no default value was specified. #[func] - pub fn last(&self) -> StrResult { - self.0.last().cloned().ok_or_else(array_is_empty) + pub fn last( + &self, + /// A default value to return if the array is empty. + #[named] + default: Option, + ) -> StrResult { + self.0.last().cloned().or(default).ok_or_else(array_is_empty) } /// Returns the item at the specified index in the array. May be used on the diff --git a/tests/suite/foundations/array.typ b/tests/suite/foundations/array.typ index 6228f471bf..61b5decb3f 100644 --- a/tests/suite/foundations/array.typ +++ b/tests/suite/foundations/array.typ @@ -179,6 +179,10 @@ #test((2,).last(), 2) #test((1, 2, 3).first(), 1) #test((1, 2, 3).last(), 3) +#test((1, 2).first(default: 99), 1) +#test(().first(default: 99), 99) +#test((1, 2).last(default: 99), 2) +#test(().last(default: 99), 99) --- array-first-empty --- // Error: 2-12 array is empty From 4f0fbfb7e003f6ae88c1b210fdb7b38f795fc9e4 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 31 Mar 2025 09:17:49 +0000 Subject: [PATCH 075/558] Add dotless parameter to `math.accent` (#5939) Co-authored-by: Laurenz --- crates/typst-layout/src/math/accent.rs | 6 +++-- crates/typst-library/src/math/accent.rs | 26 +++++++++++++++++++-- tests/ref/math-accent-dotless-disabled.png | Bin 0 -> 311 bytes tests/ref/math-accent-dotless-set-rule.png | Bin 0 -> 147 bytes tests/suite/math/accent.typ | 8 +++++++ 5 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 tests/ref/math-accent-dotless-disabled.png create mode 100644 tests/ref/math-accent-dotless-set-rule.png diff --git a/crates/typst-layout/src/math/accent.rs b/crates/typst-layout/src/math/accent.rs index f2dfa2c45a..73d8210194 100644 --- a/crates/typst-layout/src/math/accent.rs +++ b/crates/typst-layout/src/math/accent.rs @@ -19,8 +19,10 @@ pub fn layout_accent( let mut base = ctx.layout_into_fragment(&elem.base, styles.chain(&cramped))?; // Try to replace a glyph with its dotless variant. - if let MathFragment::Glyph(glyph) = &mut base { - glyph.make_dotless_form(ctx); + if elem.dotless(styles) { + if let MathFragment::Glyph(glyph) = &mut base { + glyph.make_dotless_form(ctx); + } } // Preserve class to preserve automatic spacing. diff --git a/crates/typst-library/src/math/accent.rs b/crates/typst-library/src/math/accent.rs index b162c52b1c..e62b63872c 100644 --- a/crates/typst-library/src/math/accent.rs +++ b/crates/typst-library/src/math/accent.rs @@ -13,8 +13,8 @@ use crate::math::Mathy; /// ``` #[elem(Mathy)] pub struct AccentElem { - /// The base to which the accent is applied. - /// May consist of multiple letters. + /// The base to which the accent is applied. May consist of multiple + /// letters. /// /// ```example /// $arrow(A B C)$ @@ -51,9 +51,24 @@ pub struct AccentElem { pub accent: Accent, /// The size of the accent, relative to the width of the base. + /// + /// ```example + /// $dash(A, size: #150%)$ + /// ``` #[resolve] #[default(Rel::one())] pub size: Rel, + + /// Whether to remove the dot on top of lowercase i and j when adding a top + /// accent. + /// + /// This enables the `dtls` OpenType feature. + /// + /// ```example + /// $hat(dotless: #false, i)$ + /// ``` + #[default(true)] + pub dotless: bool, } /// An accent character. @@ -103,11 +118,18 @@ macro_rules! accents { /// The size of the accent, relative to the width of the base. #[named] size: Option>, + /// Whether to remove the dot on top of lowercase i and j when + /// adding a top accent. + #[named] + dotless: Option, ) -> Content { let mut accent = AccentElem::new(base, Accent::new($primary)); if let Some(size) = size { accent = accent.with_size(size); } + if let Some(dotless) = dotless { + accent = accent.with_dotless(dotless); + } accent.pack() } )+ diff --git a/tests/ref/math-accent-dotless-disabled.png b/tests/ref/math-accent-dotless-disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..d75ec4580253f4b791384ba7e98bf209eab338c6 GIT binary patch literal 311 zcmV-70m%M|P)w)T5`r&Y`IJ`TyHLT3Y-rdKZLobSId4 z-n?iFMBq;YO@n&RpTm$K{`C_~z5IXo4^-+iO)dUB?-`iB2#e6G3nBF5`HyI7@rvb3 z!1T(HZ(#cNiR{N<#{B)gG`09&$Qwvp2SE6L|GPmMt$S%|@rU3O|6hPSxa1g+1_^}z ze+5p?KHq6-@tdMO{{yf7|G%c^;eX3_K!Ht@{x^X_|F+?005R4eF=Tc4^998002ov JPDHLkV1kDTnQ{OC literal 0 HcmV?d00001 diff --git a/tests/ref/math-accent-dotless-set-rule.png b/tests/ref/math-accent-dotless-set-rule.png new file mode 100644 index 0000000000000000000000000000000000000000..ae5ef017aaedc711d30182cd57d05de08e9f9397 GIT binary patch literal 147 zcmeAS@N?(olHy`uVBq!ia0vp^6+kS@0VEh)%)UPdQc<2Rjv*Ddl7HAcG$dYm6xi*q zE4Q^mLFeDx!h{5!dmrnL|8C@bb+w*NqtBl|Kt{gaz(6eCR>a;wY=5+2azm5Vk88gh oH(J#EV)*&v_4zopr0MHjU3IG5A literal 0 HcmV?d00001 diff --git a/tests/suite/math/accent.typ b/tests/suite/math/accent.typ index 5be4f576f3..ab0078a5fa 100644 --- a/tests/suite/math/accent.typ +++ b/tests/suite/math/accent.typ @@ -42,3 +42,11 @@ $tilde(U, size: #1.1em), x^tilde(U, size: #1.1em), sscript(tilde(U, size: #1.1em macron(bb(#c)), dot(cal(#c)), diaer(upright(#c)), breve(bold(#c)), circle(bold(upright(#c))), caron(upright(sans(#c))), arrow(bold(frak(#c)))$ $test(i) \ test(j)$ + +--- math-accent-dotless-disabled --- +// Test disabling the dotless glyph variants. +$hat(i), hat(i, dotless: #false), accent(j, tilde), accent(j, tilde, dotless: #false)$ + +--- math-accent-dotless-set-rule --- +#set math.accent(dotless: false) +$ hat(i) $ From 012e14d40cb44997630cf6469a446f217f2e9057 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 31 Mar 2025 09:38:04 +0000 Subject: [PATCH 076/558] Unify layout of `vec` and `cases` with `mat` (#5934) --- crates/typst-layout/src/math/mat.rs | 167 +++++++++++----------- crates/typst-layout/src/math/shared.rs | 16 +-- crates/typst-layout/src/math/underover.rs | 10 +- tests/ref/math-cases-linebreaks.png | Bin 570 -> 506 bytes tests/ref/math-equation-font.png | Bin 984 -> 1032 bytes tests/ref/math-mat-vec-cases-unity.png | Bin 0 -> 1202 bytes tests/ref/math-vec-linebreaks.png | Bin 856 -> 651 bytes tests/suite/math/cases.typ | 4 +- tests/suite/math/mat.typ | 11 +- tests/suite/math/vec.typ | 4 +- 10 files changed, 104 insertions(+), 108 deletions(-) create mode 100644 tests/ref/math-mat-vec-cases-unity.png diff --git a/crates/typst-layout/src/math/mat.rs b/crates/typst-layout/src/math/mat.rs index bf49290263..d678f86585 100644 --- a/crates/typst-layout/src/math/mat.rs +++ b/crates/typst-layout/src/math/mat.rs @@ -1,4 +1,4 @@ -use typst_library::diag::{bail, SourceResult}; +use typst_library::diag::{bail, warning, SourceResult}; use typst_library::foundations::{Content, Packed, Resolve, StyleChain}; use typst_library::layout::{ Abs, Axes, Em, FixedAlignment, Frame, FrameItem, Point, Ratio, Rel, Size, @@ -9,7 +9,7 @@ use typst_library::visualize::{FillRule, FixedStroke, Geometry, LineCap, Shape}; use typst_syntax::Span; use super::{ - alignments, delimiter_alignment, stack, style_for_denominator, AlignmentResult, + alignments, delimiter_alignment, style_for_denominator, AlignmentResult, FrameFragment, GlyphFragment, LeftRightAlternator, MathContext, DELIM_SHORT_FALL, }; @@ -23,17 +23,51 @@ pub fn layout_vec( ctx: &mut MathContext, styles: StyleChain, ) -> SourceResult<()> { - let delim = elem.delim(styles); - let frame = layout_vec_body( + let span = elem.span(); + + let column: Vec<&Content> = elem.children.iter().collect(); + let frame = layout_body( ctx, styles, - &elem.children, + &[column], elem.align(styles), - elem.gap(styles), LeftRightAlternator::Right, + None, + Axes::with_y(elem.gap(styles)), + span, + "elements", )?; - layout_delimiters(ctx, styles, frame, delim.open(), delim.close(), elem.span()) + let delim = elem.delim(styles); + layout_delimiters(ctx, styles, frame, delim.open(), delim.close(), span) +} + +/// Lays out a [`CasesElem`]. +#[typst_macros::time(name = "math.cases", span = elem.span())] +pub fn layout_cases( + elem: &Packed, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let span = elem.span(); + + let column: Vec<&Content> = elem.children.iter().collect(); + let frame = layout_body( + ctx, + styles, + &[column], + FixedAlignment::Start, + LeftRightAlternator::None, + None, + Axes::with_y(elem.gap(styles)), + span, + "branches", + )?; + + let delim = elem.delim(styles); + let (open, close) = + if elem.reverse(styles) { (None, delim.close()) } else { (delim.open(), None) }; + layout_delimiters(ctx, styles, frame, open, close, span) } /// Lays out a [`MatElem`]. @@ -43,14 +77,16 @@ pub fn layout_mat( ctx: &mut MathContext, styles: StyleChain, ) -> SourceResult<()> { - let augment = elem.augment(styles); + let span = elem.span(); let rows = &elem.rows; + let ncols = rows.first().map_or(0, |row| row.len()); + let augment = elem.augment(styles); if let Some(aug) = &augment { for &offset in &aug.hline.0 { if offset == 0 || offset.unsigned_abs() >= rows.len() { bail!( - elem.span(), + span, "cannot draw a horizontal line after row {} of a matrix with {} rows", if offset < 0 { rows.len() as isize + offset } else { offset }, rows.len() @@ -58,95 +94,55 @@ pub fn layout_mat( } } - let ncols = rows.first().map_or(0, |row| row.len()); - for &offset in &aug.vline.0 { if offset == 0 || offset.unsigned_abs() >= ncols { bail!( - elem.span(), - "cannot draw a vertical line after column {} of a matrix with {} columns", - if offset < 0 { ncols as isize + offset } else { offset }, - ncols - ); + span, + "cannot draw a vertical line after column {} of a matrix with {} columns", + if offset < 0 { ncols as isize + offset } else { offset }, + ncols + ); } } } - let delim = elem.delim(styles); - let frame = layout_mat_body( + // Transpose rows of the matrix into columns. + let mut row_iters: Vec<_> = rows.iter().map(|i| i.iter()).collect(); + let columns: Vec> = (0..ncols) + .map(|_| row_iters.iter_mut().map(|i| i.next().unwrap()).collect()) + .collect(); + + let frame = layout_body( ctx, styles, - rows, + &columns, elem.align(styles), + LeftRightAlternator::Right, augment, Axes::new(elem.column_gap(styles), elem.row_gap(styles)), - elem.span(), + span, + "cells", )?; - layout_delimiters(ctx, styles, frame, delim.open(), delim.close(), elem.span()) -} - -/// Lays out a [`CasesElem`]. -#[typst_macros::time(name = "math.cases", span = elem.span())] -pub fn layout_cases( - elem: &Packed, - ctx: &mut MathContext, - styles: StyleChain, -) -> SourceResult<()> { let delim = elem.delim(styles); - let frame = layout_vec_body( - ctx, - styles, - &elem.children, - FixedAlignment::Start, - elem.gap(styles), - LeftRightAlternator::None, - )?; - - let (open, close) = - if elem.reverse(styles) { (None, delim.close()) } else { (delim.open(), None) }; - - layout_delimiters(ctx, styles, frame, open, close, elem.span()) + layout_delimiters(ctx, styles, frame, delim.open(), delim.close(), span) } -/// Layout the inner contents of a vector. -fn layout_vec_body( +/// Layout the inner contents of a matrix, vector, or cases. +#[allow(clippy::too_many_arguments)] +fn layout_body( ctx: &mut MathContext, styles: StyleChain, - column: &[Content], + columns: &[Vec<&Content>], align: FixedAlignment, - row_gap: Rel, alternator: LeftRightAlternator, -) -> SourceResult { - let gap = row_gap.relative_to(ctx.region.size.y); - - let denom_style = style_for_denominator(styles); - let mut flat = vec![]; - for child in column { - // 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 - // way too big. - let paren = - GlyphFragment::new(ctx, styles.chain(&denom_style), '(', Span::detached()); - Ok(stack(flat, align, gap, 0, alternator, Some((paren.ascent, paren.descent)))) -} - -/// Layout the inner contents of a matrix. -fn layout_mat_body( - ctx: &mut MathContext, - styles: StyleChain, - rows: &[Vec], - align: FixedAlignment, augment: Option>, gap: Axes>, span: Span, + children: &str, ) -> SourceResult { - let ncols = rows.first().map_or(0, |row| row.len()); - let nrows = rows.len(); + let nrows = columns.first().map_or(0, |col| col.len()); + let ncols = columns.len(); if ncols == 0 || nrows == 0 { return Ok(Frame::soft(Size::zero())); } @@ -178,16 +174,11 @@ fn layout_mat_body( // Before the full matrix body can be laid out, the // individual cells must first be independently laid out // so we can ensure alignment across rows and columns. + let mut cols = vec![vec![]; ncols]; // This variable stores the maximum ascent and descent for each row. let mut heights = vec![(Abs::zero(), Abs::zero()); nrows]; - // We want to transpose our data layout to columns - // before final layout. For efficiency, the columns - // variable is set up here and newly generated - // individual cells are then added to it. - let mut cols = vec![vec![]; ncols]; - let denom_style = style_for_denominator(styles); // We pad ascent and descent with the ascent and descent of the paren // to ensure that normal matrices are aligned with others unless they are @@ -195,10 +186,22 @@ fn layout_mat_body( let paren = GlyphFragment::new(ctx, styles.chain(&denom_style), '(', Span::detached()); - for (row, (ascent, descent)) in rows.iter().zip(&mut heights) { - for (cell, col) in row.iter().zip(&mut cols) { + for (column, col) in columns.iter().zip(&mut cols) { + for (cell, (ascent, descent)) in column.iter().zip(&mut heights) { + let cell_span = cell.span(); let cell = ctx.layout_into_run(cell, styles.chain(&denom_style))?; + // We ignore linebreaks in the cells as we can't differentiate + // alignment points for the whole body from ones for a specific + // cell, and multiline cells don't quite make sense at the moment. + if cell.is_multiline() { + ctx.engine.sink.warn(warning!( + cell_span, + "linebreaks are ignored in {}", children; + hint: "use commas instead to separate each line" + )); + } + ascent.set_max(cell.ascent().max(paren.ascent)); descent.set_max(cell.descent().max(paren.descent)); @@ -222,7 +225,7 @@ fn layout_mat_body( let mut y = Abs::zero(); for (cell, &(ascent, descent)) in col.into_iter().zip(&heights) { - let cell = cell.into_line_frame(&points, LeftRightAlternator::Right); + let cell = cell.into_line_frame(&points, alternator); let pos = Point::new( if points.is_empty() { x + align.position(rcol - cell.width()) diff --git a/crates/typst-layout/src/math/shared.rs b/crates/typst-layout/src/math/shared.rs index 5aebdacac2..600c130d47 100644 --- a/crates/typst-layout/src/math/shared.rs +++ b/crates/typst-layout/src/math/shared.rs @@ -117,7 +117,6 @@ pub fn stack( gap: Abs, baseline: usize, alternator: LeftRightAlternator, - minimum_ascent_descent: Option<(Abs, Abs)>, ) -> Frame { let AlignmentResult { points, width } = alignments(&rows); let rows: Vec<_> = rows @@ -125,13 +124,9 @@ pub fn stack( .map(|row| row.into_line_frame(&points, alternator)) .collect(); - let padded_height = |height: Abs| { - height.max(minimum_ascent_descent.map_or(Abs::zero(), |(a, d)| a + d)) - }; - let mut frame = Frame::soft(Size::new( width, - rows.iter().map(|row| padded_height(row.height())).sum::() + rows.iter().map(|row| row.height()).sum::() + rows.len().saturating_sub(1) as f64 * gap, )); @@ -142,14 +137,11 @@ pub fn stack( } else { Abs::zero() }; - let ascent_padded_part = minimum_ascent_descent - .map_or(Abs::zero(), |(a, _)| (a - row.ascent())) - .max(Abs::zero()); - let pos = Point::new(x, y + ascent_padded_part); + let pos = Point::new(x, y); if i == baseline { - frame.set_baseline(y + row.baseline() + ascent_padded_part); + frame.set_baseline(y + row.baseline()); } - y += padded_height(row.height()) + gap; + y += row.height() + gap; frame.push_frame(pos, row); } diff --git a/crates/typst-layout/src/math/underover.rs b/crates/typst-layout/src/math/underover.rs index 7b3617c3e5..5b6bd40eb6 100644 --- a/crates/typst-layout/src/math/underover.rs +++ b/crates/typst-layout/src/math/underover.rs @@ -312,14 +312,8 @@ fn layout_underoverspreader( } }; - let frame = stack( - rows, - FixedAlignment::Center, - gap, - baseline, - LeftRightAlternator::Right, - None, - ); + let frame = + stack(rows, FixedAlignment::Center, gap, baseline, LeftRightAlternator::Right); ctx.push(FrameFragment::new(styles, frame).with_class(body_class)); Ok(()) diff --git a/tests/ref/math-cases-linebreaks.png b/tests/ref/math-cases-linebreaks.png index 543d5384c11a270a8a56f95e91e4f5ec7ac64d3f..eb4971c46fb2d2a36a8b95324d3d1e08b7d99319 100644 GIT binary patch delta 481 zcmV<70UrLk1o{JzBYy$GNklVP4Bt@3Ph~{FOY>~-6-Ym~#Fk@(%=gn7d8s##_|N9-h|Ka!jzuw_< z_^dgPGQ4DaHy6DPTURhgFj~rC+X$d z*Zcb|6!U@Jf`%y^^#b7U_EF#qbX=t@;hh41RPS2=ydL}DDms+_!Qf*H+cWR{nR?pA zq`(??MII3`luOhtmxVHZX&+S@xjHmcBS8ICMZZCNQe*yTvW{Iao?K$2M~*ie0O?xd zfm<&i8Ue;I67~?JUDnVX5>L}krf~T9e>)`Nb!69DT3Yx)FDVSiZpn3kTjVGv*L@b; z9);nE831eTfQMp?-OV?nMup+DZMjN3pfFt9L$sWP6f+Hsn<5lQAABqA!TMHEz9cRe8q zCP#{hjKH8aMcJl}i-<6+$P9wogh3C29$-Qlq}1S~m52`p2z#sqtf$p4{Ie*W^1g3T14$a%T?k;imS}Xu*AxThl5vuxwIhJd)kH^6!2pL7V0t2{+Kd2Rt|zK1 zybSY`$6yGPRKJY2V+%JoeQ>bv>DrQZjCWjjc|X8&ESNzC7Y7YIRV(jmr_iptIA~YB z-6rqaw$XMR?0-%H7M)9VA)QztpfLkw!h6e6LH9V$hchRamc!{O9Vd7bc z>fVN>mR(XB%vvNLd=(mCZyh=!Qns)PgX2aQ^RQ|gV6G-xUS(@I1Tf56%Ny$~&Kro0$&|(nW5as&2^UIJ kA*{Z0sT*Jwtb$SJ2Wbl+-x_NR>i_@%07*qoM6N<$f>qD?{Qv*} diff --git a/tests/ref/math-equation-font.png b/tests/ref/math-equation-font.png index eb84634e5a8f2c5e42a606d12505d505d63c3294..ec3c72311b4a40abad4b53074e7870c2042087db 100644 GIT binary patch delta 1023 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 N002ovPDHLkV1gW(A;bUx delta 834 zcmV-I1HJr<1=t3VBYy)QNklcHB<5?*i@o@c^IbymBXCA^i@1L`WLh=a zgU^jTb#dE4$bXZqC~OqQm3$Ew-hkXWuaCnxN=03KIU7<@`y!?cMnklSqpBe@HF;?- zVO-0hEiSIuro|u97R=L`pIQG*hV{y#uvIaNeZ9SP|Y{X{S2<~uHaTO2m z637y7d<>?bw0amLGBfQbmKmYOl2KwS2L4!7?~jkF2PnhD2f9Yi+RdowDKo;`>7%O*OBzw zDnwhnrIR@4Jm#Qgm)i;4vX#0xqJ}sJ{KQ`Uo4}>}sEg0s5nJ2LK`BGTGxVd_eQmgM zQ>lxq;&3T$H_D+)vP7I!fGfKuzZp8Iin@3@0DsaHgt%!Khu(>} Date: Tue, 1 Apr 2025 16:42:52 +0200 Subject: [PATCH 077/558] Switch PDF backend to `krilla` (#5420) Co-authored-by: Laurenz --- Cargo.lock | 200 ++++- Cargo.toml | 5 +- crates/typst-cli/src/args.rs | 32 +- crates/typst-cli/src/compile.rs | 39 +- crates/typst-layout/src/inline/shaping.rs | 40 +- crates/typst-library/src/layout/transform.rs | 14 + .../src/visualize/image/raster.rs | 27 +- crates/typst-pdf/Cargo.toml | 11 +- crates/typst-pdf/src/catalog.rs | 385 -------- crates/typst-pdf/src/color.rs | 394 --------- crates/typst-pdf/src/color_font.rs | 344 -------- crates/typst-pdf/src/content.rs | 823 ------------------ crates/typst-pdf/src/convert.rs | 661 ++++++++++++++ crates/typst-pdf/src/embed.rs | 152 +--- crates/typst-pdf/src/extg.rs | 53 -- crates/typst-pdf/src/font.rs | 278 ------ crates/typst-pdf/src/gradient.rs | 512 ----------- crates/typst-pdf/src/image.rs | 445 +++++----- crates/typst-pdf/src/lib.rs | 751 +++------------- crates/typst-pdf/src/link.rs | 94 ++ crates/typst-pdf/src/metadata.rs | 184 ++++ crates/typst-pdf/src/named_destination.rs | 86 -- crates/typst-pdf/src/outline.rs | 141 +-- crates/typst-pdf/src/page.rs | 348 ++------ crates/typst-pdf/src/paint.rs | 379 ++++++++ crates/typst-pdf/src/resources.rs | 349 -------- crates/typst-pdf/src/shape.rs | 106 +++ crates/typst-pdf/src/text.rs | 135 +++ crates/typst-pdf/src/tiling.rs | 184 ---- crates/typst-pdf/src/util.rs | 120 +++ 30 files changed, 2421 insertions(+), 4871 deletions(-) delete mode 100644 crates/typst-pdf/src/catalog.rs delete mode 100644 crates/typst-pdf/src/color.rs delete mode 100644 crates/typst-pdf/src/color_font.rs delete mode 100644 crates/typst-pdf/src/content.rs create mode 100644 crates/typst-pdf/src/convert.rs delete mode 100644 crates/typst-pdf/src/extg.rs delete mode 100644 crates/typst-pdf/src/font.rs delete mode 100644 crates/typst-pdf/src/gradient.rs create mode 100644 crates/typst-pdf/src/link.rs create mode 100644 crates/typst-pdf/src/metadata.rs delete mode 100644 crates/typst-pdf/src/named_destination.rs create mode 100644 crates/typst-pdf/src/paint.rs delete mode 100644 crates/typst-pdf/src/resources.rs create mode 100644 crates/typst-pdf/src/shape.rs create mode 100644 crates/typst-pdf/src/text.rs delete mode 100644 crates/typst-pdf/src/tiling.rs create mode 100644 crates/typst-pdf/src/util.rs diff --git a/Cargo.lock b/Cargo.lock index 630eade2fa..c13c648191 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,6 +217,20 @@ name = "bytemuck" version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef657dfab802224e671f5818e9a4935f9b1957ed18e58292690cc39e7a4092a3" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fa76293b4f7bb636ab88fd78228235b5248b4d05cc589aed610f954af5d7c7a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "byteorder" @@ -735,11 +749,12 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.35" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" +checksum = "11faaf5a5236997af9848be0bef4db95824b1d534ebc64d0f0c6cf3e67bd38dc" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] @@ -749,6 +764,15 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -761,6 +785,15 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" +[[package]] +name = "font-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d868ec188a98bb014c606072edd47e52e7ab7297db943b0b28503121e1d037bd" +dependencies = [ + "bytemuck", +] + [[package]] name = "fontconfig-parser" version = "0.5.7" @@ -829,6 +862,15 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "getopts" version = "0.2.21" @@ -966,7 +1008,7 @@ checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" dependencies = [ "displaydoc", "serde", - "yoke", + "yoke 0.7.5", "zerofrom", "zerovec", ] @@ -1064,7 +1106,7 @@ dependencies = [ "stable_deref_trait", "tinystr", "writeable", - "yoke", + "yoke 0.7.5", "zerofrom", "zerovec", ] @@ -1310,6 +1352,48 @@ dependencies = [ "libc", ] +[[package]] +name = "krilla" +version = "0.3.0" +source = "git+https://github.com/LaurenzV/krilla?rev=14756f7#14756f7067cb1a80b73b712cae9f98597153e623" +dependencies = [ + "base64", + "bumpalo", + "comemo", + "flate2", + "float-cmp 0.10.0", + "fxhash", + "gif", + "image-webp", + "imagesize", + "once_cell", + "pdf-writer", + "png", + "rayon", + "rustybuzz", + "siphasher", + "skrifa", + "subsetter", + "tiny-skia-path", + "xmp-writer", + "yoke 0.8.0", + "zune-jpeg", +] + +[[package]] +name = "krilla-svg" +version = "0.3.0" +source = "git+https://github.com/LaurenzV/krilla?rev=14756f7#14756f7067cb1a80b73b712cae9f98597153e623" +dependencies = [ + "flate2", + "fontdb", + "krilla", + "png", + "resvg", + "tiny-skia", + "usvg", +] + [[package]] name = "kurbo" version = "0.11.1" @@ -1371,6 +1455,15 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "libz-rs-sys" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "902bc563b5d65ad9bba616b490842ef0651066a1a1dc3ce1087113ffcb873c8d" +dependencies = [ + "zlib-rs", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -1458,9 +1551,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8402cab7aefae129c6977bb0ff1b8fd9a04eb5b51efc50a70bea51cda0c7924" +checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" dependencies = [ "adler2", "simd-adler32", @@ -1739,8 +1832,7 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "pdf-writer" version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5df03c7d216de06f93f398ef06f1385a60f2c597bb96f8195c8d98e08a26b1d5" +source = "git+https://github.com/typst/pdf-writer?rev=0d513b9#0d513b9050d2f1a0507cabb4898aca971af6da98" dependencies = [ "bitflags 2.8.0", "itoa", @@ -1997,6 +2089,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "read-fonts" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f004ee5c610b8beb5f33273246893ac6258ec22185a6eb8ee132bccdb904cdaa" +dependencies = [ + "bytemuck", + "font-types", +] + [[package]] name = "redox_syscall" version = "0.5.8" @@ -2315,6 +2417,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +[[package]] +name = "skrifa" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e7936ca3627fdb516e97aca3c8ab5103f94ae32fe5ce80a0a02edcbacb7b53" +dependencies = [ + "bytemuck", + "read-fonts", +] + [[package]] name = "slotmap" version = "1.0.7" @@ -2361,7 +2473,7 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" dependencies = [ - "float-cmp", + "float-cmp 0.9.0", ] [[package]] @@ -2405,27 +2517,9 @@ dependencies = [ [[package]] name = "subsetter" version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74f98178f34057d4d4de93d68104007c6dea4dfac930204a69ab4622daefa648" - -[[package]] -name = "svg2pdf" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e50dc062439cc1a396181059c80932a6e6bd731b130e674c597c0c8874b6df22" +source = "git+https://github.com/typst/subsetter?rev=460fdb6#460fdb66d6e0138b721b1ca9882faf15ce003246" dependencies = [ - "fontdb", - "image", - "log", - "miniz_oxide", - "once_cell", - "pdf-writer", - "resvg", - "siphasher", - "subsetter", - "tiny-skia", - "ttf-parser", - "usvg", + "fxhash", ] [[package]] @@ -3018,26 +3112,19 @@ dependencies = [ name = "typst-pdf" version = "0.13.1" dependencies = [ - "arrayvec", - "base64", "bytemuck", "comemo", "ecow", "image", - "indexmap 2.7.1", - "miniz_oxide", - "pdf-writer", + "krilla", + "krilla-svg", "serde", - "subsetter", - "svg2pdf", - "ttf-parser", "typst-assets", "typst-library", "typst-macros", "typst-syntax", "typst-timing", "typst-utils", - "xmp-writer", ] [[package]] @@ -3662,8 +3749,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xmp-writer" version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb5954c9ca6dcc869e98d3e42760ed9dab08f3e70212b31d7ab8ae7f3b7a487" +source = "git+https://github.com/LaurenzV/xmp-writer?rev=a1cbb887#a1cbb887a84376fea4d7590d41c194a332840549" [[package]] name = "xz2" @@ -3701,7 +3787,19 @@ checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" dependencies = [ "serde", "stable_deref_trait", - "yoke-derive", + "yoke-derive 0.7.5", + "zerofrom", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive 0.8.0", "zerofrom", ] @@ -3717,6 +3815,18 @@ dependencies = [ "synstructure", ] +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.7.35" @@ -3778,7 +3888,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" dependencies = [ "serde", - "yoke", + "yoke 0.7.5", "zerofrom", "zerovec-derive", ] @@ -3809,6 +3919,12 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zlib-rs" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b20717f0917c908dc63de2e44e97f1e6b126ca58d0e391cee86d504eb8fbd05" + [[package]] name = "zopfli" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index a732418327..cbe69a05d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,11 +71,12 @@ if_chain = "1" image = { version = "0.25.5", default-features = false, features = ["png", "jpeg", "gif"] } indexmap = { version = "2", features = ["serde"] } kamadak-exif = "0.6" +krilla = { git = "https://github.com/LaurenzV/krilla", rev = "14756f7", default-features = false, features = ["raster-images", "comemo", "rayon"] } +krilla-svg = { git = "https://github.com/LaurenzV/krilla", rev = "14756f7" } kurbo = "0.11" libfuzzer-sys = "0.4" lipsum = "0.9" memchr = "2" -miniz_oxide = "0.8" native-tls = "0.2" notify = "8" once_cell = "1" @@ -113,7 +114,6 @@ siphasher = "1" smallvec = { version = "1.11.1", features = ["union", "const_generics", "const_new"] } stacker = "0.1.15" subsetter = "0.2" -svg2pdf = "0.13" syn = { version = "2", features = ["full", "extra-traits"] } syntect = { version = "5", default-features = false, features = ["parsing", "regex-fancy", "plist-load", "yaml-load"] } tar = "0.4" @@ -140,7 +140,6 @@ wasmi = "0.40.0" web-sys = "0.3" xmlparser = "0.13.5" xmlwriter = "0.1.0" -xmp-writer = "0.3.1" xz2 = { version = "0.1", features = ["static"] } yaml-front-matter = "0.1" zip = { version = "2.5", default-features = false, features = ["deflate"] } diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index 76f6472760..fd0eb5f055 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -467,15 +467,45 @@ display_possible_values!(Feature); #[derive(Debug, Copy, Clone, Eq, PartialEq, ValueEnum)] #[allow(non_camel_case_types)] pub enum PdfStandard { + /// PDF 1.4. + #[value(name = "1.4")] + V_1_4, + /// PDF 1.5. + #[value(name = "1.5")] + V_1_5, + /// PDF 1.5. + #[value(name = "1.6")] + V_1_6, /// PDF 1.7. #[value(name = "1.7")] V_1_7, + /// PDF 2.0. + #[value(name = "2.0")] + V_2_0, + /// PDF/A-1b. + #[value(name = "a-1b")] + A_1b, /// PDF/A-2b. #[value(name = "a-2b")] A_2b, - /// PDF/A-3b. + /// PDF/A-2u. + #[value(name = "a-2u")] + A_2u, + /// PDF/A-3u. #[value(name = "a-3b")] A_3b, + /// PDF/A-3u. + #[value(name = "a-3u")] + A_3u, + /// PDF/A-4. + #[value(name = "a-4")] + A_4, + /// PDF/A-4f. + #[value(name = "a-4f")] + A_4f, + /// PDF/A-4e. + #[value(name = "a-4e")] + A_4e, } display_possible_values!(PdfStandard); diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index ae71e298cc..4edb4c3239 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -63,8 +63,7 @@ pub struct CompileConfig { /// 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. + /// A list of standards the PDF should conform to. pub pdf_standards: PdfStandards, /// A path to write a Makefile rule describing the current compilation. pub make_deps: Option, @@ -130,18 +129,9 @@ impl CompileConfig { 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, - PdfStandard::A_3b => typst_pdf::PdfStandard::A_3b, - }) - .collect::>(); - PdfStandards::new(&list)? - }; + let pdf_standards = PdfStandards::new( + &args.pdf_standard.iter().copied().map(Into::into).collect::>(), + )?; #[cfg(feature = "http-server")] let server = match watch { @@ -295,6 +285,7 @@ fn export_pdf(document: &PagedDocument, config: &CompileConfig) -> SourceResult< }) } }; + let options = PdfOptions { ident: Smart::Auto, timestamp, @@ -765,3 +756,23 @@ impl<'a> codespan_reporting::files::Files<'a> for SystemWorld { }) } } + +impl From for typst_pdf::PdfStandard { + fn from(standard: PdfStandard) -> Self { + match standard { + PdfStandard::V_1_4 => typst_pdf::PdfStandard::V_1_4, + PdfStandard::V_1_5 => typst_pdf::PdfStandard::V_1_5, + PdfStandard::V_1_6 => typst_pdf::PdfStandard::V_1_6, + PdfStandard::V_1_7 => typst_pdf::PdfStandard::V_1_7, + PdfStandard::V_2_0 => typst_pdf::PdfStandard::V_2_0, + PdfStandard::A_1b => typst_pdf::PdfStandard::A_1b, + PdfStandard::A_2b => typst_pdf::PdfStandard::A_2b, + PdfStandard::A_2u => typst_pdf::PdfStandard::A_2u, + PdfStandard::A_3b => typst_pdf::PdfStandard::A_3b, + PdfStandard::A_3u => typst_pdf::PdfStandard::A_3u, + PdfStandard::A_4 => typst_pdf::PdfStandard::A_4, + PdfStandard::A_4f => typst_pdf::PdfStandard::A_4f, + PdfStandard::A_4e => typst_pdf::PdfStandard::A_4e, + } + } +} diff --git a/crates/typst-layout/src/inline/shaping.rs b/crates/typst-layout/src/inline/shaping.rs index 159619eb3a..8236d1e367 100644 --- a/crates/typst-layout/src/inline/shaping.rs +++ b/crates/typst-layout/src/inline/shaping.rs @@ -824,12 +824,42 @@ fn shape_segment<'a>( // Add the glyph to the shaped output. if info.glyph_id != 0 && is_covered(cluster) { - // Determine the text range of the glyph. + // Assume we have the following sequence of (glyph_id, cluster): + // [(120, 0), (80, 0), (3, 3), (755, 4), (69, 4), (424, 13), + // (63, 13), (193, 25), (80, 25), (3, 31) + // + // We then want the sequence of (glyph_id, text_range) to look as follows: + // [(120, 0..3), (80, 0..3), (3, 3..4), (755, 4..13), (69, 4..13), + // (424, 13..25), (63, 13..25), (193, 25..31), (80, 25..31), (3, 31..x)] + // + // Each glyph in the same cluster should be assigned the full text + // range. This is necessary because only this way krilla can + // properly assign `ActualText` attributes in complex shaping + // scenarios. + + // The start of the glyph's text range. let start = base + cluster; - let end = base - + if ltr { i.checked_add(1) } else { i.checked_sub(1) } - .and_then(|last| infos.get(last)) - .map_or(text.len(), |info| info.cluster as usize); + + // Determine the end of the glyph's text range. + let mut k = i; + let step: isize = if ltr { 1 } else { -1 }; + let end = loop { + // If we've reached the end of the glyphs, the `end` of the + // range should be the end of the full text. + let Some((next, next_info)) = k + .checked_add_signed(step) + .and_then(|n| infos.get(n).map(|info| (n, info))) + else { + break base + text.len(); + }; + + // If the cluster doesn't match anymore, we've reached the end. + if next_info.cluster != info.cluster { + break base + next_info.cluster as usize; + } + + k = next; + }; let c = text[cluster..].chars().next().unwrap(); let script = c.script(); diff --git a/crates/typst-library/src/layout/transform.rs b/crates/typst-library/src/layout/transform.rs index 183df6098f..d153d97dbf 100644 --- a/crates/typst-library/src/layout/transform.rs +++ b/crates/typst-library/src/layout/transform.rs @@ -307,6 +307,20 @@ impl Transform { Self { sx, sy, ..Self::identity() } } + /// A scale transform at a specific position. + pub fn scale_at(sx: Ratio, sy: Ratio, px: Abs, py: Abs) -> Self { + Self::translate(px, py) + .pre_concat(Self::scale(sx, sy)) + .pre_concat(Self::translate(-px, -py)) + } + + /// A rotate transform at a specific position. + pub fn rotate_at(angle: Angle, px: Abs, py: Abs) -> Self { + Self::translate(px, py) + .pre_concat(Self::rotate(angle)) + .pre_concat(Self::translate(-px, -py)) + } + /// A rotate transform. pub fn rotate(angle: Angle) -> Self { let cos = Ratio::new(angle.cos()); diff --git a/crates/typst-library/src/visualize/image/raster.rs b/crates/typst-library/src/visualize/image/raster.rs index 453b94066e..21d5b18fc6 100644 --- a/crates/typst-library/src/visualize/image/raster.rs +++ b/crates/typst-library/src/visualize/image/raster.rs @@ -3,6 +3,8 @@ use std::hash::{Hash, Hasher}; use std::io; use std::sync::Arc; +use crate::diag::{bail, StrResult}; +use crate::foundations::{cast, dict, Bytes, Cast, Dict, Smart, Value}; use ecow::{eco_format, EcoString}; use image::codecs::gif::GifDecoder; use image::codecs::jpeg::JpegDecoder; @@ -11,9 +13,6 @@ use image::{ guess_format, DynamicImage, ImageBuffer, ImageDecoder, ImageResult, Limits, Pixel, }; -use crate::diag::{bail, StrResult}; -use crate::foundations::{cast, dict, Bytes, Cast, Dict, Smart, Value}; - /// A decoded raster image. #[derive(Clone, Hash)] pub struct RasterImage(Arc); @@ -22,7 +21,8 @@ pub struct RasterImage(Arc); struct Repr { data: Bytes, format: RasterFormat, - dynamic: image::DynamicImage, + dynamic: Arc, + exif_rotation: Option, icc: Option, dpi: Option, } @@ -50,6 +50,8 @@ impl RasterImage { format: RasterFormat, icc: Smart, ) -> StrResult { + let mut exif_rot = None; + let (dynamic, icc, dpi) = match format { RasterFormat::Exchange(format) => { fn decode( @@ -85,6 +87,7 @@ impl RasterImage { // Apply rotation from EXIF metadata. if let Some(rotation) = exif.as_ref().and_then(exif_rotation) { apply_rotation(&mut dynamic, rotation); + exif_rot = Some(rotation); } // Extract pixel density. @@ -136,7 +139,14 @@ impl RasterImage { } }; - Ok(Self(Arc::new(Repr { data, format, dynamic, icc, dpi }))) + Ok(Self(Arc::new(Repr { + data, + format, + exif_rotation: exif_rot, + dynamic: Arc::new(dynamic), + icc, + dpi, + }))) } /// The raw image data. @@ -159,6 +169,11 @@ impl RasterImage { self.dynamic().height() } + /// TODO. + pub fn exif_rotation(&self) -> Option { + self.0.exif_rotation + } + /// The image's pixel density in pixels per inch, if known. /// /// This is guaranteed to be positive. @@ -167,7 +182,7 @@ impl RasterImage { } /// Access the underlying dynamic image. - pub fn dynamic(&self) -> &image::DynamicImage { + pub fn dynamic(&self) -> &Arc { &self.0.dynamic } diff --git a/crates/typst-pdf/Cargo.toml b/crates/typst-pdf/Cargo.toml index bc0da06c37..f6f08b5bc3 100644 --- a/crates/typst-pdf/Cargo.toml +++ b/crates/typst-pdf/Cargo.toml @@ -19,20 +19,13 @@ typst-macros = { workspace = true } typst-syntax = { workspace = true } typst-timing = { workspace = true } typst-utils = { workspace = true } -arrayvec = { workspace = true } -base64 = { workspace = true } bytemuck = { workspace = true } comemo = { workspace = true } ecow = { workspace = true } image = { workspace = true } -indexmap = { workspace = true } -miniz_oxide = { workspace = true } -pdf-writer = { workspace = true } +krilla = { workspace = true } +krilla-svg = { workspace = true } serde = { workspace = true } -subsetter = { workspace = true } -svg2pdf = { workspace = true } -ttf-parser = { workspace = true } -xmp-writer = { workspace = true } [lints] workspace = true diff --git a/crates/typst-pdf/src/catalog.rs b/crates/typst-pdf/src/catalog.rs deleted file mode 100644 index 709b015532..0000000000 --- a/crates/typst-pdf/src/catalog.rs +++ /dev/null @@ -1,385 +0,0 @@ -use std::num::NonZeroUsize; - -use ecow::eco_format; -use pdf_writer::types::Direction; -use pdf_writer::writers::PageLabel; -use pdf_writer::{Finish, Name, Pdf, Ref, Str, TextStr}; -use typst_library::diag::{bail, SourceResult}; -use typst_library::foundations::{Datetime, Smart}; -use typst_library::layout::Dir; -use typst_library::text::Lang; -use typst_syntax::Span; -use xmp_writer::{DateTime, LangId, RenditionClass, XmpWriter}; - -use crate::page::PdfPageLabel; -use crate::{hash_base64, outline, TextStrExt, Timestamp, Timezone, WithEverything}; - -/// Write the document catalog. -pub fn write_catalog( - ctx: WithEverything, - pdf: &mut Pdf, - alloc: &mut Ref, -) -> SourceResult<()> { - let lang = ctx - .resources - .languages - .iter() - .max_by_key(|(_, &count)| count) - .map(|(&l, _)| l); - - let dir = if lang.map(Lang::dir) == Some(Dir::RTL) { - Direction::R2L - } else { - Direction::L2R - }; - - // Write the outline tree. - let outline_root_id = outline::write_outline(pdf, alloc, &ctx); - - // Write the page labels. - let page_labels = write_page_labels(pdf, alloc, &ctx); - - // Write the document information. - let info_ref = alloc.bump(); - let mut info = pdf.document_info(info_ref); - let mut xmp = XmpWriter::new(); - if let Some(title) = &ctx.document.info.title { - info.title(TextStr::trimmed(title)); - xmp.title([(None, title.as_str())]); - } - - if let Some(description) = &ctx.document.info.description { - info.subject(TextStr::trimmed(description)); - xmp.description([(None, description.as_str())]); - } - - let authors = &ctx.document.info.author; - if !authors.is_empty() { - // Turns out that if the authors are given in both the document - // information dictionary and the XMP metadata, Acrobat takes a little - // bit of both: The first author from the document information - // dictionary and the remaining authors from the XMP metadata. - // - // To fix this for Acrobat, we could omit the remaining authors or all - // metadata from the document information catalog (it is optional) and - // only write XMP. However, not all other tools (including Apple - // Preview) read the XMP data. This means we do want to include all - // authors in the document information dictionary. - // - // Thus, the only alternative is to fold all authors into a single - // `` in the XMP metadata. This is, in fact, exactly what the - // PDF/A spec Part 1 section 6.7.3 has to say about the matter. It's a - // bit weird to not use the array (and it makes Acrobat show the author - // list in quotes), but there's not much we can do about that. - let joined = authors.join(", "); - info.author(TextStr::trimmed(&joined)); - xmp.creator([joined.as_str()]); - } - - let creator = eco_format!("Typst {}", env!("CARGO_PKG_VERSION")); - info.creator(TextStr(&creator)); - xmp.creator_tool(&creator); - - let keywords = &ctx.document.info.keywords; - if !keywords.is_empty() { - let joined = keywords.join(", "); - info.keywords(TextStr::trimmed(&joined)); - xmp.pdf_keywords(&joined); - } - let (date, tz) = document_date(ctx.document.info.date, ctx.options.timestamp); - if let Some(pdf_date) = date.and_then(|date| pdf_date(date, tz)) { - info.creation_date(pdf_date); - info.modified_date(pdf_date); - } - - info.finish(); - - // A unique ID for this instance of the document. Changes if anything - // changes in the frames. - let instance_id = hash_base64(&pdf.as_bytes()); - - // Determine the document's ID. It should be as stable as possible. - const PDF_VERSION: &str = "PDF-1.7"; - let doc_id = if let Smart::Custom(ident) = ctx.options.ident { - // We were provided with a stable ID. Yay! - hash_base64(&(PDF_VERSION, ident)) - } else if ctx.document.info.title.is_some() && !ctx.document.info.author.is_empty() { - // If not provided from the outside, but title and author were given, we - // compute a hash of them, which should be reasonably stable and unique. - hash_base64(&(PDF_VERSION, &ctx.document.info.title, &ctx.document.info.author)) - } else { - // The user provided no usable metadata which we can use as an `/ID`. - instance_id.clone() - }; - - xmp.document_id(&doc_id); - xmp.instance_id(&instance_id); - xmp.format("application/pdf"); - xmp.pdf_version("1.7"); - xmp.language(ctx.resources.languages.keys().map(|lang| LangId(lang.as_str()))); - xmp.num_pages(ctx.document.pages.len() as u32); - xmp.rendition_class(RenditionClass::Proof); - - if let Some(xmp_date) = date.and_then(|date| xmp_date(date, tz)) { - xmp.create_date(xmp_date); - xmp.modify_date(xmp_date); - - if ctx.options.standards.pdfa { - let mut history = xmp.history(); - history - .add_event() - .action(xmp_writer::ResourceEventAction::Saved) - .when(xmp_date) - .instance_id(&eco_format!("{instance_id}_source")); - history - .add_event() - .action(xmp_writer::ResourceEventAction::Converted) - .when(xmp_date) - .instance_id(&instance_id) - .software_agent(&creator); - } - } - - // Assert dominance. - if let Some((part, conformance)) = ctx.options.standards.pdfa_part { - let mut extension_schemas = xmp.extension_schemas(); - extension_schemas - .xmp_media_management() - .properties() - .describe_instance_id(); - extension_schemas.pdf().properties().describe_all(); - extension_schemas.finish(); - xmp.pdfa_part(part); - xmp.pdfa_conformance(conformance); - } - - let xmp_buf = xmp.finish(None); - let meta_ref = alloc.bump(); - pdf.stream(meta_ref, xmp_buf.as_bytes()) - .pair(Name(b"Type"), Name(b"Metadata")) - .pair(Name(b"Subtype"), Name(b"XML")); - - // Set IDs only now, so that we don't need to clone them. - pdf.set_file_id((doc_id.into_bytes(), instance_id.into_bytes())); - - // Write the document catalog. - let catalog_ref = alloc.bump(); - let mut catalog = pdf.catalog(catalog_ref); - catalog.pages(ctx.page_tree_ref); - catalog.viewer_preferences().direction(dir); - catalog.metadata(meta_ref); - - let has_dests = !ctx.references.named_destinations.dests.is_empty(); - let has_embeddings = !ctx.references.embedded_files.is_empty(); - - // Write the `/Names` dictionary. - if has_dests || has_embeddings { - // Write the named destination tree if there are any entries. - let mut name_dict = catalog.names(); - if has_dests { - let mut dests_name_tree = name_dict.destinations(); - let mut names = dests_name_tree.names(); - for &(name, dest_ref, ..) in &ctx.references.named_destinations.dests { - names.insert(Str(name.resolve().as_bytes()), dest_ref); - } - } - - if has_embeddings { - let mut embedded_files = name_dict.embedded_files(); - let mut names = embedded_files.names(); - for (name, file_ref) in &ctx.references.embedded_files { - names.insert(Str(name.as_bytes()), *file_ref); - } - } - } - - if has_embeddings && ctx.options.standards.pdfa { - // PDF 2.0, but ISO 19005-3 (PDF/A-3) Annex E allows it for PDF/A-3. - let mut associated_files = catalog.insert(Name(b"AF")).array().typed(); - for (_, file_ref) in ctx.references.embedded_files { - associated_files.item(file_ref).finish(); - } - } - - // Insert the page labels. - if !page_labels.is_empty() { - let mut num_tree = catalog.page_labels(); - let mut entries = num_tree.nums(); - for (n, r) in &page_labels { - entries.insert(n.get() as i32 - 1, *r); - } - } - - if let Some(outline_root_id) = outline_root_id { - catalog.outlines(outline_root_id); - } - - if let Some(lang) = lang { - catalog.lang(TextStr(lang.as_str())); - } - - if ctx.options.standards.pdfa { - catalog - .output_intents() - .push() - .subtype(pdf_writer::types::OutputIntentSubtype::PDFA) - .output_condition(TextStr("sRGB")) - .output_condition_identifier(TextStr("Custom")) - .info(TextStr("sRGB IEC61966-2.1")) - .dest_output_profile(ctx.globals.color_functions.srgb.unwrap()); - } - - catalog.finish(); - - if ctx.options.standards.pdfa && pdf.refs().count() > 8388607 { - bail!(Span::detached(), "too many PDF objects"); - } - - Ok(()) -} - -/// Write the page labels. -pub(crate) fn write_page_labels( - chunk: &mut Pdf, - alloc: &mut Ref, - ctx: &WithEverything, -) -> Vec<(NonZeroUsize, Ref)> { - // If there is no exported page labeled, we skip the writing - if !ctx.pages.iter().filter_map(Option::as_ref).any(|p| { - p.label - .as_ref() - .is_some_and(|l| l.prefix.is_some() || l.style.is_some()) - }) { - return Vec::new(); - } - - let empty_label = PdfPageLabel::default(); - let mut result = vec![]; - let mut prev: Option<&PdfPageLabel> = None; - - // Skip non-exported pages for numbering. - for (i, page) in ctx.pages.iter().filter_map(Option::as_ref).enumerate() { - let nr = NonZeroUsize::new(1 + i).unwrap(); - // If there are pages with empty labels between labeled pages, we must - // write empty PageLabel entries. - let label = page.label.as_ref().unwrap_or(&empty_label); - - if let Some(pre) = prev { - if label.prefix == pre.prefix - && label.style == pre.style - && label.offset == pre.offset.map(|n| n.saturating_add(1)) - { - prev = Some(label); - continue; - } - } - - let id = alloc.bump(); - let mut entry = chunk.indirect(id).start::(); - - // Only add what is actually provided. Don't add empty prefix string if - // it wasn't given for example. - if let Some(prefix) = &label.prefix { - entry.prefix(TextStr::trimmed(prefix)); - } - - if let Some(style) = label.style { - entry.style(style.to_pdf_numbering_style()); - } - - if let Some(offset) = label.offset { - entry.offset(offset.get() as i32); - } - - result.push((nr, id)); - prev = Some(label); - } - - result -} - -/// Resolve the document date. -/// -/// (1) If the `document.date` is set to specific `datetime` or `none`, use it. -/// (2) If the `document.date` is set to `auto` or not set, try to use the -/// date from the options. -/// (3) Otherwise, we don't write date metadata. -pub fn document_date( - document_date: Smart>, - timestamp: Option, -) -> (Option, Option) { - match (document_date, timestamp) { - (Smart::Custom(date), _) => (date, None), - (Smart::Auto, Some(timestamp)) => { - (Some(timestamp.datetime), Some(timestamp.timezone)) - } - _ => (None, None), - } -} - -/// Converts a datetime to a pdf-writer date. -pub fn pdf_date(datetime: Datetime, tz: Option) -> Option { - let year = datetime.year().filter(|&y| y >= 0)? as u16; - - let mut pdf_date = pdf_writer::Date::new(year); - - if let Some(month) = datetime.month() { - pdf_date = pdf_date.month(month); - } - - if let Some(day) = datetime.day() { - pdf_date = pdf_date.day(day); - } - - if let Some(h) = datetime.hour() { - pdf_date = pdf_date.hour(h); - } - - if let Some(m) = datetime.minute() { - pdf_date = pdf_date.minute(m); - } - - if let Some(s) = datetime.second() { - pdf_date = pdf_date.second(s); - } - - match tz { - Some(Timezone::UTC) => { - pdf_date = pdf_date.utc_offset_hour(0).utc_offset_minute(0) - } - Some(Timezone::Local { hour_offset, minute_offset }) => { - pdf_date = - pdf_date.utc_offset_hour(hour_offset).utc_offset_minute(minute_offset) - } - None => {} - } - - Some(pdf_date) -} - -/// Converts a datetime to an xmp-writer datetime. -fn xmp_date( - datetime: Datetime, - timezone: Option, -) -> Option { - let year = datetime.year().filter(|&y| y >= 0)? as u16; - let timezone = timezone.map(|tz| match tz { - Timezone::UTC => xmp_writer::Timezone::Utc, - Timezone::Local { hour_offset, minute_offset } => { - // The xmp-writer use signed integers for the minute offset, which - // can be buggy if the minute offset is negative. And because our - // minute_offset is ensured to be `0 <= minute_offset < 60`, we can - // safely cast it to a signed integer. - xmp_writer::Timezone::Local { hour: hour_offset, minute: minute_offset as i8 } - } - }); - Some(DateTime { - year, - month: datetime.month(), - day: datetime.day(), - hour: datetime.hour(), - minute: datetime.minute(), - second: datetime.second(), - timezone, - }) -} diff --git a/crates/typst-pdf/src/color.rs b/crates/typst-pdf/src/color.rs deleted file mode 100644 index 412afca9a5..0000000000 --- a/crates/typst-pdf/src/color.rs +++ /dev/null @@ -1,394 +0,0 @@ -use std::sync::LazyLock; - -use arrayvec::ArrayVec; -use pdf_writer::{writers, Chunk, Dict, Filter, Name, Ref}; -use typst_library::diag::{bail, SourceResult}; -use typst_library::visualize::{Color, ColorSpace, Paint}; -use typst_syntax::Span; - -use crate::{content, deflate, PdfChunk, PdfOptions, Renumber, WithResources}; - -// The names of the color spaces. -pub const SRGB: Name<'static> = Name(b"srgb"); -pub const D65_GRAY: Name<'static> = Name(b"d65gray"); -pub const LINEAR_SRGB: Name<'static> = Name(b"linearrgb"); - -// The ICC profiles. -static SRGB_ICC_DEFLATED: LazyLock> = - LazyLock::new(|| deflate(typst_assets::icc::S_RGB_V4)); -static GRAY_ICC_DEFLATED: LazyLock> = - LazyLock::new(|| deflate(typst_assets::icc::S_GREY_V4)); - -/// The color spaces present in the PDF document -#[derive(Default)] -pub struct ColorSpaces { - use_srgb: bool, - use_d65_gray: bool, - use_linear_rgb: bool, -} - -impl ColorSpaces { - /// Mark a color space as used. - pub fn mark_as_used(&mut self, color_space: ColorSpace) { - match color_space { - ColorSpace::Oklch - | ColorSpace::Oklab - | ColorSpace::Hsl - | ColorSpace::Hsv - | ColorSpace::Srgb => { - self.use_srgb = true; - } - ColorSpace::D65Gray => { - self.use_d65_gray = true; - } - ColorSpace::LinearRgb => { - self.use_linear_rgb = true; - } - ColorSpace::Cmyk => {} - } - } - - /// Write the color spaces to the PDF file. - pub fn write_color_spaces(&self, mut spaces: Dict, refs: &ColorFunctionRefs) { - if self.use_srgb { - write(ColorSpace::Srgb, spaces.insert(SRGB).start(), refs); - } - - if self.use_d65_gray { - write(ColorSpace::D65Gray, spaces.insert(D65_GRAY).start(), refs); - } - - if self.use_linear_rgb { - write(ColorSpace::LinearRgb, spaces.insert(LINEAR_SRGB).start(), refs); - } - } - - /// Write the necessary color spaces functions and ICC profiles to the - /// PDF file. - pub fn write_functions(&self, chunk: &mut Chunk, refs: &ColorFunctionRefs) { - // Write the sRGB color space. - if let Some(id) = refs.srgb { - chunk - .icc_profile(id, &SRGB_ICC_DEFLATED) - .n(3) - .range([0.0, 1.0, 0.0, 1.0, 0.0, 1.0]) - .filter(Filter::FlateDecode); - } - - // Write the gray color space. - if let Some(id) = refs.d65_gray { - chunk - .icc_profile(id, &GRAY_ICC_DEFLATED) - .n(1) - .range([0.0, 1.0]) - .filter(Filter::FlateDecode); - } - } - - /// Merge two color space usage information together: a given color space is - /// considered to be used if it is used on either side. - pub fn merge(&mut self, other: &Self) { - self.use_d65_gray |= other.use_d65_gray; - self.use_linear_rgb |= other.use_linear_rgb; - self.use_srgb |= other.use_srgb; - } -} - -/// Write the color space. -pub fn write( - color_space: ColorSpace, - writer: writers::ColorSpace, - refs: &ColorFunctionRefs, -) { - match color_space { - ColorSpace::Srgb - | ColorSpace::Oklab - | ColorSpace::Hsl - | ColorSpace::Hsv - | ColorSpace::Oklch => writer.icc_based(refs.srgb.unwrap()), - ColorSpace::D65Gray => writer.icc_based(refs.d65_gray.unwrap()), - ColorSpace::LinearRgb => { - writer.cal_rgb( - [0.9505, 1.0, 1.0888], - None, - Some([1.0, 1.0, 1.0]), - Some([ - 0.4124, 0.2126, 0.0193, 0.3576, 0.715, 0.1192, 0.1805, 0.0722, 0.9505, - ]), - ); - } - ColorSpace::Cmyk => writer.device_cmyk(), - } -} - -/// Global references for color conversion functions. -/// -/// These functions are only written once (at most, they are not written if not -/// needed) in the final document, and be shared by all color space -/// dictionaries. -pub struct ColorFunctionRefs { - pub srgb: Option, - d65_gray: Option, -} - -impl Renumber for ColorFunctionRefs { - fn renumber(&mut self, offset: i32) { - if let Some(r) = &mut self.srgb { - r.renumber(offset); - } - if let Some(r) = &mut self.d65_gray { - r.renumber(offset); - } - } -} - -/// Allocate all necessary [`ColorFunctionRefs`]. -pub fn alloc_color_functions_refs( - context: &WithResources, -) -> SourceResult<(PdfChunk, ColorFunctionRefs)> { - let mut chunk = PdfChunk::new(); - let mut used_color_spaces = ColorSpaces::default(); - - if context.options.standards.pdfa { - used_color_spaces.mark_as_used(ColorSpace::Srgb); - } - - context.resources.traverse(&mut |r| { - used_color_spaces.merge(&r.colors); - Ok(()) - })?; - - let refs = ColorFunctionRefs { - srgb: if used_color_spaces.use_srgb { Some(chunk.alloc()) } else { None }, - d65_gray: if used_color_spaces.use_d65_gray { Some(chunk.alloc()) } else { None }, - }; - - Ok((chunk, refs)) -} - -/// Encodes the color into four f32s, which can be used in a PDF file. -/// Ensures that the values are in the range [0.0, 1.0]. -/// -/// # Why? -/// - Oklab: The a and b components are in the range [-0.5, 0.5] and the PDF -/// specifies (and some readers enforce) that all color values be in the range -/// [0.0, 1.0]. This means that the PostScript function and the encoded color -/// must be offset by 0.5. -/// - HSV/HSL: The hue component is in the range [0.0, 360.0] and the PDF format -/// specifies that it must be in the range [0.0, 1.0]. This means that the -/// PostScript function and the encoded color must be divided by 360.0. -pub trait ColorEncode { - /// Performs the color to PDF f32 array conversion. - fn encode(&self, color: Color) -> [f32; 4]; -} - -impl ColorEncode for ColorSpace { - fn encode(&self, color: Color) -> [f32; 4] { - match self { - ColorSpace::Oklab | ColorSpace::Oklch | ColorSpace::Hsl | ColorSpace::Hsv => { - color.to_space(ColorSpace::Srgb).to_vec4() - } - _ => color.to_space(*self).to_vec4(), - } - } -} - -/// Encodes a paint into either a fill or stroke color. -pub(super) trait PaintEncode { - /// Set the paint as the fill color. - fn set_as_fill( - &self, - ctx: &mut content::Builder, - on_text: bool, - transforms: content::Transforms, - ) -> SourceResult<()>; - - /// Set the paint as the stroke color. - fn set_as_stroke( - &self, - ctx: &mut content::Builder, - on_text: bool, - transforms: content::Transforms, - ) -> SourceResult<()>; -} - -impl PaintEncode for Paint { - fn set_as_fill( - &self, - ctx: &mut content::Builder, - on_text: bool, - transforms: content::Transforms, - ) -> SourceResult<()> { - match self { - Self::Solid(c) => c.set_as_fill(ctx, on_text, transforms), - Self::Gradient(gradient) => gradient.set_as_fill(ctx, on_text, transforms), - Self::Tiling(tiling) => tiling.set_as_fill(ctx, on_text, transforms), - } - } - - fn set_as_stroke( - &self, - ctx: &mut content::Builder, - on_text: bool, - transforms: content::Transforms, - ) -> SourceResult<()> { - match self { - Self::Solid(c) => c.set_as_stroke(ctx, on_text, transforms), - Self::Gradient(gradient) => gradient.set_as_stroke(ctx, on_text, transforms), - Self::Tiling(tiling) => tiling.set_as_stroke(ctx, on_text, transforms), - } - } -} - -impl PaintEncode for Color { - fn set_as_fill( - &self, - ctx: &mut content::Builder, - _: bool, - _: content::Transforms, - ) -> SourceResult<()> { - match self { - Color::Luma(_) => { - ctx.resources.colors.mark_as_used(ColorSpace::D65Gray); - ctx.set_fill_color_space(D65_GRAY); - - let [l, _, _, _] = ColorSpace::D65Gray.encode(*self); - ctx.content.set_fill_color([l]); - } - Color::LinearRgb(_) => { - ctx.resources.colors.mark_as_used(ColorSpace::LinearRgb); - ctx.set_fill_color_space(LINEAR_SRGB); - - let [r, g, b, _] = ColorSpace::LinearRgb.encode(*self); - ctx.content.set_fill_color([r, g, b]); - } - // Oklab & friends are encoded as RGB. - Color::Rgb(_) - | Color::Oklab(_) - | Color::Oklch(_) - | Color::Hsl(_) - | Color::Hsv(_) => { - ctx.resources.colors.mark_as_used(ColorSpace::Srgb); - ctx.set_fill_color_space(SRGB); - - let [r, g, b, _] = ColorSpace::Srgb.encode(*self); - ctx.content.set_fill_color([r, g, b]); - } - Color::Cmyk(_) => { - check_cmyk_allowed(ctx.options)?; - ctx.reset_fill_color_space(); - - let [c, m, y, k] = ColorSpace::Cmyk.encode(*self); - ctx.content.set_fill_cmyk(c, m, y, k); - } - } - Ok(()) - } - - fn set_as_stroke( - &self, - ctx: &mut content::Builder, - _: bool, - _: content::Transforms, - ) -> SourceResult<()> { - match self { - Color::Luma(_) => { - ctx.resources.colors.mark_as_used(ColorSpace::D65Gray); - ctx.set_stroke_color_space(D65_GRAY); - - let [l, _, _, _] = ColorSpace::D65Gray.encode(*self); - ctx.content.set_stroke_color([l]); - } - Color::LinearRgb(_) => { - ctx.resources.colors.mark_as_used(ColorSpace::LinearRgb); - ctx.set_stroke_color_space(LINEAR_SRGB); - - let [r, g, b, _] = ColorSpace::LinearRgb.encode(*self); - ctx.content.set_stroke_color([r, g, b]); - } - // Oklab & friends are encoded as RGB. - Color::Rgb(_) - | Color::Oklab(_) - | Color::Oklch(_) - | Color::Hsl(_) - | Color::Hsv(_) => { - ctx.resources.colors.mark_as_used(ColorSpace::Srgb); - ctx.set_stroke_color_space(SRGB); - - let [r, g, b, _] = ColorSpace::Srgb.encode(*self); - ctx.content.set_stroke_color([r, g, b]); - } - Color::Cmyk(_) => { - check_cmyk_allowed(ctx.options)?; - ctx.reset_stroke_color_space(); - - let [c, m, y, k] = ColorSpace::Cmyk.encode(*self); - ctx.content.set_stroke_cmyk(c, m, y, k); - } - } - Ok(()) - } -} - -/// Extra color space functions. -pub(super) trait ColorSpaceExt { - /// Returns the range of the color space. - fn range(self) -> &'static [f32]; - - /// Converts a color to the color space. - fn convert(self, color: Color) -> ArrayVec; -} - -impl ColorSpaceExt for ColorSpace { - fn range(self) -> &'static [f32] { - match self { - ColorSpace::D65Gray => &[0.0, 1.0], - ColorSpace::Oklab => &[0.0, 1.0, 0.0, 1.0, 0.0, 1.0], - ColorSpace::Oklch => &[0.0, 1.0, 0.0, 1.0, 0.0, 1.0], - ColorSpace::LinearRgb => &[0.0, 1.0, 0.0, 1.0, 0.0, 1.0], - ColorSpace::Srgb => &[0.0, 1.0, 0.0, 1.0, 0.0, 1.0], - ColorSpace::Cmyk => &[0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0], - ColorSpace::Hsl => &[0.0, 1.0, 0.0, 1.0, 0.0, 1.0], - ColorSpace::Hsv => &[0.0, 1.0, 0.0, 1.0, 0.0, 1.0], - } - } - - fn convert(self, color: Color) -> ArrayVec { - let components = self.encode(color); - - self.range() - .chunks(2) - .zip(components) - .map(|(range, component)| U::quantize(component, [range[0], range[1]])) - .collect() - } -} - -/// Quantizes a color component to a specific type. -pub(super) trait QuantizedColor { - fn quantize(color: f32, range: [f32; 2]) -> Self; -} - -impl QuantizedColor for u16 { - fn quantize(color: f32, [min, max]: [f32; 2]) -> Self { - let value = (color - min) / (max - min); - (value * Self::MAX as f32).round().clamp(0.0, Self::MAX as f32) as Self - } -} - -impl QuantizedColor for f32 { - fn quantize(color: f32, [min, max]: [f32; 2]) -> Self { - color.clamp(min, max) - } -} - -/// Fails with an error if PDF/A processing is enabled. -pub(super) fn check_cmyk_allowed(options: &PdfOptions) -> SourceResult<()> { - if options.standards.pdfa { - bail!( - Span::detached(), - "cmyk colors are not currently supported by PDF/A export" - ); - } - Ok(()) -} diff --git a/crates/typst-pdf/src/color_font.rs b/crates/typst-pdf/src/color_font.rs deleted file mode 100644 index 1183e966ec..0000000000 --- a/crates/typst-pdf/src/color_font.rs +++ /dev/null @@ -1,344 +0,0 @@ -//! OpenType fonts generally define monochrome glyphs, but they can also define -//! glyphs with colors. This is how emojis are generally implemented for -//! example. -//! -//! There are various standards to represent color glyphs, but PDF readers don't -//! support any of them natively, so Typst has to handle them manually. - -use std::collections::HashMap; - -use ecow::eco_format; -use indexmap::IndexMap; -use pdf_writer::types::UnicodeCmap; -use pdf_writer::writers::WMode; -use pdf_writer::{Filter, Finish, Name, Rect, Ref}; -use typst_library::diag::{bail, error, SourceDiagnostic, SourceResult}; -use typst_library::foundations::Repr; -use typst_library::layout::Em; -use typst_library::text::color::glyph_frame; -use typst_library::text::{Font, Glyph, TextItemView}; - -use crate::font::{base_font_name, write_font_descriptor, CMAP_NAME, SYSTEM_INFO}; -use crate::resources::{Resources, ResourcesRefs}; -use crate::{content, EmExt, PdfChunk, PdfOptions, WithGlobalRefs}; - -/// Write color fonts in the PDF document. -/// -/// They are written as Type3 fonts, which map glyph IDs to arbitrary PDF -/// instructions. -pub fn write_color_fonts( - context: &WithGlobalRefs, -) -> SourceResult<(PdfChunk, HashMap)> { - let mut out = HashMap::new(); - let mut chunk = PdfChunk::new(); - context.resources.traverse(&mut |resources: &Resources| { - let Some(color_fonts) = &resources.color_fonts else { - return Ok(()); - }; - - for (color_font, font_slice) in color_fonts.iter() { - if out.contains_key(&font_slice) { - continue; - } - - // Allocate some IDs. - let subfont_id = chunk.alloc(); - let cmap_ref = chunk.alloc(); - let descriptor_ref = chunk.alloc(); - let widths_ref = chunk.alloc(); - - // And a map between glyph IDs and the instructions to draw this - // glyph. - let mut glyphs_to_instructions = Vec::new(); - - let start = font_slice.subfont * 256; - let end = (start + 256).min(color_font.glyphs.len()); - let glyph_count = end - start; - let subset = &color_font.glyphs[start..end]; - let mut widths = Vec::new(); - let mut gids = Vec::new(); - - let scale_factor = font_slice.font.ttf().units_per_em() as f32; - - // Write the instructions for each glyph. - for color_glyph in subset { - let instructions_stream_ref = chunk.alloc(); - let width = font_slice - .font - .advance(color_glyph.gid) - .unwrap_or(Em::new(0.0)) - .get() as f32 - * scale_factor; - widths.push(width); - chunk - .stream( - instructions_stream_ref, - color_glyph.instructions.content.wait(), - ) - .filter(Filter::FlateDecode); - - // Use this stream as instructions to draw the glyph. - glyphs_to_instructions.push(instructions_stream_ref); - gids.push(color_glyph.gid); - } - - // Determine the base font name. - gids.sort(); - let base_font = base_font_name(&font_slice.font, &gids); - - // Write the Type3 font object. - let mut pdf_font = chunk.type3_font(subfont_id); - pdf_font.name(Name(base_font.as_bytes())); - pdf_font.pair(Name(b"Resources"), color_fonts.resources.reference); - pdf_font.bbox(color_font.bbox); - pdf_font.matrix([1.0 / scale_factor, 0.0, 0.0, 1.0 / scale_factor, 0.0, 0.0]); - pdf_font.first_char(0); - pdf_font.last_char((glyph_count - 1) as u8); - pdf_font.pair(Name(b"Widths"), widths_ref); - pdf_font.to_unicode(cmap_ref); - pdf_font.font_descriptor(descriptor_ref); - - // Write the /CharProcs dictionary, that maps glyph names to - // drawing instructions. - let mut char_procs = pdf_font.char_procs(); - for (gid, instructions_ref) in glyphs_to_instructions.iter().enumerate() { - char_procs - .pair(Name(eco_format!("glyph{gid}").as_bytes()), *instructions_ref); - } - char_procs.finish(); - - // Write the /Encoding dictionary. - let names = (0..glyph_count) - .map(|gid| eco_format!("glyph{gid}")) - .collect::>(); - pdf_font - .encoding_custom() - .differences() - .consecutive(0, names.iter().map(|name| Name(name.as_bytes()))); - pdf_font.finish(); - - // Encode a CMAP to make it possible to search or copy glyphs. - let glyph_set = resources.color_glyph_sets.get(&font_slice.font).unwrap(); - let mut cmap = UnicodeCmap::new(CMAP_NAME, SYSTEM_INFO); - for (index, glyph) in subset.iter().enumerate() { - let Some(text) = glyph_set.get(&glyph.gid) else { - continue; - }; - - if !text.is_empty() { - cmap.pair_with_multiple(index as u8, text.chars()); - } - } - chunk.cmap(cmap_ref, &cmap.finish()).writing_mode(WMode::Horizontal); - - // Write the font descriptor. - write_font_descriptor( - &mut chunk, - descriptor_ref, - &font_slice.font, - &base_font, - ); - - // Write the widths array - chunk.indirect(widths_ref).array().items(widths); - - out.insert(font_slice, subfont_id); - } - - Ok(()) - })?; - - Ok((chunk, out)) -} - -/// A mapping between `Font`s and all the corresponding `ColorFont`s. -/// -/// This mapping is one-to-many because there can only be 256 glyphs in a Type 3 -/// font, and fonts generally have more color glyphs than that. -pub struct ColorFontMap { - /// The mapping itself. - map: IndexMap, - /// The resources required to render the fonts in this map. - /// - /// For example, this can be the images for glyphs based on bitmaps or SVG. - pub resources: Resources, - /// The number of font slices (groups of 256 color glyphs), across all color - /// fonts. - total_slice_count: usize, -} - -/// A collection of Type3 font, belonging to the same TTF font. -pub struct ColorFont { - /// The IDs of each sub-slice of this font. They are the numbers after "Cf" - /// in the Resources dictionaries. - slice_ids: Vec, - /// The list of all color glyphs in this family. - /// - /// The index in this vector modulo 256 corresponds to the index in one of - /// the Type3 fonts in `refs` (the `n`-th in the vector, where `n` is the - /// quotient of the index divided by 256). - pub glyphs: Vec, - /// The global bounding box of the font. - pub bbox: Rect, - /// A mapping between glyph IDs and character indices in the `glyphs` - /// vector. - glyph_indices: HashMap, -} - -/// A single color glyph. -pub struct ColorGlyph { - /// The ID of the glyph. - pub gid: u16, - /// Instructions to draw the glyph. - pub instructions: content::Encoded, -} - -impl ColorFontMap<()> { - /// Creates a new empty mapping - pub fn new() -> Self { - Self { - map: IndexMap::new(), - total_slice_count: 0, - resources: Resources::default(), - } - } - - /// For a given glyph in a TTF font, give the ID of the Type3 font and the - /// index of the glyph inside of this Type3 font. - /// - /// If this is the first occurrence of this glyph in this font, it will - /// start its encoding and add it to the list of known glyphs. - pub fn get( - &mut self, - options: &PdfOptions, - text: &TextItemView, - glyph: &Glyph, - ) -> SourceResult<(usize, u8)> { - let font = &text.item.font; - let color_font = self.map.entry(font.clone()).or_insert_with(|| { - let global_bbox = font.ttf().global_bounding_box(); - let bbox = Rect::new( - font.to_em(global_bbox.x_min).to_font_units(), - font.to_em(global_bbox.y_min).to_font_units(), - font.to_em(global_bbox.x_max).to_font_units(), - font.to_em(global_bbox.y_max).to_font_units(), - ); - ColorFont { - bbox, - slice_ids: Vec::new(), - glyphs: Vec::new(), - glyph_indices: HashMap::new(), - } - }); - - Ok(if let Some(index_of_glyph) = color_font.glyph_indices.get(&glyph.id) { - // If we already know this glyph, return it. - (color_font.slice_ids[index_of_glyph / 256], *index_of_glyph as u8) - } else { - // Otherwise, allocate a new ColorGlyph in the font, and a new Type3 font - // if needed - let index = color_font.glyphs.len(); - if index % 256 == 0 { - color_font.slice_ids.push(self.total_slice_count); - self.total_slice_count += 1; - } - - let (frame, tofu) = glyph_frame(font, glyph.id); - if options.standards.pdfa && tofu { - bail!(failed_to_convert(text, glyph)); - } - - let width = font.advance(glyph.id).unwrap_or(Em::new(0.0)).get() - * font.units_per_em(); - let instructions = content::build( - options, - &mut self.resources, - &frame, - None, - Some(width as f32), - )?; - color_font.glyphs.push(ColorGlyph { gid: glyph.id, instructions }); - color_font.glyph_indices.insert(glyph.id, index); - - (color_font.slice_ids[index / 256], index as u8) - }) - } - - /// Assign references to the resource dictionary used by this set of color - /// fonts. - pub fn with_refs(self, refs: &ResourcesRefs) -> ColorFontMap { - ColorFontMap { - map: self.map, - resources: self.resources.with_refs(refs), - total_slice_count: self.total_slice_count, - } - } -} - -impl ColorFontMap { - /// Iterate over all Type3 fonts. - /// - /// Each item of this iterator maps to a Type3 font: it contains - /// at most 256 glyphs. A same TTF font can yield multiple Type3 fonts. - pub fn iter(&self) -> ColorFontMapIter<'_, R> { - ColorFontMapIter { map: self, font_index: 0, slice_index: 0 } - } -} - -/// Iterator over a [`ColorFontMap`]. -/// -/// See [`ColorFontMap::iter`]. -pub struct ColorFontMapIter<'a, R> { - /// The map over which to iterate - map: &'a ColorFontMap, - /// The index of TTF font on which we currently iterate - font_index: usize, - /// The sub-font (slice of at most 256 glyphs) at which we currently are. - slice_index: usize, -} - -impl<'a, R> Iterator for ColorFontMapIter<'a, R> { - type Item = (&'a ColorFont, ColorFontSlice); - - fn next(&mut self) -> Option { - let (font, color_font) = self.map.map.get_index(self.font_index)?; - let slice_count = (color_font.glyphs.len() / 256) + 1; - - if self.slice_index >= slice_count { - self.font_index += 1; - self.slice_index = 0; - return self.next(); - } - - let slice = ColorFontSlice { font: font.clone(), subfont: self.slice_index }; - self.slice_index += 1; - Some((color_font, slice)) - } -} - -/// A set of at most 256 glyphs (a limit imposed on Type3 fonts by the PDF -/// specification) that represents a part of a TTF font. -#[derive(Debug, Clone, Eq, PartialEq, Hash)] -pub struct ColorFontSlice { - /// The original TTF font. - pub font: Font, - /// The index of the Type3 font, among all those that are necessary to - /// represent the subset of the TTF font we are interested in. - pub subfont: usize, -} - -/// The error when the glyph could not be converted. -#[cold] -fn failed_to_convert(text: &TextItemView, glyph: &Glyph) -> SourceDiagnostic { - let mut diag = error!( - glyph.span.0, - "the glyph for {} could not be exported", - text.glyph_text(glyph).repr() - ); - - if text.item.font.ttf().tables().cff2.is_some() { - diag.hint("CFF2 fonts are not currently supported"); - } - - diag -} diff --git a/crates/typst-pdf/src/content.rs b/crates/typst-pdf/src/content.rs deleted file mode 100644 index 8b7517f51a..0000000000 --- a/crates/typst-pdf/src/content.rs +++ /dev/null @@ -1,823 +0,0 @@ -//! Generic writer for PDF content. -//! -//! It is used to write page contents, color glyph instructions, and tilings. -//! -//! See also [`pdf_writer::Content`]. - -use ecow::eco_format; -use pdf_writer::types::{ - ColorSpaceOperand, LineCapStyle, LineJoinStyle, TextRenderingMode, -}; -use pdf_writer::writers::PositionedItems; -use pdf_writer::{Content, Finish, Name, Rect, Str}; -use typst_library::diag::{bail, error, SourceDiagnostic, SourceResult}; -use typst_library::foundations::Repr; -use typst_library::layout::{ - Abs, Em, Frame, FrameItem, GroupItem, Point, Ratio, Size, Transform, -}; -use typst_library::model::Destination; -use typst_library::text::color::should_outline; -use typst_library::text::{Font, Glyph, TextItem, TextItemView}; -use typst_library::visualize::{ - Curve, CurveItem, FillRule, FixedStroke, Geometry, Image, LineCap, LineJoin, Paint, - Shape, -}; -use typst_syntax::Span; -use typst_utils::{Deferred, Numeric, SliceExt}; - -use crate::color::PaintEncode; -use crate::color_font::ColorFontMap; -use crate::extg::ExtGState; -use crate::image::deferred_image; -use crate::resources::Resources; -use crate::{deflate_deferred, AbsExt, ContentExt, EmExt, PdfOptions, StrExt}; - -/// Encode a [`Frame`] into a content stream. -/// -/// The resources that were used in the stream will be added to `resources`. -/// -/// `color_glyph_width` should be `None` unless the `Frame` represents a [color -/// glyph]. -/// -/// [color glyph]: `crate::color_font` -pub fn build( - options: &PdfOptions, - resources: &mut Resources<()>, - frame: &Frame, - fill: Option, - color_glyph_width: Option, -) -> SourceResult { - let size = frame.size(); - let mut ctx = Builder::new(options, resources, size); - - if let Some(width) = color_glyph_width { - ctx.content.start_color_glyph(width); - } - - // Make the coordinate system start at the top-left. - ctx.transform( - // Make the Y axis go upwards - Transform::scale(Ratio::one(), -Ratio::one()) - // Also move the origin to the top left corner - .post_concat(Transform::translate(Abs::zero(), size.y)), - ); - - if let Some(fill) = fill { - let shape = Geometry::Rect(frame.size()).filled(fill); - write_shape(&mut ctx, Point::zero(), &shape)?; - } - - // Encode the frame into the content stream. - write_frame(&mut ctx, frame)?; - - Ok(Encoded { - size, - content: deflate_deferred(ctx.content.finish()), - uses_opacities: ctx.uses_opacities, - links: ctx.links, - }) -} - -/// An encoded content stream. -pub struct Encoded { - /// The dimensions of the content. - pub size: Size, - /// The actual content stream. - pub content: Deferred>, - /// Whether the content opacities. - pub uses_opacities: bool, - /// Links in the PDF coordinate system. - pub links: Vec<(Destination, Rect)>, -} - -/// An exporter for a single PDF content stream. -/// -/// Content streams are a series of PDF commands. They can reference external -/// objects only through resources. -/// -/// Content streams can be used for page contents, but also to describe color -/// glyphs and tilings. -pub struct Builder<'a, R = ()> { - /// Settings for PDF export. - pub(crate) options: &'a PdfOptions<'a>, - /// A list of all resources that are used in the content stream. - pub(crate) resources: &'a mut Resources, - /// The PDF content stream that is being built. - pub content: Content, - /// Current graphic state. - state: State, - /// Stack of saved graphic states. - saves: Vec, - /// Whether any stroke or fill was not totally opaque. - uses_opacities: bool, - /// All clickable links that are present in this content. - links: Vec<(Destination, Rect)>, -} - -impl<'a, R> Builder<'a, R> { - /// Create a new content builder. - pub fn new( - options: &'a PdfOptions<'a>, - resources: &'a mut Resources, - size: Size, - ) -> Self { - Builder { - options, - resources, - uses_opacities: false, - content: Content::new(), - state: State::new(size), - saves: vec![], - links: vec![], - } - } -} - -/// A simulated graphics state used to deduplicate graphics state changes and -/// keep track of the current transformation matrix for link annotations. -#[derive(Debug, Clone)] -struct State { - /// The transform of the current item. - transform: Transform, - /// The transform of first hard frame in the hierarchy. - container_transform: Transform, - /// The size of the first hard frame in the hierarchy. - size: Size, - /// The current font. - font: Option<(Font, Abs)>, - /// The current fill paint. - fill: Option, - /// The color space of the current fill paint. - fill_space: Option>, - /// The current external graphic state. - external_graphics_state: ExtGState, - /// The current stroke paint. - stroke: Option, - /// The color space of the current stroke paint. - stroke_space: Option>, - /// The current text rendering mode. - text_rendering_mode: TextRenderingMode, -} - -impl State { - /// Creates a new, clean state for a given `size`. - pub fn new(size: Size) -> Self { - Self { - transform: Transform::identity(), - container_transform: Transform::identity(), - size, - font: None, - fill: None, - fill_space: None, - external_graphics_state: ExtGState::default(), - stroke: None, - stroke_space: None, - text_rendering_mode: TextRenderingMode::Fill, - } - } - - /// Creates the [`Transforms`] structure for the current item. - pub fn transforms(&self, size: Size, pos: Point) -> Transforms { - Transforms { - transform: self.transform.pre_concat(Transform::translate(pos.x, pos.y)), - container_transform: self.container_transform, - container_size: self.size, - size, - } - } -} - -/// Subset of the state used to calculate the transform of gradients and tilings. -#[derive(Debug, Clone, Copy)] -pub(super) struct Transforms { - /// The transform of the current item. - pub transform: Transform, - /// The transform of first hard frame in the hierarchy. - pub container_transform: Transform, - /// The size of the first hard frame in the hierarchy. - pub container_size: Size, - /// The size of the item. - pub size: Size, -} - -impl Builder<'_, ()> { - fn save_state(&mut self) -> SourceResult<()> { - self.saves.push(self.state.clone()); - self.content.save_state_checked() - } - - fn restore_state(&mut self) { - self.content.restore_state(); - self.state = self.saves.pop().expect("missing state save"); - } - - fn set_external_graphics_state(&mut self, graphics_state: &ExtGState) { - let current_state = &self.state.external_graphics_state; - if current_state != graphics_state { - let index = self.resources.ext_gs.insert(*graphics_state); - let name = eco_format!("Gs{index}"); - self.content.set_parameters(Name(name.as_bytes())); - - self.state.external_graphics_state = *graphics_state; - if graphics_state.uses_opacities() { - self.uses_opacities = true; - } - } - } - - fn set_opacities(&mut self, stroke: Option<&FixedStroke>, fill: Option<&Paint>) { - let get_opacity = |paint: &Paint| { - let color = match paint { - Paint::Solid(color) => *color, - Paint::Gradient(_) | Paint::Tiling(_) => return 255, - }; - - color.alpha().map_or(255, |v| (v * 255.0).round() as u8) - }; - - let stroke_opacity = stroke.map_or(255, |stroke| get_opacity(&stroke.paint)); - let fill_opacity = fill.map_or(255, get_opacity); - self.set_external_graphics_state(&ExtGState { stroke_opacity, fill_opacity }); - } - - fn reset_opacities(&mut self) { - self.set_external_graphics_state(&ExtGState { - stroke_opacity: 255, - fill_opacity: 255, - }); - } - - pub fn transform(&mut self, transform: Transform) { - let Transform { sx, ky, kx, sy, tx, ty } = transform; - self.state.transform = self.state.transform.pre_concat(transform); - if self.state.container_transform.is_identity() { - self.state.container_transform = self.state.transform; - } - self.content.transform([ - sx.get() as _, - ky.get() as _, - kx.get() as _, - sy.get() as _, - tx.to_f32(), - ty.to_f32(), - ]); - } - - fn group_transform(&mut self, transform: Transform) { - self.state.container_transform = - self.state.container_transform.pre_concat(transform); - } - - fn set_font(&mut self, font: &Font, size: Abs) { - if self.state.font.as_ref().map(|(f, s)| (f, *s)) != Some((font, size)) { - let index = self.resources.fonts.insert(font.clone()); - let name = eco_format!("F{index}"); - self.content.set_font(Name(name.as_bytes()), size.to_f32()); - self.state.font = Some((font.clone(), size)); - } - } - - fn size(&mut self, size: Size) { - self.state.size = size; - } - - fn set_fill( - &mut self, - fill: &Paint, - on_text: bool, - transforms: Transforms, - ) -> SourceResult<()> { - if self.state.fill.as_ref() != Some(fill) - || matches!(self.state.fill, Some(Paint::Gradient(_))) - { - fill.set_as_fill(self, on_text, transforms)?; - self.state.fill = Some(fill.clone()); - } - Ok(()) - } - - pub fn set_fill_color_space(&mut self, space: Name<'static>) { - if self.state.fill_space != Some(space) { - self.content.set_fill_color_space(ColorSpaceOperand::Named(space)); - self.state.fill_space = Some(space); - } - } - - pub fn reset_fill_color_space(&mut self) { - self.state.fill_space = None; - } - - fn set_stroke( - &mut self, - stroke: &FixedStroke, - on_text: bool, - transforms: Transforms, - ) -> SourceResult<()> { - if self.state.stroke.as_ref() != Some(stroke) - || matches!( - self.state.stroke.as_ref().map(|s| &s.paint), - Some(Paint::Gradient(_)) - ) - { - let FixedStroke { paint, thickness, cap, join, dash, miter_limit } = stroke; - paint.set_as_stroke(self, on_text, transforms)?; - - self.content.set_line_width(thickness.to_f32()); - if self.state.stroke.as_ref().map(|s| &s.cap) != Some(cap) { - self.content.set_line_cap(to_pdf_line_cap(*cap)); - } - if self.state.stroke.as_ref().map(|s| &s.join) != Some(join) { - self.content.set_line_join(to_pdf_line_join(*join)); - } - if self.state.stroke.as_ref().map(|s| &s.dash) != Some(dash) { - if let Some(dash) = dash { - self.content.set_dash_pattern( - dash.array.iter().map(|l| l.to_f32()), - dash.phase.to_f32(), - ); - } else { - self.content.set_dash_pattern([], 0.0); - } - } - if self.state.stroke.as_ref().map(|s| &s.miter_limit) != Some(miter_limit) { - self.content.set_miter_limit(miter_limit.get() as f32); - } - self.state.stroke = Some(stroke.clone()); - } - - Ok(()) - } - - pub fn set_stroke_color_space(&mut self, space: Name<'static>) { - if self.state.stroke_space != Some(space) { - self.content.set_stroke_color_space(ColorSpaceOperand::Named(space)); - self.state.stroke_space = Some(space); - } - } - - pub fn reset_stroke_color_space(&mut self) { - self.state.stroke_space = None; - } - - fn set_text_rendering_mode(&mut self, mode: TextRenderingMode) { - if self.state.text_rendering_mode != mode { - self.content.set_text_rendering_mode(mode); - self.state.text_rendering_mode = mode; - } - } -} - -/// Encode a frame into the content stream. -pub(crate) fn write_frame(ctx: &mut Builder, frame: &Frame) -> SourceResult<()> { - for &(pos, ref item) in frame.items() { - let x = pos.x.to_f32(); - let y = pos.y.to_f32(); - match item { - FrameItem::Group(group) => write_group(ctx, pos, group)?, - FrameItem::Text(text) => write_text(ctx, pos, text)?, - FrameItem::Shape(shape, _) => write_shape(ctx, pos, shape)?, - FrameItem::Image(image, size, span) => { - write_image(ctx, x, y, image, *size, *span)? - } - FrameItem::Link(dest, size) => write_link(ctx, pos, dest, *size), - FrameItem::Tag(_) => {} - } - } - Ok(()) -} - -/// Encode a group into the content stream. -fn write_group(ctx: &mut Builder, pos: Point, group: &GroupItem) -> SourceResult<()> { - let translation = Transform::translate(pos.x, pos.y); - - ctx.save_state()?; - - if group.frame.kind().is_hard() { - ctx.group_transform( - ctx.state - .transform - .post_concat(ctx.state.container_transform.invert().unwrap()) - .pre_concat(translation) - .pre_concat(group.transform), - ); - ctx.size(group.frame.size()); - } - - ctx.transform(translation.pre_concat(group.transform)); - if let Some(clip_curve) = &group.clip { - write_curve(ctx, 0.0, 0.0, clip_curve); - ctx.content.clip_nonzero(); - ctx.content.end_path(); - } - - write_frame(ctx, &group.frame)?; - ctx.restore_state(); - - Ok(()) -} - -/// Encode a text run into the content stream. -fn write_text(ctx: &mut Builder, pos: Point, text: &TextItem) -> SourceResult<()> { - if ctx.options.standards.pdfa && text.font.info().is_last_resort() { - bail!( - Span::find(text.glyphs.iter().map(|g| g.span.0)), - "the text {} could not be displayed with any font", - &text.text, - ); - } - - let outline_glyphs = - text.glyphs.iter().filter(|g| should_outline(&text.font, g)).count(); - - if outline_glyphs == text.glyphs.len() { - write_normal_text(ctx, pos, TextItemView::full(text))?; - } else if outline_glyphs == 0 { - write_complex_glyphs(ctx, pos, TextItemView::full(text))?; - } else { - // Otherwise we need to split it into smaller text runs. - let mut offset = 0; - let mut position_in_run = Abs::zero(); - for (should_outline, sub_run) in - text.glyphs.group_by_key(|g| should_outline(&text.font, g)) - { - let end = offset + sub_run.len(); - - // Build a sub text-run - let text_item_view = TextItemView::from_glyph_range(text, offset..end); - - // Adjust the position of the run on the line - let pos = pos + Point::new(position_in_run, Abs::zero()); - position_in_run += text_item_view.width(); - offset = end; - - // Actually write the sub text-run. - if should_outline { - write_normal_text(ctx, pos, text_item_view)?; - } else { - write_complex_glyphs(ctx, pos, text_item_view)?; - } - } - } - - Ok(()) -} - -/// Encodes a text run (without any color glyph) into the content stream. -fn write_normal_text( - ctx: &mut Builder, - pos: Point, - text: TextItemView, -) -> SourceResult<()> { - let x = pos.x.to_f32(); - let y = pos.y.to_f32(); - - *ctx.resources.languages.entry(text.item.lang).or_insert(0) += text.glyph_range.len(); - - let glyph_set = ctx.resources.glyph_sets.entry(text.item.font.clone()).or_default(); - for g in text.glyphs() { - glyph_set.entry(g.id).or_insert_with(|| text.glyph_text(g)); - } - - let fill_transform = ctx.state.transforms(Size::zero(), pos); - ctx.set_fill(&text.item.fill, true, fill_transform)?; - - let stroke = text.item.stroke.as_ref().and_then(|stroke| { - if stroke.thickness.to_f32() > 0.0 { - Some(stroke) - } else { - None - } - }); - - if let Some(stroke) = stroke { - ctx.set_stroke(stroke, true, fill_transform)?; - ctx.set_text_rendering_mode(TextRenderingMode::FillStroke); - } else { - ctx.set_text_rendering_mode(TextRenderingMode::Fill); - } - - ctx.set_font(&text.item.font, text.item.size); - ctx.set_opacities(text.item.stroke.as_ref(), Some(&text.item.fill)); - ctx.content.begin_text(); - - // Position the text. - ctx.content.set_text_matrix([1.0, 0.0, 0.0, -1.0, x, y]); - - let mut positioned = ctx.content.show_positioned(); - let mut items = positioned.items(); - let mut adjustment = Em::zero(); - let mut encoded = vec![]; - - let glyph_remapper = ctx - .resources - .glyph_remappers - .entry(text.item.font.clone()) - .or_default(); - - // Write the glyphs with kerning adjustments. - for glyph in text.glyphs() { - if ctx.options.standards.pdfa && glyph.id == 0 { - bail!(tofu(&text, glyph)); - } - - adjustment += glyph.x_offset; - - if !adjustment.is_zero() { - if !encoded.is_empty() { - show_text(&mut items, &encoded); - encoded.clear(); - } - - items.adjust(-adjustment.to_font_units()); - adjustment = Em::zero(); - } - - // In PDF, we use CIDs to index the glyphs in a font, not GIDs. What a - // CID actually refers to depends on the type of font we are embedding: - // - // - For TrueType fonts, the CIDs are defined by an external mapping. - // - For SID-keyed CFF fonts, the CID is the same as the GID in the font. - // - For CID-keyed CFF fonts, the CID refers to the CID in the font. - // - // (See in the PDF-spec for more details on this.) - // - // However, in our case: - // - We use the identity-mapping for TrueType fonts. - // - SID-keyed fonts will get converted into CID-keyed fonts by the - // subsetter. - // - CID-keyed fonts will be rewritten in a way so that the mapping - // between CID and GID is always the identity mapping, regardless of - // the mapping before. - // - // Because of this, we can always use the remapped GID as the CID, - // regardless of which type of font we are actually embedding. - let cid = glyph_remapper.remap(glyph.id); - encoded.push((cid >> 8) as u8); - encoded.push((cid & 0xff) as u8); - - if let Some(advance) = text.item.font.advance(glyph.id) { - adjustment += glyph.x_advance - advance; - } - - adjustment -= glyph.x_offset; - } - - if !encoded.is_empty() { - show_text(&mut items, &encoded); - } - - items.finish(); - positioned.finish(); - ctx.content.end_text(); - - Ok(()) -} - -/// Shows text, ensuring that each individual string doesn't exceed the -/// implementation limits. -fn show_text(items: &mut PositionedItems, encoded: &[u8]) { - for chunk in encoded.chunks(Str::PDFA_LIMIT) { - items.show(Str(chunk)); - } -} - -/// Encodes a text run made only of color glyphs into the content stream -fn write_complex_glyphs( - ctx: &mut Builder, - pos: Point, - text: TextItemView, -) -> SourceResult<()> { - let x = pos.x.to_f32(); - let y = pos.y.to_f32(); - - let mut last_font = None; - - ctx.reset_opacities(); - - ctx.content.begin_text(); - ctx.content.set_text_matrix([1.0, 0.0, 0.0, -1.0, x, y]); - // So that the next call to ctx.set_font() will change the font to one that - // displays regular glyphs and not color glyphs. - ctx.state.font = None; - - let glyph_set = ctx - .resources - .color_glyph_sets - .entry(text.item.font.clone()) - .or_default(); - - for glyph in text.glyphs() { - if ctx.options.standards.pdfa && glyph.id == 0 { - bail!(tofu(&text, glyph)); - } - - // Retrieve the Type3 font reference and the glyph index in the font. - let color_fonts = ctx - .resources - .color_fonts - .get_or_insert_with(|| Box::new(ColorFontMap::new())); - - let (font, index) = color_fonts.get(ctx.options, &text, glyph)?; - - if last_font != Some(font) { - ctx.content.set_font( - Name(eco_format!("Cf{}", font).as_bytes()), - text.item.size.to_f32(), - ); - last_font = Some(font); - } - - ctx.content.show(Str(&[index])); - - glyph_set.entry(glyph.id).or_insert_with(|| text.glyph_text(glyph)); - } - ctx.content.end_text(); - - Ok(()) -} - -/// Encode a geometrical shape into the content stream. -fn write_shape(ctx: &mut Builder, pos: Point, shape: &Shape) -> SourceResult<()> { - let x = pos.x.to_f32(); - let y = pos.y.to_f32(); - - let stroke = shape.stroke.as_ref().and_then(|stroke| { - if stroke.thickness.to_f32() > 0.0 { - Some(stroke) - } else { - None - } - }); - - if shape.fill.is_none() && stroke.is_none() { - return Ok(()); - } - - if let Some(fill) = &shape.fill { - ctx.set_fill(fill, false, ctx.state.transforms(shape.geometry.bbox_size(), pos))?; - } - - if let Some(stroke) = stroke { - ctx.set_stroke( - stroke, - false, - ctx.state.transforms(shape.geometry.bbox_size(), pos), - )?; - } - - ctx.set_opacities(stroke, shape.fill.as_ref()); - - match &shape.geometry { - Geometry::Line(target) => { - let dx = target.x.to_f32(); - let dy = target.y.to_f32(); - ctx.content.move_to(x, y); - ctx.content.line_to(x + dx, y + dy); - } - Geometry::Rect(size) => { - let w = size.x.to_f32(); - let h = size.y.to_f32(); - if w.abs() > f32::EPSILON && h.abs() > f32::EPSILON { - ctx.content.rect(x, y, w, h); - } - } - Geometry::Curve(curve) => { - write_curve(ctx, x, y, curve); - } - } - - match (&shape.fill, &shape.fill_rule, stroke) { - (None, _, None) => unreachable!(), - (Some(_), FillRule::NonZero, None) => ctx.content.fill_nonzero(), - (Some(_), FillRule::EvenOdd, None) => ctx.content.fill_even_odd(), - (None, _, Some(_)) => ctx.content.stroke(), - (Some(_), FillRule::NonZero, Some(_)) => ctx.content.fill_nonzero_and_stroke(), - (Some(_), FillRule::EvenOdd, Some(_)) => ctx.content.fill_even_odd_and_stroke(), - }; - - Ok(()) -} - -/// Encode a curve into the content stream. -fn write_curve(ctx: &mut Builder, x: f32, y: f32, curve: &Curve) { - for elem in &curve.0 { - match elem { - CurveItem::Move(p) => ctx.content.move_to(x + p.x.to_f32(), y + p.y.to_f32()), - CurveItem::Line(p) => ctx.content.line_to(x + p.x.to_f32(), y + p.y.to_f32()), - CurveItem::Cubic(p1, p2, p3) => ctx.content.cubic_to( - x + p1.x.to_f32(), - y + p1.y.to_f32(), - x + p2.x.to_f32(), - y + p2.y.to_f32(), - x + p3.x.to_f32(), - y + p3.y.to_f32(), - ), - CurveItem::Close => ctx.content.close_path(), - }; - } -} - -/// Encode a vector or raster image into the content stream. -fn write_image( - ctx: &mut Builder, - x: f32, - y: f32, - image: &Image, - size: Size, - span: Span, -) -> SourceResult<()> { - let index = ctx.resources.images.insert(image.clone()); - ctx.resources.deferred_images.entry(index).or_insert_with(|| { - let (image, color_space) = - deferred_image(image.clone(), ctx.options.standards.pdfa); - if let Some(color_space) = color_space { - ctx.resources.colors.mark_as_used(color_space); - } - (image, span) - }); - - ctx.reset_opacities(); - - let name = eco_format!("Im{index}"); - let w = size.x.to_f32(); - let h = size.y.to_f32(); - ctx.content.save_state_checked()?; - ctx.content.transform([w, 0.0, 0.0, -h, x, y + h]); - - if let Some(alt) = image.alt() { - if ctx.options.standards.pdfa && alt.len() > Str::PDFA_LIMIT { - bail!(span, "the image's alt text is too long"); - } - - let mut image_span = - ctx.content.begin_marked_content_with_properties(Name(b"Span")); - let mut image_alt = image_span.properties(); - image_alt.pair(Name(b"Alt"), Str(alt.as_bytes())); - image_alt.finish(); - image_span.finish(); - - ctx.content.x_object(Name(name.as_bytes())); - ctx.content.end_marked_content(); - } else { - ctx.content.x_object(Name(name.as_bytes())); - } - - ctx.content.restore_state(); - Ok(()) -} - -/// Save a link for later writing in the annotations dictionary. -fn write_link(ctx: &mut Builder, pos: Point, dest: &Destination, size: Size) { - let mut min_x = Abs::inf(); - let mut min_y = Abs::inf(); - let mut max_x = -Abs::inf(); - let mut max_y = -Abs::inf(); - - // Compute the bounding box of the transformed link. - for point in [ - pos, - pos + Point::with_x(size.x), - pos + Point::with_y(size.y), - pos + size.to_point(), - ] { - let t = point.transform(ctx.state.transform); - min_x.set_min(t.x); - min_y.set_min(t.y); - max_x.set_max(t.x); - max_y.set_max(t.y); - } - - let x1 = min_x.to_f32(); - let x2 = max_x.to_f32(); - let y1 = max_y.to_f32(); - let y2 = min_y.to_f32(); - let rect = Rect::new(x1, y1, x2, y2); - - ctx.links.push((dest.clone(), rect)); -} - -fn to_pdf_line_cap(cap: LineCap) -> LineCapStyle { - match cap { - LineCap::Butt => LineCapStyle::ButtCap, - LineCap::Round => LineCapStyle::RoundCap, - LineCap::Square => LineCapStyle::ProjectingSquareCap, - } -} - -fn to_pdf_line_join(join: LineJoin) -> LineJoinStyle { - match join { - LineJoin::Miter => LineJoinStyle::MiterJoin, - LineJoin::Round => LineJoinStyle::RoundJoin, - LineJoin::Bevel => LineJoinStyle::BevelJoin, - } -} - -/// The error when there is a tofu glyph. -#[cold] -fn tofu(text: &TextItemView, glyph: &Glyph) -> SourceDiagnostic { - error!( - glyph.span.0, - "the text {} could not be displayed with any font", - text.glyph_text(glyph).repr(), - ) -} diff --git a/crates/typst-pdf/src/convert.rs b/crates/typst-pdf/src/convert.rs new file mode 100644 index 0000000000..f5ca31730c --- /dev/null +++ b/crates/typst-pdf/src/convert.rs @@ -0,0 +1,661 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::num::NonZeroU64; + +use ecow::{eco_format, EcoVec}; +use krilla::annotation::Annotation; +use krilla::configure::{Configuration, ValidationError, Validator}; +use krilla::destination::{NamedDestination, XyzDestination}; +use krilla::embed::EmbedError; +use krilla::error::KrillaError; +use krilla::geom::PathBuilder; +use krilla::page::{PageLabel, PageSettings}; +use krilla::surface::Surface; +use krilla::{Document, SerializeSettings}; +use krilla_svg::render_svg_glyph; +use typst_library::diag::{bail, error, SourceDiagnostic, SourceResult}; +use typst_library::foundations::NativeElement; +use typst_library::introspection::Location; +use typst_library::layout::{ + Abs, Frame, FrameItem, GroupItem, PagedDocument, Size, Transform, +}; +use typst_library::model::HeadingElem; +use typst_library::text::{Font, Lang}; +use typst_library::visualize::{Geometry, Paint}; +use typst_syntax::Span; + +use crate::embed::embed_files; +use crate::image::handle_image; +use crate::link::handle_link; +use crate::metadata::build_metadata; +use crate::outline::build_outline; +use crate::page::PageLabelExt; +use crate::shape::handle_shape; +use crate::text::handle_text; +use crate::util::{convert_path, display_font, AbsExt, TransformExt}; +use crate::PdfOptions; + +#[typst_macros::time(name = "convert document")] +pub fn convert( + typst_document: &PagedDocument, + options: &PdfOptions, +) -> SourceResult> { + let settings = SerializeSettings { + compress_content_streams: true, + no_device_cs: true, + ascii_compatible: false, + xmp_metadata: true, + cmyk_profile: None, + configuration: options.standards.config, + enable_tagging: false, + render_svg_glyph_fn: render_svg_glyph, + }; + + let mut document = Document::new_with(settings); + let page_index_converter = PageIndexConverter::new(typst_document, options); + let named_destinations = + collect_named_destinations(typst_document, &page_index_converter); + let mut gc = GlobalContext::new( + typst_document, + options, + named_destinations, + page_index_converter, + ); + + convert_pages(&mut gc, &mut document)?; + embed_files(typst_document, &mut document)?; + + document.set_outline(build_outline(&gc)); + document.set_metadata(build_metadata(&gc)); + + finish(document, gc, options.standards.config) +} + +fn convert_pages(gc: &mut GlobalContext, document: &mut Document) -> SourceResult<()> { + for (i, typst_page) in gc.document.pages.iter().enumerate() { + if gc.page_index_converter.pdf_page_index(i).is_none() { + // Don't export this page. + continue; + } else { + let mut settings = PageSettings::new( + typst_page.frame.width().to_f32(), + typst_page.frame.height().to_f32(), + ); + + if let Some(label) = typst_page + .numbering + .as_ref() + .and_then(|num| PageLabel::generate(num, typst_page.number)) + .or_else(|| { + // When some pages were ignored from export, we show a page label with + // the correct real (not logical) page number. + // This is for consistency with normal output when pages have no numbering + // and all are exported: the final PDF page numbers always correspond to + // the real (not logical) page numbers. Here, the final PDF page number + // will differ, but we can at least use labels to indicate what was + // the corresponding real page number in the Typst document. + gc.page_index_converter + .has_skipped_pages() + .then(|| PageLabel::arabic((i + 1) as u64)) + }) + { + settings = settings.with_page_label(label); + } + + let mut page = document.start_page_with(settings); + let mut surface = page.surface(); + let mut fc = FrameContext::new(typst_page.frame.size()); + + handle_frame( + &mut fc, + &typst_page.frame, + typst_page.fill_or_transparent(), + &mut surface, + gc, + )?; + + surface.finish(); + + for annotation in fc.annotations { + page.add_annotation(annotation); + } + } + } + + Ok(()) +} + +/// A state allowing us to keep track of transforms and container sizes, +/// which is mainly needed to resolve gradients and patterns correctly. +#[derive(Debug, Clone)] +pub(crate) struct State { + /// The current transform. + transform: Transform, + /// The transform of first hard frame in the hierarchy. + container_transform: Transform, + /// The size of the first hard frame in the hierarchy. + container_size: Size, +} + +impl State { + /// Creates a new, clean state for a given `size`. + fn new(size: Size) -> Self { + Self { + transform: Transform::identity(), + container_transform: Transform::identity(), + container_size: size, + } + } + + pub(crate) fn register_container(&mut self, size: Size) { + self.container_transform = self.transform; + self.container_size = size; + } + + pub(crate) fn pre_concat(&mut self, transform: Transform) { + self.transform = self.transform.pre_concat(transform); + } + + pub(crate) fn transform(&self) -> Transform { + self.transform + } + + pub(crate) fn container_transform(&self) -> Transform { + self.container_transform + } + + pub(crate) fn container_size(&self) -> Size { + self.container_size + } +} + +/// Context needed for converting a single frame. +pub(crate) struct FrameContext { + states: Vec, + annotations: Vec, +} + +impl FrameContext { + pub(crate) fn new(size: Size) -> Self { + Self { + states: vec![State::new(size)], + annotations: vec![], + } + } + + pub(crate) fn push(&mut self) { + self.states.push(self.states.last().unwrap().clone()); + } + + pub(crate) fn pop(&mut self) { + self.states.pop(); + } + + pub(crate) fn state(&self) -> &State { + self.states.last().unwrap() + } + + pub(crate) fn state_mut(&mut self) -> &mut State { + self.states.last_mut().unwrap() + } + + pub(crate) fn push_annotation(&mut self, annotation: Annotation) { + self.annotations.push(annotation); + } +} + +/// Globally needed context for converting a typst document. +pub(crate) struct GlobalContext<'a> { + /// Cache the conversion between krilla and Typst fonts (forward and backward). + pub(crate) fonts_forward: HashMap, + pub(crate) fonts_backward: HashMap, + /// Mapping between images and their span. + // Note: In theory, the same image can have multiple spans + // if it appears in the document multiple times. We just store the + // first appearance, though. + pub(crate) image_to_spans: HashMap, + /// The spans of all images that appear in the document. We use this so + /// we can give more accurate error messages. + pub(crate) image_spans: HashSet, + /// The document to convert. + pub(crate) document: &'a PagedDocument, + /// Options for PDF export. + pub(crate) options: &'a PdfOptions<'a>, + /// Mapping between locations in the document and named destinations. + pub(crate) loc_to_names: HashMap, + /// The languages used throughout the document. + pub(crate) languages: BTreeMap, + pub(crate) page_index_converter: PageIndexConverter, +} + +impl<'a> GlobalContext<'a> { + pub(crate) fn new( + document: &'a PagedDocument, + options: &'a PdfOptions, + loc_to_names: HashMap, + page_index_converter: PageIndexConverter, + ) -> GlobalContext<'a> { + Self { + fonts_forward: HashMap::new(), + fonts_backward: HashMap::new(), + document, + options, + loc_to_names, + image_to_spans: HashMap::new(), + image_spans: HashSet::new(), + languages: BTreeMap::new(), + page_index_converter, + } + } +} + +#[typst_macros::time(name = "handle page")] +pub(crate) fn handle_frame( + fc: &mut FrameContext, + frame: &Frame, + fill: Option, + surface: &mut Surface, + gc: &mut GlobalContext, +) -> SourceResult<()> { + fc.push(); + + if frame.kind().is_hard() { + fc.state_mut().register_container(frame.size()); + } + + if let Some(fill) = fill { + let shape = Geometry::Rect(frame.size()).filled(fill); + handle_shape(fc, &shape, surface, gc, Span::detached())?; + } + + for (point, item) in frame.items() { + fc.push(); + fc.state_mut().pre_concat(Transform::translate(point.x, point.y)); + + match item { + FrameItem::Group(g) => handle_group(fc, g, surface, gc)?, + FrameItem::Text(t) => handle_text(fc, t, surface, gc)?, + FrameItem::Shape(s, span) => handle_shape(fc, s, surface, gc, *span)?, + FrameItem::Image(image, size, span) => { + handle_image(gc, fc, image, *size, surface, *span)? + } + FrameItem::Link(d, s) => handle_link(fc, gc, d, *s), + FrameItem::Tag(_) => {} + } + + fc.pop(); + } + + fc.pop(); + + Ok(()) +} + +pub(crate) fn handle_group( + fc: &mut FrameContext, + group: &GroupItem, + surface: &mut Surface, + context: &mut GlobalContext, +) -> SourceResult<()> { + fc.push(); + fc.state_mut().pre_concat(group.transform); + + let clip_path = group + .clip + .as_ref() + .and_then(|p| { + let mut builder = PathBuilder::new(); + convert_path(p, &mut builder); + builder.finish() + }) + .and_then(|p| p.transform(fc.state().transform.to_krilla())); + + if let Some(clip_path) = &clip_path { + surface.push_clip_path(clip_path, &krilla::paint::FillRule::NonZero); + } + + handle_frame(fc, &group.frame, None, surface, context)?; + + if clip_path.is_some() { + surface.pop(); + } + + fc.pop(); + + Ok(()) +} + +#[typst_macros::time(name = "finish export")] +/// Finish a krilla document and handle export errors. +fn finish( + document: Document, + gc: GlobalContext, + configuration: Configuration, +) -> SourceResult> { + let validator = configuration.validator(); + + match document.finish() { + Ok(r) => Ok(r), + Err(e) => match e { + KrillaError::Font(f, s) => { + let font_str = display_font(gc.fonts_backward.get(&f).unwrap()); + bail!( + Span::detached(), + "failed to process font {font_str}: {s}"; + hint: "make sure the font is valid"; + hint: "the used font might be unsupported by Typst" + ); + } + KrillaError::Validation(ve) => { + let errors = ve + .iter() + .map(|e| convert_error(&gc, validator, e)) + .collect::>(); + Err(errors) + } + KrillaError::Image(_, loc) => { + let span = to_span(loc); + bail!(span, "failed to process image"); + } + KrillaError::SixteenBitImage(image, _) => { + let span = gc.image_to_spans.get(&image).unwrap(); + bail!( + *span, "16 bit images are not supported in this export mode"; + hint: "convert the image to 8 bit instead" + ) + } + }, + } +} + +/// Converts a krilla error into a Typst error. +fn convert_error( + gc: &GlobalContext, + validator: Validator, + error: &ValidationError, +) -> SourceDiagnostic { + let prefix = eco_format!("{} error:", validator.as_str()); + match error { + ValidationError::TooLongString => error!( + Span::detached(), + "{prefix} a PDF string is longer than 32767 characters"; + hint: "ensure title and author names are short enough" + ), + // Should in theory never occur, as krilla always trims font names. + ValidationError::TooLongName => error!( + Span::detached(), + "{prefix} a PDF name is longer than 127 characters"; + hint: "perhaps a font name is too long" + ), + + ValidationError::TooLongArray => error!( + Span::detached(), + "{prefix} a PDF array is longer than 8191 elements"; + hint: "this can happen if you have a very long text in a single line" + ), + ValidationError::TooLongDictionary => error!( + Span::detached(), + "{prefix} a PDF dictionary has more than 4095 entries"; + hint: "try reducing the complexity of your document" + ), + ValidationError::TooLargeFloat => error!( + Span::detached(), + "{prefix} a PDF floating point number is larger than the allowed limit"; + hint: "try exporting with a higher PDF version" + ), + ValidationError::TooManyIndirectObjects => error!( + Span::detached(), + "{prefix} the PDF has too many indirect objects"; + hint: "reduce the size of your document" + ), + // Can only occur if we have 27+ nested clip paths + ValidationError::TooHighQNestingLevel => error!( + Span::detached(), + "{prefix} the PDF has too high q nesting"; + hint: "reduce the number of nested containers" + ), + ValidationError::ContainsPostScript(loc) => error!( + to_span(*loc), + "{prefix} the PDF contains PostScript code"; + hint: "conic gradients are not supported in this PDF standard" + ), + ValidationError::MissingCMYKProfile => error!( + Span::detached(), + "{prefix} the PDF is missing a CMYK profile"; + hint: "CMYK colors are not yet supported in this export mode" + ), + ValidationError::ContainsNotDefGlyph(f, loc, text) => error!( + to_span(*loc), + "{prefix} the text '{text}' cannot be displayed using {}", + display_font(gc.fonts_backward.get(f).unwrap()); + hint: "try using a different font" + ), + ValidationError::InvalidCodepointMapping(_, _, cp, loc) => { + if let Some(c) = cp.map(|c| eco_format!("{:#06x}", c as u32)) { + let msg = if loc.is_some() { + "the PDF contains text with" + } else { + "the text contains" + }; + error!(to_span(*loc), "{prefix} {msg} the disallowed codepoint {c}") + } else { + // I think this code path is in theory unreachable, + // but just to be safe. + let msg = if loc.is_some() { + "the PDF contains text with missing codepoints" + } else { + "the text was not mapped to a code point" + }; + error!( + to_span(*loc), + "{prefix} {msg}"; + hint: "for complex scripts like Arabic, it might not be \ + possible to produce a compliant document" + ) + } + } + ValidationError::UnicodePrivateArea(_, _, c, loc) => { + let code_point = eco_format!("{:#06x}", *c as u32); + let msg = if loc.is_some() { "the PDF" } else { "the text" }; + error!( + to_span(*loc), + "{prefix} {msg} contains the codepoint {code_point}"; + hint: "codepoints from the Unicode private area are \ + forbidden in this export mode" + ) + } + ValidationError::Transparency(loc) => { + let span = to_span(*loc); + let hint1 = "try exporting with a different standard that \ + supports transparency"; + if loc.is_some() { + if gc.image_spans.contains(&span) { + error!( + span, "{prefix} the image contains transparency"; + hint: "{hint1}"; + hint: "or convert the image to a non-transparent one"; + hint: "you might have to convert SVGs into \ + non-transparent bitmap images" + ) + } else { + error!( + span, "{prefix} the used fill or stroke has transparency"; + hint: "{hint1}"; + hint: "or don't use colors with transparency in \ + this export mode" + ) + } + } else { + error!( + span, "{prefix} the PDF contains transparency"; + hint: "{hint1}" + ) + } + } + ValidationError::ImageInterpolation(loc) => { + let span = to_span(*loc); + if loc.is_some() { + error!( + span, "{prefix} the image has smooth scaling"; + hint: "set the `scaling` attribute to `pixelated`" + ) + } else { + error!( + span, "{prefix} an image in the PDF has smooth scaling"; + hint: "set the `scaling` attribute of all images to `pixelated`" + ) + } + } + ValidationError::EmbeddedFile(e, s) => { + // We always set the span for embedded files, so it cannot be detached. + let span = to_span(*s); + match e { + EmbedError::Existence => { + error!( + span, "{prefix} document contains an embedded file"; + hint: "embedded files are not supported in this export mode" + ) + } + EmbedError::MissingDate => { + error!( + span, "{prefix} document date is missing"; + hint: "the document must have a date when embedding files"; + hint: "`set document(date: none)` must not be used in this case" + ) + } + EmbedError::MissingDescription => { + error!(span, "{prefix} the file description is missing") + } + EmbedError::MissingMimeType => { + error!(span, "{prefix} the file mime type is missing") + } + } + } + // The below errors cannot occur yet, only once Typst supports full PDF/A + // and PDF/UA. But let's still add a message just to be on the safe side. + ValidationError::MissingAnnotationAltText => error!( + Span::detached(), + "{prefix} missing annotation alt text"; + hint: "please report this as a bug" + ), + ValidationError::MissingAltText => error!( + Span::detached(), + "{prefix} missing alt text"; + hint: "make sure your images and equations have alt text" + ), + ValidationError::NoDocumentLanguage => error!( + Span::detached(), + "{prefix} missing document language"; + hint: "set the language of the document" + ), + // Needs to be set by typst-pdf. + ValidationError::MissingHeadingTitle => error!( + Span::detached(), + "{prefix} missing heading title"; + hint: "please report this as a bug" + ), + ValidationError::MissingDocumentOutline => error!( + Span::detached(), + "{prefix} missing document outline"; + hint: "please report this as a bug" + ), + ValidationError::MissingTagging => error!( + Span::detached(), + "{prefix} missing document tags"; + hint: "please report this as a bug" + ), + ValidationError::NoDocumentTitle => error!( + Span::detached(), + "{prefix} missing document title"; + hint: "set the title of the document" + ), + ValidationError::MissingDocumentDate => error!( + Span::detached(), + "{prefix} missing document date"; + hint: "set the date of the document" + ), + } +} + +/// Convert a krilla location to a span. +fn to_span(loc: Option) -> Span { + loc.map(|l| Span::from_raw(NonZeroU64::new(l).unwrap())) + .unwrap_or(Span::detached()) +} + +fn collect_named_destinations( + document: &PagedDocument, + pic: &PageIndexConverter, +) -> HashMap { + let mut locs_to_names = HashMap::new(); + + // Find all headings that have a label and are the first among other + // headings with the same label. + let matches: Vec<_> = { + let mut seen = HashSet::new(); + document + .introspector + .query(&HeadingElem::elem().select()) + .iter() + .filter_map(|elem| elem.location().zip(elem.label())) + .filter(|&(_, label)| seen.insert(label)) + .collect() + }; + + for (loc, label) in matches { + let pos = document.introspector.position(loc); + let index = pos.page.get() - 1; + // We are subtracting 10 because the position of links e.g. to headings is always at the + // baseline and if you link directly to it, the text will not be visible + // because it is right above. + let y = (pos.point.y - Abs::pt(10.0)).max(Abs::zero()); + + // Only add named destination if page belonging to the position is exported. + if let Some(index) = pic.pdf_page_index(index) { + let named = NamedDestination::new( + label.resolve().to_string(), + XyzDestination::new( + index, + krilla::geom::Point::from_xy(pos.point.x.to_f32(), y.to_f32()), + ), + ); + locs_to_names.insert(loc, named); + } + } + + locs_to_names +} + +pub(crate) struct PageIndexConverter { + page_indices: HashMap, + skipped_pages: usize, +} + +impl PageIndexConverter { + pub fn new(document: &PagedDocument, options: &PdfOptions) -> Self { + let mut page_indices = HashMap::new(); + let mut skipped_pages = 0; + + for i in 0..document.pages.len() { + if options + .page_ranges + .as_ref() + .is_some_and(|ranges| !ranges.includes_page_index(i)) + { + skipped_pages += 1; + } else { + page_indices.insert(i, i - skipped_pages); + } + } + + Self { page_indices, skipped_pages } + } + + pub(crate) fn has_skipped_pages(&self) -> bool { + self.skipped_pages > 0 + } + + /// Get the PDF page index of a page index, if it's not excluded. + pub(crate) fn pdf_page_index(&self, page_index: usize) -> Option { + self.page_indices.get(&page_index).copied() + } +} diff --git a/crates/typst-pdf/src/embed.rs b/crates/typst-pdf/src/embed.rs index 597638f4bd..6ed65a2b69 100644 --- a/crates/typst-pdf/src/embed.rs +++ b/crates/typst-pdf/src/embed.rs @@ -1,122 +1,54 @@ -use std::collections::BTreeMap; +use std::sync::Arc; -use ecow::EcoString; -use pdf_writer::types::AssociationKind; -use pdf_writer::{Filter, Finish, Name, Ref, Str, TextStr}; +use krilla::embed::{AssociationKind, EmbeddedFile}; +use krilla::Document; use typst_library::diag::{bail, SourceResult}; -use typst_library::foundations::{NativeElement, Packed, StyleChain}; +use typst_library::foundations::{NativeElement, StyleChain}; +use typst_library::layout::PagedDocument; use typst_library::pdf::{EmbedElem, EmbeddedFileRelationship}; -use crate::catalog::{document_date, pdf_date}; -use crate::{deflate, NameExt, PdfChunk, StrExt, WithGlobalRefs}; +pub(crate) fn embed_files( + typst_doc: &PagedDocument, + document: &mut Document, +) -> SourceResult<()> { + let elements = typst_doc.introspector.query(&EmbedElem::elem().select()); -/// Query for all [`EmbedElem`] and write them and their file specifications. -/// -/// This returns a map of embedding names and references so that we can later -/// add them to the catalog's `/Names` dictionary. -pub fn write_embedded_files( - ctx: &WithGlobalRefs, -) -> SourceResult<(PdfChunk, BTreeMap)> { - let mut chunk = PdfChunk::new(); - let mut embedded_files = BTreeMap::default(); - - let elements = ctx.document.introspector.query(&EmbedElem::elem().select()); for elem in &elements { - if !ctx.options.standards.embedded_files { - // PDF/A-2 requires embedded files to be PDF/A-1 or PDF/A-2, - // which we don't currently check. - bail!( - elem.span(), - "file embeddings are not currently supported for PDF/A-2"; - hint: "PDF/A-3 supports arbitrary embedded files" - ); - } - let embed = elem.to_packed::().unwrap(); - if embed.path.derived.len() > Str::PDFA_LIMIT { - bail!(embed.span(), "embedded file path is too long"); - } - - let id = embed_file(ctx, &mut chunk, embed)?; - if embedded_files.insert(embed.path.derived.clone(), id).is_some() { - bail!( - elem.span(), - "duplicate embedded file for path `{}`", embed.path.derived; - hint: "embedded file paths must be unique", - ); - } - } - - Ok((chunk, embedded_files)) -} - -/// Write the embedded file stream and its file specification. -fn embed_file( - ctx: &WithGlobalRefs, - chunk: &mut PdfChunk, - embed: &Packed, -) -> SourceResult { - let embedded_file_stream_ref = chunk.alloc.bump(); - let file_spec_dict_ref = chunk.alloc.bump(); - - let data = embed.data.as_slice(); - let compressed = deflate(data); - - let mut embedded_file = chunk.embedded_file(embedded_file_stream_ref, &compressed); - embedded_file.filter(Filter::FlateDecode); - - if let Some(mime_type) = embed.mime_type(StyleChain::default()) { - if mime_type.len() > Name::PDFA_LIMIT { - bail!(embed.span(), "embedded file MIME type is too long"); - } - embedded_file.subtype(Name(mime_type.as_bytes())); - } else if ctx.options.standards.pdfa { - bail!(embed.span(), "embedded files must have a MIME type in PDF/A-3"); - } - - let mut params = embedded_file.params(); - params.size(data.len() as i32); - - let (date, tz) = document_date(ctx.document.info.date, ctx.options.timestamp); - if let Some(pdf_date) = date.and_then(|date| pdf_date(date, tz)) { - params.modification_date(pdf_date); - } else if ctx.options.standards.pdfa { - bail!( - embed.span(), - "the document must have a date when embedding files in PDF/A-3"; - hint: "`set document(date: none)` must not be used in this case" - ); - } - - params.finish(); - embedded_file.finish(); - - let mut file_spec = chunk.file_spec(file_spec_dict_ref); - file_spec.path(Str(embed.path.derived.as_bytes())); - file_spec.unic_file(TextStr(&embed.path.derived)); - file_spec - .insert(Name(b"EF")) - .dict() - .pair(Name(b"F"), embedded_file_stream_ref) - .pair(Name(b"UF"), embedded_file_stream_ref); - - if ctx.options.standards.pdfa { - // PDF 2.0, but ISO 19005-3 (PDF/A-3) Annex E allows it for PDF/A-3. - file_spec.association_kind(match embed.relationship(StyleChain::default()) { - Some(EmbeddedFileRelationship::Source) => AssociationKind::Source, - Some(EmbeddedFileRelationship::Data) => AssociationKind::Data, - Some(EmbeddedFileRelationship::Alternative) => AssociationKind::Alternative, - Some(EmbeddedFileRelationship::Supplement) => AssociationKind::Supplement, + let span = embed.span(); + let derived_path = &embed.path.derived; + let path = derived_path.to_string(); + let mime_type = + embed.mime_type(StyleChain::default()).clone().map(|s| s.to_string()); + let description = embed + .description(StyleChain::default()) + .clone() + .map(|s| s.to_string()); + let association_kind = match embed.relationship(StyleChain::default()) { None => AssociationKind::Unspecified, - }); - } - - if let Some(description) = embed.description(StyleChain::default()) { - if description.len() > Str::PDFA_LIMIT { - bail!(embed.span(), "embedded file description is too long"); + Some(e) => match e { + EmbeddedFileRelationship::Source => AssociationKind::Source, + EmbeddedFileRelationship::Data => AssociationKind::Data, + EmbeddedFileRelationship::Alternative => AssociationKind::Alternative, + EmbeddedFileRelationship::Supplement => AssociationKind::Supplement, + }, + }; + let data: Arc + Send + Sync> = Arc::new(embed.data.clone()); + + let file = EmbeddedFile { + path, + mime_type, + description, + association_kind, + data: data.into(), + compress: true, + location: Some(span.into_raw().get()), + }; + + if document.embed_file(file).is_none() { + bail!(span, "attempted to embed file {derived_path} twice"); } - file_spec.description(TextStr(description)); } - Ok(file_spec_dict_ref) + Ok(()) } diff --git a/crates/typst-pdf/src/extg.rs b/crates/typst-pdf/src/extg.rs deleted file mode 100644 index 06617d8d21..0000000000 --- a/crates/typst-pdf/src/extg.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::collections::HashMap; - -use pdf_writer::Ref; -use typst_library::diag::SourceResult; - -use crate::{PdfChunk, WithGlobalRefs}; - -/// A PDF external graphics state. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] -pub struct ExtGState { - // In the range 0-255, needs to be divided before being written into the graphics state! - pub stroke_opacity: u8, - // In the range 0-255, needs to be divided before being written into the graphics state! - pub fill_opacity: u8, -} - -impl Default for ExtGState { - fn default() -> Self { - Self { stroke_opacity: 255, fill_opacity: 255 } - } -} - -impl ExtGState { - pub fn uses_opacities(&self) -> bool { - self.stroke_opacity != 255 || self.fill_opacity != 255 - } -} - -/// Embed all used external graphics states into the PDF. -pub fn write_graphic_states( - context: &WithGlobalRefs, -) -> SourceResult<(PdfChunk, HashMap)> { - let mut chunk = PdfChunk::new(); - let mut out = HashMap::new(); - context.resources.traverse(&mut |resources| { - for external_gs in resources.ext_gs.items() { - if out.contains_key(external_gs) { - continue; - } - - let id = chunk.alloc(); - out.insert(*external_gs, id); - chunk - .ext_graphics(id) - .non_stroking_alpha(external_gs.fill_opacity as f32 / 255.0) - .stroking_alpha(external_gs.stroke_opacity as f32 / 255.0); - } - - Ok(()) - })?; - - Ok((chunk, out)) -} diff --git a/crates/typst-pdf/src/font.rs b/crates/typst-pdf/src/font.rs deleted file mode 100644 index f2df2ac92a..0000000000 --- a/crates/typst-pdf/src/font.rs +++ /dev/null @@ -1,278 +0,0 @@ -use std::collections::{BTreeMap, HashMap}; -use std::hash::Hash; -use std::sync::Arc; - -use ecow::{eco_format, EcoString}; -use pdf_writer::types::{CidFontType, FontFlags, SystemInfo, UnicodeCmap}; -use pdf_writer::writers::{FontDescriptor, WMode}; -use pdf_writer::{Chunk, Filter, Finish, Name, Rect, Ref, Str}; -use subsetter::GlyphRemapper; -use ttf_parser::{name_id, GlyphId, Tag}; -use typst_library::diag::{At, SourceResult}; -use typst_library::text::Font; -use typst_syntax::Span; -use typst_utils::SliceExt; - -use crate::{deflate, EmExt, NameExt, PdfChunk, WithGlobalRefs}; - -const CFF: Tag = Tag::from_bytes(b"CFF "); -const CFF2: Tag = Tag::from_bytes(b"CFF2"); - -const SUBSET_TAG_LEN: usize = 6; -const IDENTITY_H: &str = "Identity-H"; - -pub(crate) const CMAP_NAME: Name = Name(b"Custom"); -pub(crate) const SYSTEM_INFO: SystemInfo = SystemInfo { - registry: Str(b"Adobe"), - ordering: Str(b"Identity"), - supplement: 0, -}; - -/// Embed all used fonts into the PDF. -#[typst_macros::time(name = "write fonts")] -pub fn write_fonts( - context: &WithGlobalRefs, -) -> SourceResult<(PdfChunk, HashMap)> { - let mut chunk = PdfChunk::new(); - let mut out = HashMap::new(); - context.resources.traverse(&mut |resources| { - for font in resources.fonts.items() { - if out.contains_key(font) { - continue; - } - - let type0_ref = chunk.alloc(); - let cid_ref = chunk.alloc(); - let descriptor_ref = chunk.alloc(); - let cmap_ref = chunk.alloc(); - let data_ref = chunk.alloc(); - out.insert(font.clone(), type0_ref); - - let glyph_set = resources.glyph_sets.get(font).unwrap(); - let glyph_remapper = resources.glyph_remappers.get(font).unwrap(); - let ttf = font.ttf(); - - // Do we have a TrueType or CFF font? - // - // FIXME: CFF2 must be handled differently and requires PDF 2.0 - // (or we have to convert it to CFF). - let is_cff = ttf - .raw_face() - .table(CFF) - .or_else(|| ttf.raw_face().table(CFF2)) - .is_some(); - - let base_font = base_font_name(font, glyph_set); - let base_font_type0 = if is_cff { - eco_format!("{base_font}-{IDENTITY_H}") - } else { - base_font.clone() - }; - - // Write the base font object referencing the CID font. - chunk - .type0_font(type0_ref) - .base_font(Name(base_font_type0.as_bytes())) - .encoding_predefined(Name(IDENTITY_H.as_bytes())) - .descendant_font(cid_ref) - .to_unicode(cmap_ref); - - // Write the CID font referencing the font descriptor. - let mut cid = chunk.cid_font(cid_ref); - cid.subtype(if is_cff { CidFontType::Type0 } else { CidFontType::Type2 }); - cid.base_font(Name(base_font.as_bytes())); - cid.system_info(SYSTEM_INFO); - cid.font_descriptor(descriptor_ref); - cid.default_width(0.0); - if !is_cff { - cid.cid_to_gid_map_predefined(Name(b"Identity")); - } - - // Extract the widths of all glyphs. - // `remapped_gids` returns an iterator over the old GIDs in their new sorted - // order, so we can append the widths as is. - let widths = glyph_remapper - .remapped_gids() - .map(|gid| { - let width = ttf.glyph_hor_advance(GlyphId(gid)).unwrap_or(0); - font.to_em(width).to_font_units() - }) - .collect::>(); - - // Write all non-zero glyph widths. - let mut first = 0; - let mut width_writer = cid.widths(); - for (w, group) in widths.group_by_key(|&w| w) { - let end = first + group.len(); - if w != 0.0 { - let last = end - 1; - width_writer.same(first as u16, last as u16, w); - } - first = end; - } - - width_writer.finish(); - cid.finish(); - - // Write the /ToUnicode character map, which maps glyph ids back to - // unicode codepoints to enable copying out of the PDF. - let cmap = create_cmap(glyph_set, glyph_remapper); - chunk - .cmap(cmap_ref, &cmap) - .writing_mode(WMode::Horizontal) - .filter(Filter::FlateDecode); - - let subset = subset_font(font, glyph_remapper) - .map_err(|err| { - let postscript_name = font.find_name(name_id::POST_SCRIPT_NAME); - let name = postscript_name.as_deref().unwrap_or(&font.info().family); - eco_format!("failed to process font {name}: {err}") - }) - .at(Span::detached())?; - - let mut stream = chunk.stream(data_ref, &subset); - stream.filter(Filter::FlateDecode); - if is_cff { - stream.pair(Name(b"Subtype"), Name(b"CIDFontType0C")); - } - stream.finish(); - - let mut font_descriptor = - write_font_descriptor(&mut chunk, descriptor_ref, font, &base_font); - if is_cff { - font_descriptor.font_file3(data_ref); - } else { - font_descriptor.font_file2(data_ref); - } - } - - Ok(()) - })?; - - Ok((chunk, out)) -} - -/// Writes a FontDescriptor dictionary. -pub fn write_font_descriptor<'a>( - pdf: &'a mut Chunk, - descriptor_ref: Ref, - font: &'a Font, - base_font: &str, -) -> FontDescriptor<'a> { - let ttf = font.ttf(); - let metrics = font.metrics(); - let serif = font - .find_name(name_id::POST_SCRIPT_NAME) - .is_some_and(|name| name.contains("Serif")); - - let mut flags = FontFlags::empty(); - flags.set(FontFlags::SERIF, serif); - flags.set(FontFlags::FIXED_PITCH, ttf.is_monospaced()); - flags.set(FontFlags::ITALIC, ttf.is_italic()); - flags.insert(FontFlags::SYMBOLIC); - flags.insert(FontFlags::SMALL_CAP); - - let global_bbox = ttf.global_bounding_box(); - let bbox = Rect::new( - font.to_em(global_bbox.x_min).to_font_units(), - font.to_em(global_bbox.y_min).to_font_units(), - font.to_em(global_bbox.x_max).to_font_units(), - font.to_em(global_bbox.y_max).to_font_units(), - ); - - let italic_angle = ttf.italic_angle(); - let ascender = metrics.ascender.to_font_units(); - let descender = metrics.descender.to_font_units(); - let cap_height = metrics.cap_height.to_font_units(); - let stem_v = 10.0 + 0.244 * (f32::from(ttf.weight().to_number()) - 50.0); - - // Write the font descriptor (contains metrics about the font). - let mut font_descriptor = pdf.font_descriptor(descriptor_ref); - font_descriptor - .name(Name(base_font.as_bytes())) - .flags(flags) - .bbox(bbox) - .italic_angle(italic_angle) - .ascent(ascender) - .descent(descender) - .cap_height(cap_height) - .stem_v(stem_v); - - font_descriptor -} - -/// Subset a font to the given glyphs. -/// -/// - For a font with TrueType outlines, this produces the whole OpenType font. -/// - For a font with CFF outlines, this produces just the CFF font program. -/// -/// In both cases, this returns the already compressed data. -#[comemo::memoize] -#[typst_macros::time(name = "subset font")] -fn subset_font( - font: &Font, - glyph_remapper: &GlyphRemapper, -) -> Result>, subsetter::Error> { - let data = font.data(); - let subset = subsetter::subset(data, font.index(), glyph_remapper)?; - let mut data = subset.as_ref(); - - // Extract the standalone CFF font program if applicable. - let raw = ttf_parser::RawFace::parse(data, 0).unwrap(); - if let Some(cff) = raw.table(CFF) { - data = cff; - } - - Ok(Arc::new(deflate(data))) -} - -/// Creates the base font name for a font with a specific glyph subset. -/// Consists of a subset tag and the PostScript name of the font. -/// -/// Returns a string of length maximum 116, so that even with `-Identity-H` -/// added it does not exceed the maximum PDF/A name length of 127. -pub(crate) fn base_font_name(font: &Font, glyphs: &T) -> EcoString { - const MAX_LEN: usize = Name::PDFA_LIMIT - REST_LEN; - const REST_LEN: usize = SUBSET_TAG_LEN + 1 + 1 + IDENTITY_H.len(); - - let postscript_name = font.find_name(name_id::POST_SCRIPT_NAME); - let name = postscript_name.as_deref().unwrap_or("unknown"); - let trimmed = &name[..name.len().min(MAX_LEN)]; - - // Hash the full name (we might have trimmed) and the glyphs to produce - // a fairly unique subset tag. - let subset_tag = subset_tag(&(name, glyphs)); - - eco_format!("{subset_tag}+{trimmed}") -} - -/// Produce a unique 6 letter tag for a glyph set. -pub(crate) fn subset_tag(glyphs: &T) -> EcoString { - const BASE: u128 = 26; - let mut hash = typst_utils::hash128(&glyphs); - let mut letter = [b'A'; SUBSET_TAG_LEN]; - for l in letter.iter_mut() { - *l = b'A' + (hash % BASE) as u8; - hash /= BASE; - } - std::str::from_utf8(&letter).unwrap().into() -} - -/// Create a compressed `/ToUnicode` CMap. -#[comemo::memoize] -#[typst_macros::time(name = "create cmap")] -fn create_cmap( - glyph_set: &BTreeMap, - glyph_remapper: &GlyphRemapper, -) -> Arc> { - // Produce a reverse mapping from glyphs' CIDs to unicode strings. - let mut cmap = UnicodeCmap::new(CMAP_NAME, SYSTEM_INFO); - for (&g, text) in glyph_set.iter() { - // See commend in `write_normal_text` for why we can choose the CID this way. - let cid = glyph_remapper.get(g).unwrap(); - if !text.is_empty() { - cmap.pair_with_multiple(cid, text.chars()); - } - } - Arc::new(deflate(&cmap.finish())) -} diff --git a/crates/typst-pdf/src/gradient.rs b/crates/typst-pdf/src/gradient.rs deleted file mode 100644 index 6cd4c1ae8d..0000000000 --- a/crates/typst-pdf/src/gradient.rs +++ /dev/null @@ -1,512 +0,0 @@ -use std::collections::HashMap; -use std::f32::consts::{PI, TAU}; -use std::sync::Arc; - -use ecow::eco_format; -use pdf_writer::types::{ColorSpaceOperand, FunctionShadingType}; -use pdf_writer::writers::StreamShadingType; -use pdf_writer::{Filter, Finish, Name, Ref}; -use typst_library::diag::SourceResult; -use typst_library::layout::{Abs, Angle, Point, Quadrant, Ratio, Transform}; -use typst_library::visualize::{ - Color, ColorSpace, Gradient, RatioOrAngle, RelativeTo, WeightedColor, -}; -use typst_utils::Numeric; - -use crate::color::{ - self, check_cmyk_allowed, ColorSpaceExt, PaintEncode, QuantizedColor, -}; -use crate::{content, deflate, transform_to_array, AbsExt, PdfChunk, WithGlobalRefs}; - -/// A unique-transform-aspect-ratio combination that will be encoded into the -/// PDF. -#[derive(Debug, Clone, Eq, PartialEq, Hash)] -pub struct PdfGradient { - /// The transform to apply to the gradient. - pub transform: Transform, - /// The aspect ratio of the gradient. - /// Required for aspect ratio correction. - pub aspect_ratio: Ratio, - /// The gradient. - pub gradient: Gradient, - /// The corrected angle of the gradient. - pub angle: Angle, -} - -/// Writes the actual gradients (shading patterns) to the PDF. -/// This is performed once after writing all pages. -pub fn write_gradients( - context: &WithGlobalRefs, -) -> SourceResult<(PdfChunk, HashMap)> { - let mut chunk = PdfChunk::new(); - let mut out = HashMap::new(); - context.resources.traverse(&mut |resources| { - for pdf_gradient in resources.gradients.items() { - if out.contains_key(pdf_gradient) { - continue; - } - - let shading = chunk.alloc(); - out.insert(pdf_gradient.clone(), shading); - - let PdfGradient { transform, aspect_ratio, gradient, angle } = pdf_gradient; - - let color_space = if gradient.space().hue_index().is_some() { - ColorSpace::Oklab - } else { - gradient.space() - }; - - if color_space == ColorSpace::Cmyk { - check_cmyk_allowed(context.options)?; - } - - let mut shading_pattern = match &gradient { - Gradient::Linear(_) => { - let shading_function = - shading_function(gradient, &mut chunk, color_space); - let mut shading_pattern = chunk.chunk.shading_pattern(shading); - let mut shading = shading_pattern.function_shading(); - shading.shading_type(FunctionShadingType::Axial); - - color::write( - color_space, - shading.color_space(), - &context.globals.color_functions, - ); - - let (mut sin, mut cos) = (angle.sin(), angle.cos()); - - // Scale to edges of unit square. - let factor = cos.abs() + sin.abs(); - sin *= factor; - cos *= factor; - - let (x1, y1, x2, y2): (f64, f64, f64, f64) = match angle.quadrant() { - Quadrant::First => (0.0, 0.0, cos, sin), - Quadrant::Second => (1.0, 0.0, cos + 1.0, sin), - Quadrant::Third => (1.0, 1.0, cos + 1.0, sin + 1.0), - Quadrant::Fourth => (0.0, 1.0, cos, sin + 1.0), - }; - - shading - .anti_alias(gradient.anti_alias()) - .function(shading_function) - .coords([x1 as f32, y1 as f32, x2 as f32, y2 as f32]) - .extend([true; 2]); - - shading.finish(); - - shading_pattern - } - Gradient::Radial(radial) => { - let shading_function = - shading_function(gradient, &mut chunk, color_space_of(gradient)); - let mut shading_pattern = chunk.chunk.shading_pattern(shading); - let mut shading = shading_pattern.function_shading(); - shading.shading_type(FunctionShadingType::Radial); - - color::write( - color_space, - shading.color_space(), - &context.globals.color_functions, - ); - - shading - .anti_alias(gradient.anti_alias()) - .function(shading_function) - .coords([ - radial.focal_center.x.get() as f32, - radial.focal_center.y.get() as f32, - radial.focal_radius.get() as f32, - radial.center.x.get() as f32, - radial.center.y.get() as f32, - radial.radius.get() as f32, - ]) - .extend([true; 2]); - - shading.finish(); - - shading_pattern - } - Gradient::Conic(_) => { - let vertices = compute_vertex_stream(gradient, *aspect_ratio); - - let stream_shading_id = chunk.alloc(); - let mut stream_shading = - chunk.chunk.stream_shading(stream_shading_id, &vertices); - - color::write( - color_space, - stream_shading.color_space(), - &context.globals.color_functions, - ); - - let range = color_space.range(); - stream_shading - .bits_per_coordinate(16) - .bits_per_component(16) - .bits_per_flag(8) - .shading_type(StreamShadingType::CoonsPatch) - .decode( - [0.0, 1.0, 0.0, 1.0].into_iter().chain(range.iter().copied()), - ) - .anti_alias(gradient.anti_alias()) - .filter(Filter::FlateDecode); - - stream_shading.finish(); - - let mut shading_pattern = chunk.shading_pattern(shading); - shading_pattern.shading_ref(stream_shading_id); - shading_pattern - } - }; - - shading_pattern.matrix(transform_to_array(*transform)); - } - - Ok(()) - })?; - - Ok((chunk, out)) -} - -/// Writes an exponential or stitched function that expresses the gradient. -fn shading_function( - gradient: &Gradient, - chunk: &mut PdfChunk, - color_space: ColorSpace, -) -> Ref { - let function = chunk.alloc(); - let mut functions = vec![]; - let mut bounds = vec![]; - let mut encode = vec![]; - - // Create the individual gradient functions for each pair of stops. - for window in gradient.stops_ref().windows(2) { - let (first, second) = (window[0], window[1]); - - // If we have a hue index or are using Oklab, we will create several - // stops in-between to make the gradient smoother without interpolation - // issues with native color spaces. - let mut last_c = first.0; - if gradient.space().hue_index().is_some() { - for i in 0..=32 { - let t = i as f64 / 32.0; - let real_t = first.1.get() * (1.0 - t) + second.1.get() * t; - - let c = gradient.sample(RatioOrAngle::Ratio(Ratio::new(real_t))); - functions.push(single_gradient(chunk, last_c, c, color_space)); - bounds.push(real_t as f32); - encode.extend([0.0, 1.0]); - last_c = c; - } - } - - bounds.push(second.1.get() as f32); - functions.push(single_gradient(chunk, first.0, second.0, color_space)); - encode.extend([0.0, 1.0]); - } - - // Special case for gradients with only two stops. - if functions.len() == 1 { - return functions[0]; - } - - // Remove the last bound, since it's not needed for the stitching function. - bounds.pop(); - - // Create the stitching function. - chunk - .stitching_function(function) - .domain([0.0, 1.0]) - .range(color_space.range().iter().copied()) - .functions(functions) - .bounds(bounds) - .encode(encode); - - function -} - -/// Writes an exponential function that expresses a single segment (between two -/// stops) of a gradient. -fn single_gradient( - chunk: &mut PdfChunk, - first_color: Color, - second_color: Color, - color_space: ColorSpace, -) -> Ref { - let reference = chunk.alloc(); - chunk - .exponential_function(reference) - .range(color_space.range().iter().copied()) - .c0(color_space.convert(first_color)) - .c1(color_space.convert(second_color)) - .domain([0.0, 1.0]) - .n(1.0); - - reference -} - -impl PaintEncode for Gradient { - fn set_as_fill( - &self, - ctx: &mut content::Builder, - on_text: bool, - transforms: content::Transforms, - ) -> SourceResult<()> { - ctx.reset_fill_color_space(); - - let index = register_gradient(ctx, self, on_text, transforms); - let id = eco_format!("Gr{index}"); - let name = Name(id.as_bytes()); - - ctx.content.set_fill_color_space(ColorSpaceOperand::Pattern); - ctx.content.set_fill_pattern(None, name); - Ok(()) - } - - fn set_as_stroke( - &self, - ctx: &mut content::Builder, - on_text: bool, - transforms: content::Transforms, - ) -> SourceResult<()> { - ctx.reset_stroke_color_space(); - - let index = register_gradient(ctx, self, on_text, transforms); - let id = eco_format!("Gr{index}"); - let name = Name(id.as_bytes()); - - ctx.content.set_stroke_color_space(ColorSpaceOperand::Pattern); - ctx.content.set_stroke_pattern(None, name); - Ok(()) - } -} - -/// Deduplicates a gradient to a named PDF resource. -fn register_gradient( - ctx: &mut content::Builder, - gradient: &Gradient, - on_text: bool, - mut transforms: content::Transforms, -) -> usize { - // Edge cases for strokes. - if transforms.size.x.is_zero() { - transforms.size.x = Abs::pt(1.0); - } - - if transforms.size.y.is_zero() { - transforms.size.y = Abs::pt(1.0); - } - let size = match gradient.unwrap_relative(on_text) { - RelativeTo::Self_ => transforms.size, - RelativeTo::Parent => transforms.container_size, - }; - - let (offset_x, offset_y) = match gradient { - Gradient::Conic(conic) => ( - -size.x * (1.0 - conic.center.x.get() / 2.0) / 2.0, - -size.y * (1.0 - conic.center.y.get() / 2.0) / 2.0, - ), - _ => (Abs::zero(), Abs::zero()), - }; - - let rotation = gradient.angle().unwrap_or_else(Angle::zero); - - let transform = match gradient.unwrap_relative(on_text) { - RelativeTo::Self_ => transforms.transform, - RelativeTo::Parent => transforms.container_transform, - }; - - let scale_offset = match gradient { - Gradient::Conic(_) => 4.0_f64, - _ => 1.0, - }; - - let pdf_gradient = PdfGradient { - aspect_ratio: size.aspect_ratio(), - transform: transform - .pre_concat(Transform::translate( - offset_x * scale_offset, - offset_y * scale_offset, - )) - .pre_concat(Transform::scale( - Ratio::new(size.x.to_pt() * scale_offset), - Ratio::new(size.y.to_pt() * scale_offset), - )), - gradient: gradient.clone(), - angle: Gradient::correct_aspect_ratio(rotation, size.aspect_ratio()), - }; - - ctx.resources.colors.mark_as_used(color_space_of(gradient)); - - ctx.resources.gradients.insert(pdf_gradient) -} - -/// Writes a single Coons Patch as defined in the PDF specification -/// to a binary vec. -/// -/// Structure: -/// - flag: `u8` -/// - points: `[u16; 24]` -/// - colors: `[u16; 4*N]` (N = number of components) -fn write_patch( - target: &mut Vec, - t: f32, - t1: f32, - c0: &[u16], - c1: &[u16], - angle: Angle, -) { - let theta = -TAU * t + angle.to_rad() as f32 + PI; - let theta1 = -TAU * t1 + angle.to_rad() as f32 + PI; - - let (cp1, cp2) = - control_point(Point::new(Abs::pt(0.5), Abs::pt(0.5)), 0.5, theta, theta1); - - // Push the flag - target.push(0); - - let p1 = - [u16::quantize(0.5, [0.0, 1.0]).to_be(), u16::quantize(0.5, [0.0, 1.0]).to_be()]; - - let p2 = [ - u16::quantize(theta.cos(), [-1.0, 1.0]).to_be(), - u16::quantize(theta.sin(), [-1.0, 1.0]).to_be(), - ]; - - let p3 = [ - u16::quantize(theta1.cos(), [-1.0, 1.0]).to_be(), - u16::quantize(theta1.sin(), [-1.0, 1.0]).to_be(), - ]; - - let cp1 = [ - u16::quantize(cp1.x.to_f32(), [0.0, 1.0]).to_be(), - u16::quantize(cp1.y.to_f32(), [0.0, 1.0]).to_be(), - ]; - - let cp2 = [ - u16::quantize(cp2.x.to_f32(), [0.0, 1.0]).to_be(), - u16::quantize(cp2.y.to_f32(), [0.0, 1.0]).to_be(), - ]; - - // Push the points - target.extend_from_slice(bytemuck::cast_slice(&[ - p1, p1, p2, p2, cp1, cp2, p3, p3, p1, p1, p1, p1, - ])); - - // Push the colors. - let colors = [c0, c0, c1, c1] - .into_iter() - .flat_map(|c| c.iter().copied().map(u16::to_be_bytes)) - .flatten(); - - target.extend(colors); -} - -fn control_point(c: Point, r: f32, angle_start: f32, angle_end: f32) -> (Point, Point) { - let n = (TAU / (angle_end - angle_start)).abs(); - let f = ((angle_end - angle_start) / n).tan() * 4.0 / 3.0; - - let p1 = c + Point::new( - Abs::pt((r * angle_start.cos() - f * r * angle_start.sin()) as f64), - Abs::pt((r * angle_start.sin() + f * r * angle_start.cos()) as f64), - ); - - let p2 = c + Point::new( - Abs::pt((r * angle_end.cos() + f * r * angle_end.sin()) as f64), - Abs::pt((r * angle_end.sin() - f * r * angle_end.cos()) as f64), - ); - - (p1, p2) -} - -#[comemo::memoize] -fn compute_vertex_stream(gradient: &Gradient, aspect_ratio: Ratio) -> Arc> { - let Gradient::Conic(conic) = gradient else { unreachable!() }; - - // Generated vertices for the Coons patches - let mut vertices = Vec::new(); - - // Correct the gradient's angle - let angle = Gradient::correct_aspect_ratio(conic.angle, aspect_ratio); - - for window in conic.stops.windows(2) { - let ((c0, t0), (c1, t1)) = (window[0], window[1]); - - // Precision: - // - On an even color, insert a stop every 90deg - // - For a hue-based color space, insert 200 stops minimum - // - On any other, insert 20 stops minimum - let max_dt = if c0 == c1 { - 0.25 - } else if conic.space.hue_index().is_some() { - 0.005 - } else { - 0.05 - }; - let encode_space = conic - .space - .hue_index() - .map(|_| ColorSpace::Oklab) - .unwrap_or(conic.space); - let mut t_x = t0.get(); - let dt = (t1.get() - t0.get()).min(max_dt); - - // Special casing for sharp gradients. - if t0 == t1 { - write_patch( - &mut vertices, - t0.get() as f32, - t1.get() as f32, - &encode_space.convert(c0), - &encode_space.convert(c1), - angle, - ); - continue; - } - - while t_x < t1.get() { - let t_next = (t_x + dt).min(t1.get()); - - // The current progress in the current window. - let t = |t| (t - t0.get()) / (t1.get() - t0.get()); - let c = Color::mix_iter( - [WeightedColor::new(c0, 1.0 - t(t_x)), WeightedColor::new(c1, t(t_x))], - conic.space, - ) - .unwrap(); - - let c_next = Color::mix_iter( - [ - WeightedColor::new(c0, 1.0 - t(t_next)), - WeightedColor::new(c1, t(t_next)), - ], - conic.space, - ) - .unwrap(); - - write_patch( - &mut vertices, - t_x as f32, - t_next as f32, - &encode_space.convert(c), - &encode_space.convert(c_next), - angle, - ); - - t_x = t_next; - } - } - - Arc::new(deflate(&vertices)) -} - -fn color_space_of(gradient: &Gradient) -> ColorSpace { - if gradient.space().hue_index().is_some() { - ColorSpace::Oklab - } else { - gradient.space() - } -} diff --git a/crates/typst-pdf/src/image.rs b/crates/typst-pdf/src/image.rs index fa326e3e05..93bdb19506 100644 --- a/crates/typst-pdf/src/image.rs +++ b/crates/typst-pdf/src/image.rs @@ -1,249 +1,244 @@ -use std::collections::HashMap; -use std::io::Cursor; - -use ecow::eco_format; -use image::{DynamicImage, GenericImageView, Rgba}; -use pdf_writer::{Chunk, Filter, Finish, Ref}; -use typst_library::diag::{At, SourceResult, StrResult}; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, OnceLock}; + +use image::{DynamicImage, EncodableLayout, GenericImageView, Rgba}; +use krilla::image::{BitsPerComponent, CustomImage, ImageColorspace}; +use krilla::surface::Surface; +use krilla_svg::{SurfaceExt, SvgSettings}; +use typst_library::diag::{bail, SourceResult}; use typst_library::foundations::Smart; +use typst_library::layout::{Abs, Angle, Ratio, Size, Transform}; use typst_library::visualize::{ - ColorSpace, ExchangeFormat, Image, ImageKind, ImageScaling, RasterFormat, - RasterImage, SvgImage, + ExchangeFormat, Image, ImageKind, ImageScaling, RasterFormat, RasterImage, }; -use typst_utils::Deferred; - -use crate::{color, deflate, PdfChunk, WithGlobalRefs}; - -/// Embed all used images into the PDF. -#[typst_macros::time(name = "write images")] -pub fn write_images( - context: &WithGlobalRefs, -) -> SourceResult<(PdfChunk, HashMap)> { - let mut chunk = PdfChunk::new(); - let mut out = HashMap::new(); - context.resources.traverse(&mut |resources| { - for (i, image) in resources.images.items().enumerate() { - if out.contains_key(image) { - continue; - } - - let (handle, span) = resources.deferred_images.get(&i).unwrap(); - let encoded = handle.wait().as_ref().map_err(Clone::clone).at(*span)?; - - match encoded { - EncodedImage::Raster { - data, - filter, - color_space, - bits_per_component, - width, - height, - compressed_icc, - alpha, - interpolate, - } => { - let image_ref = chunk.alloc(); - out.insert(image.clone(), image_ref); - - let mut image = chunk.chunk.image_xobject(image_ref, data); - image.filter(*filter); - image.width(*width as i32); - image.height(*height as i32); - image.bits_per_component(i32::from(*bits_per_component)); - image.interpolate(*interpolate); - - let mut icc_ref = None; - let space = image.color_space(); - if compressed_icc.is_some() { - let id = chunk.alloc.bump(); - space.icc_based(id); - icc_ref = Some(id); - } else { - color::write( - *color_space, - space, - &context.globals.color_functions, - ); - } - - // Add a second gray-scale image containing the alpha values if - // this image has an alpha channel. - if let Some((alpha_data, alpha_filter)) = alpha { - let mask_ref = chunk.alloc.bump(); - image.s_mask(mask_ref); - image.finish(); - - let mut mask = chunk.image_xobject(mask_ref, alpha_data); - mask.filter(*alpha_filter); - mask.width(*width as i32); - mask.height(*height as i32); - mask.color_space().device_gray(); - mask.bits_per_component(i32::from(*bits_per_component)); - mask.interpolate(*interpolate); - } else { - image.finish(); - } - - if let (Some(compressed_icc), Some(icc_ref)) = - (compressed_icc, icc_ref) - { - let mut stream = chunk.icc_profile(icc_ref, compressed_icc); - stream.filter(Filter::FlateDecode); - match color_space { - ColorSpace::Srgb => { - stream.n(3); - stream.alternate().srgb(); - } - ColorSpace::D65Gray => { - stream.n(1); - stream.alternate().d65_gray(); - } - _ => unimplemented!(), - } - } - } - EncodedImage::Svg(svg_chunk, id) => { - let mut map = HashMap::new(); - svg_chunk.renumber_into(&mut chunk.chunk, |old| { - *map.entry(old).or_insert_with(|| chunk.alloc.bump()) - }); - out.insert(image.clone(), map[id]); - } - } - } +use typst_syntax::Span; + +use crate::convert::{FrameContext, GlobalContext}; +use crate::util::{SizeExt, TransformExt}; + +#[typst_macros::time(name = "handle image")] +pub(crate) fn handle_image( + gc: &mut GlobalContext, + fc: &mut FrameContext, + image: &Image, + size: Size, + surface: &mut Surface, + span: Span, +) -> SourceResult<()> { + surface.push_transform(&fc.state().transform().to_krilla()); + surface.set_location(span.into_raw().get()); + + let interpolate = image.scaling() == Smart::Custom(ImageScaling::Smooth); + + if let Some(alt) = image.alt() { + surface.start_alt_text(alt); + } - Ok(()) - })?; + gc.image_spans.insert(span); - Ok((chunk, out)) -} + match image.kind() { + ImageKind::Raster(raster) => { + let (exif_transform, new_size) = exif_transform(raster, size); + surface.push_transform(&exif_transform.to_krilla()); -/// Creates a new PDF image from the given image. -/// -/// Also starts the deferred encoding of the image. -#[comemo::memoize] -pub fn deferred_image( - image: Image, - pdfa: bool, -) -> (Deferred>, Option) { - let color_space = match image.kind() { - ImageKind::Raster(raster) if raster.icc().is_none() => { - Some(to_color_space(raster.dynamic().color())) - } - _ => None, - }; + let image = match convert_raster(raster.clone(), interpolate) { + None => bail!(span, "failed to process image"), + Some(i) => i, + }; - // PDF/A does not appear to allow interpolation. - // See https://github.com/typst/typst/issues/2942. - let interpolate = !pdfa && image.scaling() == Smart::Custom(ImageScaling::Smooth); + if !gc.image_to_spans.contains_key(&image) { + gc.image_to_spans.insert(image.clone(), span); + } - let deferred = Deferred::new(move || match image.kind() { - ImageKind::Raster(raster) => Ok(encode_raster_image(raster, interpolate)), + surface.draw_image(image, new_size.to_krilla()); + surface.pop(); + } ImageKind::Svg(svg) => { - let (chunk, id) = encode_svg(svg, pdfa) - .map_err(|err| eco_format!("failed to convert SVG to PDF: {err}"))?; - Ok(EncodedImage::Svg(chunk, id)) + surface.draw_svg( + svg.tree(), + size.to_krilla(), + SvgSettings { embed_text: true, ..Default::default() }, + ); } - }); + } - (deferred, color_space) + if image.alt().is_some() { + surface.end_alt_text(); + } + + surface.pop(); + surface.reset_location(); + + Ok(()) } -/// Encode an image with a suitable filter. -#[typst_macros::time(name = "encode raster image")] -fn encode_raster_image(image: &RasterImage, interpolate: bool) -> EncodedImage { - let dynamic = image.dynamic(); - let color_space = to_color_space(dynamic.color()); - - let (filter, data, bits_per_component) = - if image.format() == RasterFormat::Exchange(ExchangeFormat::Jpg) { - let mut data = Cursor::new(vec![]); - dynamic.write_to(&mut data, image::ImageFormat::Jpeg).unwrap(); - (Filter::DctDecode, data.into_inner(), 8) - } else { - // TODO: Encode flate streams with PNG-predictor? - let (data, bits_per_component) = match (dynamic, color_space) { - // RGB image. - (DynamicImage::ImageRgb8(rgb), _) => (deflate(rgb.as_raw()), 8), - // Grayscale image - (DynamicImage::ImageLuma8(luma), _) => (deflate(luma.as_raw()), 8), - (_, ColorSpace::D65Gray) => (deflate(dynamic.to_luma8().as_raw()), 8), - // Anything else - _ => (deflate(dynamic.to_rgb8().as_raw()), 8), - }; - (Filter::FlateDecode, data, bits_per_component) - }; - - let compressed_icc = image.icc().map(|data| deflate(data)); - let alpha = dynamic.color().has_alpha().then(|| encode_alpha(dynamic)); - - EncodedImage::Raster { - data, - filter, - color_space, - bits_per_component, - width: image.width(), - height: image.height(), - compressed_icc, - alpha, - interpolate, +struct Repr { + /// The original, underlying raster image. + raster: RasterImage, + /// The alpha channel of the raster image, if existing. + alpha_channel: OnceLock>>, + /// A (potentially) converted version of the dynamic image stored `raster` that is + /// guaranteed to either be in luma8 or rgb8, and thus can be used for the + /// `color_channel` method of `CustomImage`. + actual_dynamic: OnceLock>, +} + +/// A wrapper around `RasterImage` so that we can implement `CustomImage`. +#[derive(Clone)] +struct PdfImage(Arc); + +impl PdfImage { + pub fn new(raster: RasterImage) -> Self { + Self(Arc::new(Repr { + raster, + alpha_channel: OnceLock::new(), + actual_dynamic: OnceLock::new(), + })) } } -/// Encode an image's alpha channel if present. -#[typst_macros::time(name = "encode alpha")] -fn encode_alpha(image: &DynamicImage) -> (Vec, Filter) { - let pixels: Vec<_> = image.pixels().map(|(_, _, Rgba([_, _, _, a]))| a).collect(); - (deflate(&pixels), Filter::FlateDecode) +impl Hash for PdfImage { + fn hash(&self, state: &mut H) { + // `alpha_channel` and `actual_dynamic` are generated from the underlying `RasterImage`, + // so this is enough. Since `raster` is prehashed, this is also very cheap. + self.0.raster.hash(state); + } } -/// Encode an SVG into a chunk of PDF objects. -#[typst_macros::time(name = "encode svg")] -fn encode_svg( - svg: &SvgImage, - pdfa: bool, -) -> Result<(Chunk, Ref), svg2pdf::ConversionError> { - svg2pdf::to_chunk( - svg.tree(), - svg2pdf::ConversionOptions { pdfa, ..Default::default() }, - ) +impl CustomImage for PdfImage { + fn color_channel(&self) -> &[u8] { + self.0 + .actual_dynamic + .get_or_init(|| { + let dynamic = self.0.raster.dynamic(); + let channel_count = dynamic.color().channel_count(); + + match (dynamic.as_ref(), channel_count) { + // Pure luma8 or rgb8 image, can use it directly. + (DynamicImage::ImageLuma8(_), _) => dynamic.clone(), + (DynamicImage::ImageRgb8(_), _) => dynamic.clone(), + // Grey-scale image, convert to luma8. + (_, 1 | 2) => Arc::new(DynamicImage::ImageLuma8(dynamic.to_luma8())), + // Anything else, convert to rgb8. + _ => Arc::new(DynamicImage::ImageRgb8(dynamic.to_rgb8())), + } + }) + .as_bytes() + } + + fn alpha_channel(&self) -> Option<&[u8]> { + self.0 + .alpha_channel + .get_or_init(|| { + self.0.raster.dynamic().color().has_alpha().then(|| { + self.0 + .raster + .dynamic() + .pixels() + .map(|(_, _, Rgba([_, _, _, a]))| a) + .collect() + }) + }) + .as_ref() + .map(|v| &**v) + } + + fn bits_per_component(&self) -> BitsPerComponent { + BitsPerComponent::Eight + } + + fn size(&self) -> (u32, u32) { + (self.0.raster.width(), self.0.raster.height()) + } + + fn icc_profile(&self) -> Option<&[u8]> { + if matches!( + self.0.raster.dynamic().as_ref(), + DynamicImage::ImageLuma8(_) + | DynamicImage::ImageLumaA8(_) + | DynamicImage::ImageRgb8(_) + | DynamicImage::ImageRgba8(_) + ) { + self.0.raster.icc().map(|b| b.as_bytes()) + } else { + // In all other cases, the dynamic will be converted into RGB8 or LUMA8, so the ICC + // profile may become invalid, and thus we don't include it. + None + } + } + + fn color_space(&self) -> ImageColorspace { + // Remember that we convert all images to either RGB or luma. + if self.0.raster.dynamic().color().has_color() { + ImageColorspace::Rgb + } else { + ImageColorspace::Luma + } + } } -/// A pre-encoded image. -pub enum EncodedImage { - /// A pre-encoded rasterized image. - Raster { - /// The raw, pre-deflated image data. - data: Vec, - /// The filter to use for the image. - filter: Filter, - /// Which color space this image is encoded in. - color_space: ColorSpace, - /// How many bits of each color component are stored. - bits_per_component: u8, - /// The image's width. - width: u32, - /// The image's height. - height: u32, - /// The image's ICC profile, deflated, if any. - compressed_icc: Option>, - /// The alpha channel of the image, pre-deflated, if any. - alpha: Option<(Vec, Filter)>, - /// Whether image interpolation should be enabled. - interpolate: bool, - }, - /// A vector graphic. - /// - /// The chunk is the SVG converted to PDF objects. - Svg(Chunk, Ref), +#[comemo::memoize] +fn convert_raster( + raster: RasterImage, + interpolate: bool, +) -> Option { + if let RasterFormat::Exchange(ExchangeFormat::Jpg) = raster.format() { + let image_data: Arc + Send + Sync> = + Arc::new(raster.data().clone()); + let icc_profile = raster.icc().map(|i| { + let i: Arc + Send + Sync> = Arc::new(i.clone()); + i + }); + + krilla::image::Image::from_jpeg_with_icc( + image_data.into(), + icc_profile.map(|i| i.into()), + interpolate, + ) + } else { + krilla::image::Image::from_custom(PdfImage::new(raster), interpolate) + } } -/// Matches an [`image::ColorType`] to [`ColorSpace`]. -fn to_color_space(color: image::ColorType) -> ColorSpace { - use image::ColorType::*; - match color { - L8 | La8 | L16 | La16 => ColorSpace::D65Gray, - Rgb8 | Rgba8 | Rgb16 | Rgba16 | Rgb32F | Rgba32F => ColorSpace::Srgb, - _ => unimplemented!(), +fn exif_transform(image: &RasterImage, size: Size) -> (Transform, Size) { + let base = |hp: bool, vp: bool, mut base_ts: Transform, size: Size| { + if hp { + // Flip horizontally in-place. + base_ts = base_ts.pre_concat( + Transform::scale(-Ratio::one(), Ratio::one()) + .pre_concat(Transform::translate(-size.x, Abs::zero())), + ) + } + + if vp { + // Flip vertically in-place. + base_ts = base_ts.pre_concat( + Transform::scale(Ratio::one(), -Ratio::one()) + .pre_concat(Transform::translate(Abs::zero(), -size.y)), + ) + } + + base_ts + }; + + let no_flipping = + |hp: bool, vp: bool| (base(hp, vp, Transform::identity(), size), size); + + let with_flipping = |hp: bool, vp: bool| { + let base_ts = Transform::rotate_at(Angle::deg(90.0), Abs::zero(), Abs::zero()) + .pre_concat(Transform::scale(Ratio::one(), -Ratio::one())); + let inv_size = Size::new(size.y, size.x); + (base(hp, vp, base_ts, inv_size), inv_size) + }; + + match image.exif_rotation() { + Some(2) => no_flipping(true, false), + Some(3) => no_flipping(true, true), + Some(4) => no_flipping(false, true), + Some(5) => with_flipping(false, false), + Some(6) => with_flipping(true, false), + Some(7) => with_flipping(true, true), + Some(8) => with_flipping(false, true), + _ => no_flipping(false, false), } } diff --git a/crates/typst-pdf/src/lib.rs b/crates/typst-pdf/src/lib.rs index 88e62389c6..4e0b743086 100644 --- a/crates/typst-pdf/src/lib.rs +++ b/crates/typst-pdf/src/lib.rs @@ -1,81 +1,33 @@ -//! Exporting of Typst documents into PDFs. +//! Exporting Typst documents to PDF. -mod catalog; -mod color; -mod color_font; -mod content; +mod convert; mod embed; -mod extg; -mod font; -mod gradient; mod image; -mod named_destination; +mod link; +mod metadata; mod outline; mod page; -mod resources; -mod tiling; +mod paint; +mod shape; +mod text; +mod util; + +pub use self::metadata::{Timestamp, Timezone}; -use std::collections::{BTreeMap, HashMap}; use std::fmt::{self, Debug, Formatter}; -use std::hash::Hash; -use std::ops::{Deref, DerefMut}; -use base64::Engine; -use ecow::EcoString; -use pdf_writer::{Chunk, Name, Pdf, Ref, Str, TextStr}; +use ecow::eco_format; use serde::{Deserialize, Serialize}; use typst_library::diag::{bail, SourceResult, StrResult}; -use typst_library::foundations::{Datetime, Smart}; -use typst_library::layout::{Abs, Em, PageRanges, PagedDocument, Transform}; -use typst_library::text::Font; -use typst_library::visualize::Image; -use typst_syntax::Span; -use typst_utils::Deferred; - -use crate::catalog::write_catalog; -use crate::color::{alloc_color_functions_refs, ColorFunctionRefs}; -use crate::color_font::{write_color_fonts, ColorFontSlice}; -use crate::embed::write_embedded_files; -use crate::extg::{write_graphic_states, ExtGState}; -use crate::font::write_fonts; -use crate::gradient::{write_gradients, PdfGradient}; -use crate::image::write_images; -use crate::named_destination::{write_named_destinations, NamedDestinations}; -use crate::page::{alloc_page_refs, traverse_pages, write_page_tree, EncodedPage}; -use crate::resources::{ - alloc_resources_refs, write_resource_dictionaries, Resources, ResourcesRefs, -}; -use crate::tiling::{write_tilings, PdfTiling}; +use typst_library::foundations::Smart; +use typst_library::layout::{PageRanges, PagedDocument}; /// Export a document into a PDF file. /// /// Returns the raw bytes making up the PDF file. #[typst_macros::time(name = "pdf")] pub fn pdf(document: &PagedDocument, options: &PdfOptions) -> SourceResult> { - PdfBuilder::new(document, options) - .phase(|builder| builder.run(traverse_pages))? - .phase(|builder| { - Ok(GlobalRefs { - color_functions: builder.run(alloc_color_functions_refs)?, - pages: builder.run(alloc_page_refs)?, - resources: builder.run(alloc_resources_refs)?, - }) - })? - .phase(|builder| { - Ok(References { - named_destinations: builder.run(write_named_destinations)?, - fonts: builder.run(write_fonts)?, - color_fonts: builder.run(write_color_fonts)?, - images: builder.run(write_images)?, - gradients: builder.run(write_gradients)?, - tilings: builder.run(write_tilings)?, - ext_gs: builder.run(write_graphic_states)?, - embedded_files: builder.run(write_embedded_files)?, - }) - })? - .phase(|builder| builder.run(write_page_tree))? - .phase(|builder| builder.run(write_resource_dictionaries))? - .export_with(write_catalog) + convert::convert(document, options) } /// Settings for PDF export. @@ -103,82 +55,74 @@ pub struct PdfOptions<'a> { pub standards: PdfStandards, } -/// A timestamp with timezone information. -#[derive(Debug, Clone, Copy)] -pub struct Timestamp { - /// The datetime of the timestamp. - pub(crate) datetime: Datetime, - /// The timezone of the timestamp. - pub(crate) timezone: Timezone, -} - -impl Timestamp { - /// Create a new timestamp with a given datetime and UTC suffix. - pub fn new_utc(datetime: Datetime) -> Self { - Self { datetime, timezone: Timezone::UTC } - } - - /// Create a new timestamp with a given datetime, and a local timezone offset. - pub fn new_local(datetime: Datetime, whole_minute_offset: i32) -> Option { - let hour_offset = (whole_minute_offset / 60).try_into().ok()?; - // Note: the `%` operator in Rust is the remainder operator, not the - // modulo operator. The remainder operator can return negative results. - // We can simply apply `abs` here because we assume the `minute_offset` - // will have the same sign as `hour_offset`. - let minute_offset = (whole_minute_offset % 60).abs().try_into().ok()?; - match (hour_offset, minute_offset) { - // Only accept valid timezone offsets with `-23 <= hours <= 23`, - // and `0 <= minutes <= 59`. - (-23..=23, 0..=59) => Some(Self { - datetime, - timezone: Timezone::Local { hour_offset, minute_offset }, - }), - _ => None, - } - } -} - -/// A timezone. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Timezone { - /// The UTC timezone. - UTC, - /// The local timezone offset from UTC. And the `minute_offset` will have - /// same sign as `hour_offset`. - Local { hour_offset: i8, minute_offset: u8 }, -} - /// Encapsulates a list of compatible PDF standards. #[derive(Clone)] pub struct PdfStandards { - /// For now, we simplify to just PDF/A. But it can be more fine-grained in - /// the future. - pub(crate) pdfa: bool, - /// Whether the standard allows for embedding any kind of file into the PDF. - /// We disallow this for PDF/A-2, since it only allows embedding - /// PDF/A-1 and PDF/A-2 documents. - pub(crate) embedded_files: bool, - /// Part of the PDF/A standard. - pub(crate) pdfa_part: Option<(i32, &'static str)>, + pub(crate) config: krilla::configure::Configuration, } impl PdfStandards { /// Validates a list of PDF standards for compatibility and returns their /// encapsulated representation. pub fn new(list: &[PdfStandard]) -> StrResult { - let a2b = list.contains(&PdfStandard::A_2b); - let a3b = list.contains(&PdfStandard::A_3b); + use krilla::configure::{Configuration, PdfVersion, Validator}; + + let mut version: Option = None; + let mut set_version = |v: PdfVersion| -> StrResult<()> { + if let Some(prev) = version { + bail!( + "PDF cannot conform to {} and {} at the same time", + prev.as_str(), + v.as_str() + ); + } + version = Some(v); + Ok(()) + }; + + let mut validator = None; + let mut set_validator = |v: Validator| -> StrResult<()> { + if validator.is_some() { + bail!("Typst currently only supports one PDF substandard at a time"); + } + validator = Some(v); + Ok(()) + }; - if a2b && a3b { - bail!("PDF cannot conform to A-2B and A-3B at the same time") + for standard in list { + match standard { + PdfStandard::V_1_4 => set_version(PdfVersion::Pdf14)?, + PdfStandard::V_1_5 => set_version(PdfVersion::Pdf15)?, + PdfStandard::V_1_6 => set_version(PdfVersion::Pdf16)?, + PdfStandard::V_1_7 => set_version(PdfVersion::Pdf17)?, + PdfStandard::V_2_0 => set_version(PdfVersion::Pdf20)?, + PdfStandard::A_1b => set_validator(Validator::A1_B)?, + PdfStandard::A_2b => set_validator(Validator::A2_B)?, + PdfStandard::A_2u => set_validator(Validator::A2_U)?, + PdfStandard::A_3b => set_validator(Validator::A3_B)?, + PdfStandard::A_3u => set_validator(Validator::A3_U)?, + PdfStandard::A_4 => set_validator(Validator::A4)?, + PdfStandard::A_4f => set_validator(Validator::A4F)?, + PdfStandard::A_4e => set_validator(Validator::A4E)?, + } } - let pdfa = a2b || a3b; - Ok(Self { - pdfa, - embedded_files: !a2b, - pdfa_part: pdfa.then_some((if a2b { 2 } else { 3 }, "B")), - }) + let config = match (version, validator) { + (Some(version), Some(validator)) => { + Configuration::new_with(validator, version).ok_or_else(|| { + eco_format!( + "{} is not compatible with {}", + version.as_str(), + validator.as_str() + ) + })? + } + (Some(version), None) => Configuration::new_with_version(version), + (None, Some(validator)) => Configuration::new_with_validator(validator), + (None, None) => Configuration::new_with_version(PdfVersion::Pdf17), + }; + + Ok(Self { config }) } } @@ -190,7 +134,10 @@ impl Debug for PdfStandards { impl Default for PdfStandards { fn default() -> Self { - Self { pdfa: false, embedded_files: true, pdfa_part: None } + use krilla::configure::{Configuration, PdfVersion}; + Self { + config: Configuration::new_with_version(PdfVersion::Pdf17), + } } } @@ -201,531 +148,43 @@ impl Default for PdfStandards { #[allow(non_camel_case_types)] #[non_exhaustive] pub enum PdfStandard { + /// PDF 1.4. + #[serde(rename = "1.4")] + V_1_4, + /// PDF 1.5. + #[serde(rename = "1.5")] + V_1_5, + /// PDF 1.5. + #[serde(rename = "1.6")] + V_1_6, /// PDF 1.7. #[serde(rename = "1.7")] V_1_7, + /// PDF 2.0. + #[serde(rename = "2.0")] + V_2_0, + /// PDF/A-1b. + #[serde(rename = "a-1b")] + A_1b, /// PDF/A-2b. #[serde(rename = "a-2b")] A_2b, - /// PDF/A-3b. + /// PDF/A-2u. + #[serde(rename = "a-2u")] + A_2u, + /// PDF/A-3u. #[serde(rename = "a-3b")] A_3b, -} - -/// A struct to build a PDF following a fixed succession of phases. -/// -/// This type uses generics to represent its current state. `S` (for "state") is -/// all data that was produced by the previous phases, that is now read-only. -/// -/// Phase after phase, this state will be transformed. Each phase corresponds to -/// a call to the [eponymous function](`PdfBuilder::phase`) and produces a new -/// part of the state, that will be aggregated with all other information, for -/// consumption during the next phase. -/// -/// In other words: this struct follows the **typestate pattern**. This prevents -/// you from using data that is not yet available, at the type level. -/// -/// Each phase consists of processes, that can read the state of the previous -/// phases, and construct a part of the new state. -/// -/// A final step, that has direct access to the global reference allocator and -/// PDF document, can be run with [`PdfBuilder::export_with`]. -struct PdfBuilder { - /// The context that has been accumulated so far. - state: S, - /// A global bump allocator. - alloc: Ref, - /// The PDF document that is being written. - pdf: Pdf, -} - -/// The initial state: we are exploring the document, collecting all resources -/// that will be necessary later. The content of the pages is also built during -/// this phase. -struct WithDocument<'a> { - /// The Typst document that is exported. - document: &'a PagedDocument, - /// Settings for PDF export. - options: &'a PdfOptions<'a>, -} - -/// At this point, resources were listed, but they don't have any reference -/// associated with them. -/// -/// This phase allocates some global references. -struct WithResources<'a> { - document: &'a PagedDocument, - options: &'a PdfOptions<'a>, - /// The content of the pages encoded as PDF content streams. - /// - /// The pages are at the index corresponding to their page number, but they - /// may be `None` if they are not in the range specified by - /// `exported_pages`. - pages: Vec>, - /// The PDF resources that are used in the content of the pages. - resources: Resources<()>, -} - -/// Global references. -struct GlobalRefs { - /// References for color conversion functions. - color_functions: ColorFunctionRefs, - /// Reference for pages. - /// - /// Items of this vector are `None` if the corresponding page is not - /// exported. - pages: Vec>, - /// References for the resource dictionaries. - resources: ResourcesRefs, -} - -impl<'a> From<(WithDocument<'a>, (Vec>, Resources<()>))> - for WithResources<'a> -{ - fn from( - (previous, (pages, resources)): ( - WithDocument<'a>, - (Vec>, Resources<()>), - ), - ) -> Self { - Self { - document: previous.document, - options: previous.options, - pages, - resources, - } - } -} - -/// At this point, the resources have been collected, and global references have -/// been allocated. -/// -/// We are now writing objects corresponding to resources, and giving them references, -/// that will be collected in [`References`]. -struct WithGlobalRefs<'a> { - document: &'a PagedDocument, - options: &'a PdfOptions<'a>, - pages: Vec>, - /// Resources are the same as in previous phases, but each dictionary now has a reference. - resources: Resources, - /// Global references that were just allocated. - globals: GlobalRefs, -} - -impl<'a> From<(WithResources<'a>, GlobalRefs)> for WithGlobalRefs<'a> { - fn from((previous, globals): (WithResources<'a>, GlobalRefs)) -> Self { - Self { - document: previous.document, - options: previous.options, - pages: previous.pages, - resources: previous.resources.with_refs(&globals.resources), - globals, - } - } -} - -/// The references that have been assigned to each object. -struct References { - /// List of named destinations, each with an ID. - named_destinations: NamedDestinations, - /// The IDs of written fonts. - fonts: HashMap, - /// The IDs of written color fonts. - color_fonts: HashMap, - /// The IDs of written images. - images: HashMap, - /// The IDs of written gradients. - gradients: HashMap, - /// The IDs of written tilings. - tilings: HashMap, - /// The IDs of written external graphics states. - ext_gs: HashMap, - /// The names and references for embedded files. - embedded_files: BTreeMap, -} - -/// At this point, the references have been assigned to all resources. The page -/// tree is going to be written, and given a reference. It is also at this point that -/// the page contents is actually written. -struct WithRefs<'a> { - document: &'a PagedDocument, - options: &'a PdfOptions<'a>, - globals: GlobalRefs, - pages: Vec>, - resources: Resources, - /// References that were allocated for resources. - references: References, -} - -impl<'a> From<(WithGlobalRefs<'a>, References)> for WithRefs<'a> { - fn from((previous, references): (WithGlobalRefs<'a>, References)) -> Self { - Self { - document: previous.document, - options: previous.options, - globals: previous.globals, - pages: previous.pages, - resources: previous.resources, - references, - } - } -} - -/// In this phase, we write resource dictionaries. -/// -/// Each sub-resource gets its own isolated resource dictionary. -struct WithEverything<'a> { - document: &'a PagedDocument, - options: &'a PdfOptions<'a>, - globals: GlobalRefs, - pages: Vec>, - resources: Resources, - references: References, - /// Reference that was allocated for the page tree. - page_tree_ref: Ref, -} - -impl<'a> From<(WithEverything<'a>, ())> for WithEverything<'a> { - fn from((this, _): (WithEverything<'a>, ())) -> Self { - this - } -} - -impl<'a> From<(WithRefs<'a>, Ref)> for WithEverything<'a> { - fn from((previous, page_tree_ref): (WithRefs<'a>, Ref)) -> Self { - Self { - document: previous.document, - options: previous.options, - globals: previous.globals, - resources: previous.resources, - references: previous.references, - pages: previous.pages, - page_tree_ref, - } - } -} - -impl<'a> PdfBuilder> { - /// Start building a PDF for a Typst document. - fn new(document: &'a PagedDocument, options: &'a PdfOptions<'a>) -> Self { - Self { - alloc: Ref::new(1), - pdf: Pdf::new(), - state: WithDocument { document, options }, - } - } -} - -impl PdfBuilder { - /// Start a new phase, and save its output in the global state. - fn phase(mut self, builder: B) -> SourceResult> - where - // New state - NS: From<(S, O)>, - // Builder - B: Fn(&mut Self) -> SourceResult, - { - let output = builder(&mut self)?; - Ok(PdfBuilder { - state: NS::from((self.state, output)), - alloc: self.alloc, - pdf: self.pdf, - }) - } - - /// Run a step with the current state, merges its output into the PDF file, - /// and renumbers any references it returned. - fn run(&mut self, process: P) -> SourceResult - where - // Process - P: Fn(&S) -> SourceResult<(PdfChunk, O)>, - // Output - O: Renumber, - { - let (chunk, mut output) = process(&self.state)?; - // Allocate a final reference for each temporary one - let allocated = chunk.alloc.get() - TEMPORARY_REFS_START; - let offset = TEMPORARY_REFS_START - self.alloc.get(); - - // Merge the chunk into the PDF, using the new references - chunk.renumber_into(&mut self.pdf, |mut r| { - r.renumber(offset); - - r - }); - - // Also update the references in the output - output.renumber(offset); - - self.alloc = Ref::new(self.alloc.get() + allocated); - - Ok(output) - } - - /// Finalize the PDF export and returns the buffer representing the - /// document. - fn export_with

(mut self, process: P) -> SourceResult> - where - P: Fn(S, &mut Pdf, &mut Ref) -> SourceResult<()>, - { - process(self.state, &mut self.pdf, &mut self.alloc)?; - Ok(self.pdf.finish()) - } -} - -/// A reference or collection of references that can be re-numbered, -/// to become valid in a global scope. -trait Renumber { - /// Renumber this value by shifting any references it contains by `offset`. - fn renumber(&mut self, offset: i32); -} - -impl Renumber for () { - fn renumber(&mut self, _offset: i32) {} -} - -impl Renumber for Ref { - fn renumber(&mut self, offset: i32) { - if self.get() >= TEMPORARY_REFS_START { - *self = Ref::new(self.get() - offset); - } - } -} - -impl Renumber for Vec { - fn renumber(&mut self, offset: i32) { - for item in self { - item.renumber(offset); - } - } -} - -impl Renumber for HashMap { - fn renumber(&mut self, offset: i32) { - for v in self.values_mut() { - v.renumber(offset); - } - } -} - -impl Renumber for BTreeMap { - fn renumber(&mut self, offset: i32) { - for v in self.values_mut() { - v.renumber(offset); - } - } -} - -impl Renumber for Option { - fn renumber(&mut self, offset: i32) { - if let Some(r) = self { - r.renumber(offset) - } - } -} - -impl Renumber for (T, R) { - fn renumber(&mut self, offset: i32) { - self.1.renumber(offset) - } -} - -/// A portion of a PDF file. -struct PdfChunk { - /// The actual chunk. - chunk: Chunk, - /// A local allocator. - alloc: Ref, -} - -/// Any reference below that value was already allocated before and -/// should not be rewritten. Anything above was allocated in the current -/// chunk, and should be remapped. -/// -/// This is a constant (large enough to avoid collisions) and not -/// dependent on self.alloc to allow for better memoization of steps, if -/// needed in the future. -const TEMPORARY_REFS_START: i32 = 1_000_000_000; - -/// A part of a PDF document. -impl PdfChunk { - /// Start writing a new part of the document. - fn new() -> Self { - PdfChunk { - chunk: Chunk::new(), - alloc: Ref::new(TEMPORARY_REFS_START), - } - } - - /// Allocate a reference that is valid in the context of this chunk. - /// - /// References allocated with this function should be [renumbered](`Renumber::renumber`) - /// before being used in other chunks. This is done automatically if these - /// references are stored in the global `PdfBuilder` state. - fn alloc(&mut self) -> Ref { - self.alloc.bump() - } -} - -impl Deref for PdfChunk { - type Target = Chunk; - - fn deref(&self) -> &Self::Target { - &self.chunk - } -} - -impl DerefMut for PdfChunk { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.chunk - } -} - -/// Compress data with the DEFLATE algorithm. -fn deflate(data: &[u8]) -> Vec { - const COMPRESSION_LEVEL: u8 = 6; - miniz_oxide::deflate::compress_to_vec_zlib(data, COMPRESSION_LEVEL) -} - -/// Memoized and deferred version of [`deflate`] specialized for a page's content -/// stream. -#[comemo::memoize] -fn deflate_deferred(content: Vec) -> Deferred> { - Deferred::new(move || deflate(&content)) -} - -/// Create a base64-encoded hash of the value. -fn hash_base64(value: &T) -> String { - base64::engine::general_purpose::STANDARD - .encode(typst_utils::hash128(value).to_be_bytes()) -} - -/// Additional methods for [`Abs`]. -trait AbsExt { - /// Convert an to a number of points. - fn to_f32(self) -> f32; -} - -impl AbsExt for Abs { - fn to_f32(self) -> f32 { - self.to_pt() as f32 - } -} - -/// Additional methods for [`Em`]. -trait EmExt { - /// Convert an em length to a number of PDF font units. - fn to_font_units(self) -> f32; -} - -impl EmExt for Em { - fn to_font_units(self) -> f32 { - 1000.0 * self.get() as f32 - } -} - -trait NameExt<'a> { - /// The maximum length of a name in PDF/A. - const PDFA_LIMIT: usize = 127; -} - -impl<'a> NameExt<'a> for Name<'a> {} - -/// Additional methods for [`Str`]. -trait StrExt<'a>: Sized { - /// The maximum length of a string in PDF/A. - const PDFA_LIMIT: usize = 32767; - - /// Create a string that satisfies the constraints of PDF/A. - #[allow(unused)] - fn trimmed(string: &'a [u8]) -> Self; -} - -impl<'a> StrExt<'a> for Str<'a> { - fn trimmed(string: &'a [u8]) -> Self { - Self(&string[..string.len().min(Self::PDFA_LIMIT)]) - } -} - -/// Additional methods for [`TextStr`]. -trait TextStrExt<'a>: Sized { - /// The maximum length of a string in PDF/A. - const PDFA_LIMIT: usize = Str::PDFA_LIMIT; - - /// Create a text string that satisfies the constraints of PDF/A. - fn trimmed(string: &'a str) -> Self; -} - -impl<'a> TextStrExt<'a> for TextStr<'a> { - fn trimmed(string: &'a str) -> Self { - Self(&string[..string.len().min(Self::PDFA_LIMIT)]) - } -} - -/// Extension trait for [`Content`](pdf_writer::Content). -trait ContentExt { - fn save_state_checked(&mut self) -> SourceResult<()>; -} - -impl ContentExt for pdf_writer::Content { - fn save_state_checked(&mut self) -> SourceResult<()> { - self.save_state(); - if self.state_nesting_depth() > 28 { - bail!( - Span::detached(), - "maximum PDF grouping depth exceeding"; - hint: "try to avoid excessive nesting of layout containers", - ); - } - Ok(()) - } -} - -/// Convert to an array of floats. -fn transform_to_array(ts: Transform) -> [f32; 6] { - [ - ts.sx.get() as f32, - ts.ky.get() as f32, - ts.kx.get() as f32, - ts.sy.get() as f32, - ts.tx.to_f32(), - ts.ty.to_f32(), - ] -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_timestamp_new_local() { - let dummy_datetime = Datetime::from_ymd_hms(2024, 12, 17, 10, 10, 10).unwrap(); - let test = |whole_minute_offset, expect_timezone| { - assert_eq!( - Timestamp::new_local(dummy_datetime, whole_minute_offset) - .unwrap() - .timezone, - expect_timezone - ); - }; - - // Valid timezone offsets - test(0, Timezone::Local { hour_offset: 0, minute_offset: 0 }); - test(480, Timezone::Local { hour_offset: 8, minute_offset: 0 }); - test(-480, Timezone::Local { hour_offset: -8, minute_offset: 0 }); - test(330, Timezone::Local { hour_offset: 5, minute_offset: 30 }); - test(-210, Timezone::Local { hour_offset: -3, minute_offset: 30 }); - test(-720, Timezone::Local { hour_offset: -12, minute_offset: 0 }); // AoE - - // Corner cases - test(315, Timezone::Local { hour_offset: 5, minute_offset: 15 }); - test(-225, Timezone::Local { hour_offset: -3, minute_offset: 45 }); - test(1439, Timezone::Local { hour_offset: 23, minute_offset: 59 }); - test(-1439, Timezone::Local { hour_offset: -23, minute_offset: 59 }); - - // Invalid timezone offsets - assert!(Timestamp::new_local(dummy_datetime, 1440).is_none()); - assert!(Timestamp::new_local(dummy_datetime, -1440).is_none()); - assert!(Timestamp::new_local(dummy_datetime, i32::MAX).is_none()); - assert!(Timestamp::new_local(dummy_datetime, i32::MIN).is_none()); - } + /// PDF/A-3u. + #[serde(rename = "a-3u")] + A_3u, + /// PDF/A-4. + #[serde(rename = "a-4")] + A_4, + /// PDF/A-4f. + #[serde(rename = "a-4f")] + A_4f, + /// PDF/A-4e. + #[serde(rename = "a-4e")] + A_4e, } diff --git a/crates/typst-pdf/src/link.rs b/crates/typst-pdf/src/link.rs new file mode 100644 index 0000000000..64cb8f0a27 --- /dev/null +++ b/crates/typst-pdf/src/link.rs @@ -0,0 +1,94 @@ +use krilla::action::{Action, LinkAction}; +use krilla::annotation::{LinkAnnotation, Target}; +use krilla::destination::XyzDestination; +use krilla::geom::Rect; +use typst_library::layout::{Abs, Point, Size}; +use typst_library::model::Destination; + +use crate::convert::{FrameContext, GlobalContext}; +use crate::util::{AbsExt, PointExt}; + +pub(crate) fn handle_link( + fc: &mut FrameContext, + gc: &mut GlobalContext, + dest: &Destination, + size: Size, +) { + let mut min_x = Abs::inf(); + let mut min_y = Abs::inf(); + let mut max_x = -Abs::inf(); + let mut max_y = -Abs::inf(); + + let pos = Point::zero(); + + // Compute the bounding box of the transformed link. + for point in [ + pos, + pos + Point::with_x(size.x), + pos + Point::with_y(size.y), + pos + size.to_point(), + ] { + let t = point.transform(fc.state().transform()); + min_x.set_min(t.x); + min_y.set_min(t.y); + max_x.set_max(t.x); + max_y.set_max(t.y); + } + + let x1 = min_x.to_f32(); + let x2 = max_x.to_f32(); + let y1 = min_y.to_f32(); + let y2 = max_y.to_f32(); + + let rect = Rect::from_ltrb(x1, y1, x2, y2).unwrap(); + + // TODO: Support quad points. + + let pos = match dest { + Destination::Url(u) => { + fc.push_annotation( + LinkAnnotation::new( + rect, + None, + Target::Action(Action::Link(LinkAction::new(u.to_string()))), + ) + .into(), + ); + return; + } + Destination::Position(p) => *p, + Destination::Location(loc) => { + if let Some(nd) = gc.loc_to_names.get(loc) { + // If a named destination has been registered, it's already guaranteed to + // not point to an excluded page. + fc.push_annotation( + LinkAnnotation::new( + rect, + None, + Target::Destination(krilla::destination::Destination::Named( + nd.clone(), + )), + ) + .into(), + ); + return; + } else { + gc.document.introspector.position(*loc) + } + } + }; + + let page_index = pos.page.get() - 1; + if let Some(index) = gc.page_index_converter.pdf_page_index(page_index) { + fc.push_annotation( + LinkAnnotation::new( + rect, + None, + Target::Destination(krilla::destination::Destination::Xyz( + XyzDestination::new(index, pos.point.to_krilla()), + )), + ) + .into(), + ); + } +} diff --git a/crates/typst-pdf/src/metadata.rs b/crates/typst-pdf/src/metadata.rs new file mode 100644 index 0000000000..589c5e2fb8 --- /dev/null +++ b/crates/typst-pdf/src/metadata.rs @@ -0,0 +1,184 @@ +use ecow::EcoString; +use krilla::metadata::{Metadata, TextDirection}; +use typst_library::foundations::{Datetime, Smart}; +use typst_library::layout::Dir; +use typst_library::text::Lang; + +use crate::convert::GlobalContext; + +pub(crate) fn build_metadata(gc: &GlobalContext) -> Metadata { + let creator = format!("Typst {}", env!("CARGO_PKG_VERSION")); + + let lang = gc.languages.iter().max_by_key(|(_, &count)| count).map(|(&l, _)| l); + + let dir = if lang.map(Lang::dir) == Some(Dir::RTL) { + TextDirection::RightToLeft + } else { + TextDirection::LeftToRight + }; + + let mut metadata = Metadata::new() + .creator(creator) + .keywords(gc.document.info.keywords.iter().map(EcoString::to_string).collect()) + .authors(gc.document.info.author.iter().map(EcoString::to_string).collect()); + + let lang = gc.languages.iter().max_by_key(|(_, &count)| count).map(|(&l, _)| l); + + if let Some(lang) = lang { + metadata = metadata.language(lang.as_str().to_string()); + } + + if let Some(title) = &gc.document.info.title { + metadata = metadata.title(title.to_string()); + } + + if let Some(subject) = &gc.document.info.description { + metadata = metadata.subject(subject.to_string()); + } + + if let Some(ident) = gc.options.ident.custom() { + metadata = metadata.document_id(ident.to_string()); + } + + // (1) If the `document.date` is set to specific `datetime` or `none`, use it. + // (2) If the `document.date` is set to `auto` or not set, try to use the + // date from the options. + // (3) Otherwise, we don't write date metadata. + let (date, tz) = match (gc.document.info.date, gc.options.timestamp) { + (Smart::Custom(date), _) => (date, None), + (Smart::Auto, Some(timestamp)) => { + (Some(timestamp.datetime), Some(timestamp.timezone)) + } + _ => (None, None), + }; + + if let Some(date) = date.and_then(|d| convert_date(d, tz)) { + metadata = metadata.creation_date(date); + } + + metadata = metadata.text_direction(dir); + + metadata +} + +fn convert_date( + datetime: Datetime, + tz: Option, +) -> Option { + let year = datetime.year().filter(|&y| y >= 0)? as u16; + + let mut kd = krilla::metadata::DateTime::new(year); + + if let Some(month) = datetime.month() { + kd = kd.month(month); + } + + if let Some(day) = datetime.day() { + kd = kd.day(day); + } + + if let Some(h) = datetime.hour() { + kd = kd.hour(h); + } + + if let Some(m) = datetime.minute() { + kd = kd.minute(m); + } + + if let Some(s) = datetime.second() { + kd = kd.second(s); + } + + match tz { + Some(Timezone::UTC) => kd = kd.utc_offset_hour(0).utc_offset_minute(0), + Some(Timezone::Local { hour_offset, minute_offset }) => { + kd = kd.utc_offset_hour(hour_offset).utc_offset_minute(minute_offset) + } + None => {} + } + + Some(kd) +} + +/// A timestamp with timezone information. +#[derive(Debug, Clone, Copy)] +pub struct Timestamp { + /// The datetime of the timestamp. + pub(crate) datetime: Datetime, + /// The timezone of the timestamp. + pub(crate) timezone: Timezone, +} + +impl Timestamp { + /// Create a new timestamp with a given datetime and UTC suffix. + pub fn new_utc(datetime: Datetime) -> Self { + Self { datetime, timezone: Timezone::UTC } + } + + /// Create a new timestamp with a given datetime, and a local timezone offset. + pub fn new_local(datetime: Datetime, whole_minute_offset: i32) -> Option { + let hour_offset = (whole_minute_offset / 60).try_into().ok()?; + // Note: the `%` operator in Rust is the remainder operator, not the + // modulo operator. The remainder operator can return negative results. + // We can simply apply `abs` here because we assume the `minute_offset` + // will have the same sign as `hour_offset`. + let minute_offset = (whole_minute_offset % 60).abs().try_into().ok()?; + match (hour_offset, minute_offset) { + // Only accept valid timezone offsets with `-23 <= hours <= 23`, + // and `0 <= minutes <= 59`. + (-23..=23, 0..=59) => Some(Self { + datetime, + timezone: Timezone::Local { hour_offset, minute_offset }, + }), + _ => None, + } + } +} + +/// A timezone. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Timezone { + /// The UTC timezone. + UTC, + /// The local timezone offset from UTC. And the `minute_offset` will have + /// same sign as `hour_offset`. + Local { hour_offset: i8, minute_offset: u8 }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_timestamp_new_local() { + let dummy_datetime = Datetime::from_ymd_hms(2024, 12, 17, 10, 10, 10).unwrap(); + let test = |whole_minute_offset, expect_timezone| { + assert_eq!( + Timestamp::new_local(dummy_datetime, whole_minute_offset) + .unwrap() + .timezone, + expect_timezone + ); + }; + + // Valid timezone offsets + test(0, Timezone::Local { hour_offset: 0, minute_offset: 0 }); + test(480, Timezone::Local { hour_offset: 8, minute_offset: 0 }); + test(-480, Timezone::Local { hour_offset: -8, minute_offset: 0 }); + test(330, Timezone::Local { hour_offset: 5, minute_offset: 30 }); + test(-210, Timezone::Local { hour_offset: -3, minute_offset: 30 }); + test(-720, Timezone::Local { hour_offset: -12, minute_offset: 0 }); // AoE + + // Corner cases + test(315, Timezone::Local { hour_offset: 5, minute_offset: 15 }); + test(-225, Timezone::Local { hour_offset: -3, minute_offset: 45 }); + test(1439, Timezone::Local { hour_offset: 23, minute_offset: 59 }); + test(-1439, Timezone::Local { hour_offset: -23, minute_offset: 59 }); + + // Invalid timezone offsets + assert!(Timestamp::new_local(dummy_datetime, 1440).is_none()); + assert!(Timestamp::new_local(dummy_datetime, -1440).is_none()); + assert!(Timestamp::new_local(dummy_datetime, i32::MAX).is_none()); + assert!(Timestamp::new_local(dummy_datetime, i32::MIN).is_none()); + } +} diff --git a/crates/typst-pdf/src/named_destination.rs b/crates/typst-pdf/src/named_destination.rs deleted file mode 100644 index 7ae2c5e6fc..0000000000 --- a/crates/typst-pdf/src/named_destination.rs +++ /dev/null @@ -1,86 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use pdf_writer::writers::Destination; -use pdf_writer::{Ref, Str}; -use typst_library::diag::SourceResult; -use typst_library::foundations::{Label, NativeElement}; -use typst_library::introspection::Location; -use typst_library::layout::Abs; -use typst_library::model::HeadingElem; - -use crate::{AbsExt, PdfChunk, Renumber, StrExt, WithGlobalRefs}; - -/// A list of destinations in the PDF document (a specific point on a specific -/// page), that have a name associated with them. -/// -/// Typst creates a named destination for each heading in the document, that -/// will then be written in the document catalog. PDF readers can then display -/// them to show a clickable outline of the document. -#[derive(Default)] -pub struct NamedDestinations { - /// A map between elements and their associated labels - pub loc_to_dest: HashMap, - /// A sorted list of all named destinations. - pub dests: Vec<(Label, Ref)>, -} - -impl Renumber for NamedDestinations { - fn renumber(&mut self, offset: i32) { - for (_, reference) in &mut self.dests { - reference.renumber(offset); - } - } -} - -/// Fills in the map and vector for named destinations and writes the indirect -/// destination objects. -pub fn write_named_destinations( - context: &WithGlobalRefs, -) -> SourceResult<(PdfChunk, NamedDestinations)> { - let mut chunk = PdfChunk::new(); - let mut out = NamedDestinations::default(); - let mut seen = HashSet::new(); - - // Find all headings that have a label and are the first among other - // headings with the same label. - let mut matches: Vec<_> = context - .document - .introspector - .query(&HeadingElem::elem().select()) - .iter() - .filter_map(|elem| elem.location().zip(elem.label())) - .filter(|&(_, label)| seen.insert(label)) - .collect(); - - // Named destinations must be sorted by key. - matches.sort_by_key(|&(_, label)| label.resolve()); - - for (loc, label) in matches { - // Don't encode named destinations that would exceed the limit. Those - // will instead be encoded as normal links. - if label.resolve().len() > Str::PDFA_LIMIT { - continue; - } - - let pos = context.document.introspector.position(loc); - let index = pos.page.get() - 1; - let y = (pos.point.y - Abs::pt(10.0)).max(Abs::zero()); - - if let Some((Some(page), Some(page_ref))) = - context.pages.get(index).zip(context.globals.pages.get(index)) - { - let dest_ref = chunk.alloc(); - let x = pos.point.x.to_f32(); - let y = (page.content.size.y - y).to_f32(); - out.dests.push((label, dest_ref)); - out.loc_to_dest.insert(loc, label); - chunk - .indirect(dest_ref) - .start::() - .page(*page_ref) - .xyz(x, y, None); - } - } - - Ok((chunk, out)) -} diff --git a/crates/typst-pdf/src/outline.rs b/crates/typst-pdf/src/outline.rs index eff1182c1a..e6324309ff 100644 --- a/crates/typst-pdf/src/outline.rs +++ b/crates/typst-pdf/src/outline.rs @@ -1,18 +1,15 @@ use std::num::NonZeroUsize; -use pdf_writer::{Finish, Pdf, Ref, TextStr}; +use krilla::destination::XyzDestination; +use krilla::outline::{Outline, OutlineNode}; use typst_library::foundations::{NativeElement, Packed, StyleChain}; use typst_library::layout::Abs; use typst_library::model::HeadingElem; -use crate::{AbsExt, TextStrExt, WithEverything}; +use crate::convert::GlobalContext; +use crate::util::AbsExt; -/// Construct the outline for the document. -pub(crate) fn write_outline( - chunk: &mut Pdf, - alloc: &mut Ref, - ctx: &WithEverything, -) -> Option { +pub(crate) fn build_outline(gc: &GlobalContext) -> Outline { let mut tree: Vec = vec![]; // Stores the level of the topmost skipped ancestor of the next bookmarked @@ -21,14 +18,14 @@ pub(crate) fn write_outline( // Therefore, its next descendant must be added at its level, which is // enforced in the manner shown below. let mut last_skipped_level = None; - let elements = ctx.document.introspector.query(&HeadingElem::elem().select()); + let elements = &gc.document.introspector.query(&HeadingElem::elem().select()); for elem in elements.iter() { - if let Some(page_ranges) = &ctx.options.page_ranges { + if let Some(page_ranges) = &gc.options.page_ranges { if !page_ranges - .includes_page(ctx.document.introspector.page(elem.location().unwrap())) + .includes_page(gc.document.introspector.page(elem.location().unwrap())) { - // Don't bookmark headings in non-exported pages + // Don't bookmark headings in non-exported pages. continue; } } @@ -95,39 +92,15 @@ pub(crate) fn write_outline( } } - if tree.is_empty() { - return None; - } + let mut outline = Outline::new(); - let root_id = alloc.bump(); - let start_ref = *alloc; - let len = tree.len(); - - let mut prev_ref = None; - for (i, node) in tree.iter().enumerate() { - prev_ref = Some(write_outline_item( - ctx, - chunk, - alloc, - node, - root_id, - prev_ref, - i + 1 == len, - )); + for child in convert_nodes(&tree, gc) { + outline.push_child(child); } - chunk - .outline(root_id) - .first(start_ref) - .last(Ref::new( - alloc.get() - tree.last().map(|child| child.len() as i32).unwrap_or(1), - )) - .count(tree.len() as i32); - - Some(root_id) + outline } -/// A heading in the outline panel. #[derive(Debug)] struct HeadingNode<'a> { element: &'a Packed, @@ -149,73 +122,31 @@ impl<'a> HeadingNode<'a> { } } - fn len(&self) -> usize { - 1 + self.children.iter().map(Self::len).sum::() - } -} - -/// Write an outline item and all its children. -fn write_outline_item( - ctx: &WithEverything, - chunk: &mut Pdf, - alloc: &mut Ref, - node: &HeadingNode, - parent_ref: Ref, - prev_ref: Option, - is_last: bool, -) -> Ref { - let id = alloc.bump(); - let next_ref = Ref::new(id.get() + node.len() as i32); - - let mut outline = chunk.outline_item(id); - outline.parent(parent_ref); - - if !is_last { - outline.next(next_ref); - } - - if let Some(prev_rev) = prev_ref { - outline.prev(prev_rev); - } - - if let Some(last_immediate_child) = node.children.last() { - outline.first(Ref::new(id.get() + 1)); - outline.last(Ref::new(next_ref.get() - last_immediate_child.len() as i32)); - outline.count(-(node.children.len() as i32)); - } + fn to_krilla(&self, gc: &GlobalContext) -> Option { + let loc = self.element.location().unwrap(); + let title = self.element.body.plain_text().to_string(); + let pos = gc.document.introspector.position(loc); + let page_index = pos.page.get() - 1; + + if let Some(index) = gc.page_index_converter.pdf_page_index(page_index) { + let y = (pos.point.y - Abs::pt(10.0)).max(Abs::zero()); + let dest = XyzDestination::new( + index, + krilla::geom::Point::from_xy(pos.point.x.to_f32(), y.to_f32()), + ); + + let mut outline_node = OutlineNode::new(title, dest); + for child in convert_nodes(&self.children, gc) { + outline_node.push_child(child); + } - outline.title(TextStr::trimmed(node.element.body.plain_text().trim())); - - let loc = node.element.location().unwrap(); - let pos = ctx.document.introspector.position(loc); - let index = pos.page.get() - 1; - - // Don't link to non-exported pages. - if let Some((Some(page), Some(page_ref))) = - ctx.pages.get(index).zip(ctx.globals.pages.get(index)) - { - let y = (pos.point.y - Abs::pt(10.0)).max(Abs::zero()); - outline.dest().page(*page_ref).xyz( - pos.point.x.to_f32(), - (page.content.size.y - y).to_f32(), - None, - ); - } + return Some(outline_node); + } - outline.finish(); - - let mut prev_ref = None; - for (i, child) in node.children.iter().enumerate() { - prev_ref = Some(write_outline_item( - ctx, - chunk, - alloc, - child, - id, - prev_ref, - i + 1 == node.children.len(), - )); + None } +} - id +fn convert_nodes(nodes: &[HeadingNode], gc: &GlobalContext) -> Vec { + nodes.iter().flat_map(|node| node.to_krilla(gc)).collect() } diff --git a/crates/typst-pdf/src/page.rs b/crates/typst-pdf/src/page.rs index 68125d29ab..aa34400ef8 100644 --- a/crates/typst-pdf/src/page.rs +++ b/crates/typst-pdf/src/page.rs @@ -1,310 +1,64 @@ -use std::collections::HashMap; -use std::num::NonZeroU64; +use std::num::NonZeroUsize; -use ecow::EcoString; -use pdf_writer::types::{ActionType, AnnotationFlags, AnnotationType, NumberingStyle}; -use pdf_writer::{Filter, Finish, Name, Rect, Ref, Str}; -use typst_library::diag::SourceResult; -use typst_library::foundations::Label; -use typst_library::introspection::Location; -use typst_library::layout::{Abs, Page}; -use typst_library::model::{Destination, Numbering}; +use krilla::page::{NumberingStyle, PageLabel}; +use typst_library::model::Numbering; -use crate::{ - content, AbsExt, PdfChunk, PdfOptions, Resources, WithDocument, WithRefs, - WithResources, -}; - -/// Construct page objects. -#[typst_macros::time(name = "construct pages")] -#[allow(clippy::type_complexity)] -pub fn traverse_pages( - state: &WithDocument, -) -> SourceResult<(PdfChunk, (Vec>, Resources<()>))> { - let mut resources = Resources::default(); - let mut pages = Vec::with_capacity(state.document.pages.len()); - let mut skipped_pages = 0; - for (i, page) in state.document.pages.iter().enumerate() { - if state - .options - .page_ranges - .as_ref() - .is_some_and(|ranges| !ranges.includes_page_index(i)) - { - // Don't export this page. - pages.push(None); - skipped_pages += 1; - } else { - let mut encoded = construct_page(state.options, &mut resources, page)?; - encoded.label = page - .numbering - .as_ref() - .and_then(|num| PdfPageLabel::generate(num, page.number)) - .or_else(|| { - // When some pages were ignored from export, we show a page label with - // the correct real (not logical) page number. - // This is for consistency with normal output when pages have no numbering - // and all are exported: the final PDF page numbers always correspond to - // the real (not logical) page numbers. Here, the final PDF page number - // will differ, but we can at least use labels to indicate what was - // the corresponding real page number in the Typst document. - (skipped_pages > 0).then(|| PdfPageLabel::arabic((i + 1) as u64)) - }); - pages.push(Some(encoded)); - } - } - - Ok((PdfChunk::new(), (pages, resources))) -} - -/// Construct a page object. -#[typst_macros::time(name = "construct page")] -fn construct_page( - options: &PdfOptions, - out: &mut Resources<()>, - page: &Page, -) -> SourceResult { - Ok(EncodedPage { - content: content::build( - options, - out, - &page.frame, - page.fill_or_transparent(), - None, - )?, - label: None, - }) -} - -/// Allocate a reference for each exported page. -pub fn alloc_page_refs( - context: &WithResources, -) -> SourceResult<(PdfChunk, Vec>)> { - let mut chunk = PdfChunk::new(); - let page_refs = context - .pages - .iter() - .map(|p| p.as_ref().map(|_| chunk.alloc())) - .collect(); - Ok((chunk, page_refs)) -} - -/// Write the page tree. -pub fn write_page_tree(ctx: &WithRefs) -> SourceResult<(PdfChunk, Ref)> { - let mut chunk = PdfChunk::new(); - let page_tree_ref = chunk.alloc.bump(); - - for i in 0..ctx.pages.len() { - let content_id = chunk.alloc.bump(); - write_page( - &mut chunk, - ctx, - content_id, - page_tree_ref, - &ctx.references.named_destinations.loc_to_dest, - i, - ); - } - - let page_kids = ctx.globals.pages.iter().filter_map(Option::as_ref).copied(); - - chunk - .pages(page_tree_ref) - .count(page_kids.clone().count() as i32) - .kids(page_kids); - - Ok((chunk, page_tree_ref)) -} - -/// Write a page tree node. -fn write_page( - chunk: &mut PdfChunk, - ctx: &WithRefs, - content_id: Ref, - page_tree_ref: Ref, - loc_to_dest: &HashMap, - i: usize, -) { - let Some((page, page_ref)) = ctx.pages[i].as_ref().zip(ctx.globals.pages[i]) else { - // Page excluded from export. - return; - }; - - let mut annotations = Vec::with_capacity(page.content.links.len()); - for (dest, rect) in &page.content.links { - let id = chunk.alloc(); - annotations.push(id); - - let mut annotation = chunk.annotation(id); - annotation.subtype(AnnotationType::Link).rect(*rect); - annotation.border(0.0, 0.0, 0.0, None).flags(AnnotationFlags::PRINT); - - let pos = match dest { - Destination::Url(uri) => { - annotation - .action() - .action_type(ActionType::Uri) - .uri(Str(uri.as_bytes())); - continue; - } - Destination::Position(pos) => *pos, - Destination::Location(loc) => { - if let Some(key) = loc_to_dest.get(loc) { - annotation - .action() - .action_type(ActionType::GoTo) - // `key` must be a `Str`, not a `Name`. - .pair(Name(b"D"), Str(key.resolve().as_bytes())); - continue; - } else { - ctx.document.introspector.position(*loc) - } - } - }; - - let index = pos.page.get() - 1; - let y = (pos.point.y - Abs::pt(10.0)).max(Abs::zero()); - - // Don't add links to non-exported pages. - if let Some((Some(page), Some(page_ref))) = - ctx.pages.get(index).zip(ctx.globals.pages.get(index)) - { - annotation - .action() - .action_type(ActionType::GoTo) - .destination() - .page(*page_ref) - .xyz(pos.point.x.to_f32(), (page.content.size.y - y).to_f32(), None); - } - } - - let mut page_writer = chunk.page(page_ref); - page_writer.parent(page_tree_ref); - - let w = page.content.size.x.to_f32(); - let h = page.content.size.y.to_f32(); - page_writer.media_box(Rect::new(0.0, 0.0, w, h)); - page_writer.contents(content_id); - page_writer.pair(Name(b"Resources"), ctx.resources.reference); - - if page.content.uses_opacities { - page_writer - .group() - .transparency() - .isolated(false) - .knockout(false) - .color_space() - .srgb(); - } - - page_writer.annotations(annotations); - - page_writer.finish(); - - chunk - .stream(content_id, page.content.content.wait()) - .filter(Filter::FlateDecode); -} - -/// Specification for a PDF page label. -#[derive(Debug, Clone, PartialEq, Hash, Default)] -pub(crate) struct PdfPageLabel { - /// Can be any string or none. Will always be prepended to the numbering style. - pub prefix: Option, - /// Based on the numbering pattern. - /// - /// If `None` or numbering is a function, the field will be empty. - pub style: Option, - /// Offset for the page label start. - /// - /// Describes where to start counting from when setting a style. - /// (Has to be greater or equal than 1) - pub offset: Option, -} - -/// A PDF page label number style. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] -pub enum PdfPageLabelStyle { - /// Decimal arabic numerals (1, 2, 3). - Arabic, - /// Lowercase roman numerals (i, ii, iii). - LowerRoman, - /// Uppercase roman numerals (I, II, III). - UpperRoman, - /// Lowercase letters (`a` to `z` for the first 26 pages, - /// `aa` to `zz` and so on for the next). - LowerAlpha, - /// Uppercase letters (`A` to `Z` for the first 26 pages, - /// `AA` to `ZZ` and so on for the next). - UpperAlpha, -} - -impl PdfPageLabel { - /// Create a new `PdfNumbering` from a `Numbering` applied to a page +pub(crate) trait PageLabelExt { + /// Create a new `PageLabel` from a `Numbering` applied to a page /// number. - fn generate(numbering: &Numbering, number: u64) -> Option { - let Numbering::Pattern(pat) = numbering else { - return None; - }; - - let (prefix, kind) = pat.pieces.first()?; - - // If there is a suffix, we cannot use the common style optimisation, - // since PDF does not provide a suffix field. - let style = if pat.suffix.is_empty() { - use typst_library::model::NumberingKind as Kind; - use PdfPageLabelStyle as Style; - match kind { - Kind::Arabic => Some(Style::Arabic), - Kind::LowerRoman => Some(Style::LowerRoman), - Kind::UpperRoman => Some(Style::UpperRoman), - Kind::LowerLatin if number <= 26 => Some(Style::LowerAlpha), - Kind::LowerLatin if number <= 26 => Some(Style::UpperAlpha), - _ => None, - } - } else { - None - }; - - // Prefix and offset depend on the style: If it is supported by the PDF - // spec, we use the given prefix and an offset. Otherwise, everything - // goes into prefix. - let prefix = if style.is_none() { - Some(pat.apply(&[number])) - } else { - (!prefix.is_empty()).then(|| prefix.clone()) - }; - - let offset = style.and(NonZeroU64::new(number)); - Some(PdfPageLabel { prefix, style, offset }) - } + fn generate(numbering: &Numbering, number: u64) -> Option; /// Creates an arabic page label with the specified page number. /// For example, this will display page label `11` when given the page /// number 11. - fn arabic(number: u64) -> PdfPageLabel { - PdfPageLabel { - prefix: None, - style: Some(PdfPageLabelStyle::Arabic), - offset: NonZeroU64::new(number), - } - } + fn arabic(number: u64) -> PageLabel; } -impl PdfPageLabelStyle { - pub fn to_pdf_numbering_style(self) -> NumberingStyle { - match self { - PdfPageLabelStyle::Arabic => NumberingStyle::Arabic, - PdfPageLabelStyle::LowerRoman => NumberingStyle::LowerRoman, - PdfPageLabelStyle::UpperRoman => NumberingStyle::UpperRoman, - PdfPageLabelStyle::LowerAlpha => NumberingStyle::LowerAlpha, - PdfPageLabelStyle::UpperAlpha => NumberingStyle::UpperAlpha, +impl PageLabelExt for PageLabel { + fn generate(numbering: &Numbering, number: u64) -> Option { + { + let Numbering::Pattern(pat) = numbering else { + return None; + }; + + let (prefix, kind) = pat.pieces.first()?; + + // If there is a suffix, we cannot use the common style optimisation, + // since PDF does not provide a suffix field. + let style = if pat.suffix.is_empty() { + use krilla::page::NumberingStyle as Style; + use typst_library::model::NumberingKind as Kind; + match kind { + Kind::Arabic => Some(Style::Arabic), + Kind::LowerRoman => Some(Style::LowerRoman), + Kind::UpperRoman => Some(Style::UpperRoman), + Kind::LowerLatin if number <= 26 => Some(Style::LowerAlpha), + Kind::LowerLatin if number <= 26 => Some(Style::UpperAlpha), + _ => None, + } + } else { + None + }; + + // Prefix and offset depend on the style: If it is supported by the PDF + // spec, we use the given prefix and an offset. Otherwise, everything + // goes into prefix. + let prefix = if style.is_none() { + Some(pat.apply(&[number])) + } else { + (!prefix.is_empty()).then(|| prefix.clone()) + }; + + let offset = style.and(number.try_into().ok().and_then(NonZeroUsize::new)); + Some(PageLabel::new(style, prefix.map(|s| s.to_string()), offset)) } } -} -/// Data for an exported page. -pub struct EncodedPage { - pub content: content::Encoded, - pub label: Option, + fn arabic(number: u64) -> PageLabel { + PageLabel::new( + Some(NumberingStyle::Arabic), + None, + number.try_into().ok().and_then(NonZeroUsize::new), + ) + } } diff --git a/crates/typst-pdf/src/paint.rs b/crates/typst-pdf/src/paint.rs new file mode 100644 index 0000000000..5224464abe --- /dev/null +++ b/crates/typst-pdf/src/paint.rs @@ -0,0 +1,379 @@ +//! Convert paint types from typst to krilla. + +use krilla::color::{self, cmyk, luma, rgb}; +use krilla::num::NormalizedF32; +use krilla::paint::{ + Fill, LinearGradient, Pattern, RadialGradient, SpreadMethod, Stop, Stroke, + StrokeDash, SweepGradient, +}; +use krilla::surface::Surface; +use typst_library::diag::SourceResult; +use typst_library::layout::{Abs, Angle, Quadrant, Ratio, Size, Transform}; +use typst_library::visualize::{ + Color, ColorSpace, DashPattern, FillRule, FixedStroke, Gradient, Paint, RatioOrAngle, + RelativeTo, Tiling, WeightedColor, +}; +use typst_utils::Numeric; + +use crate::convert::{handle_frame, FrameContext, GlobalContext, State}; +use crate::util::{AbsExt, FillRuleExt, LineCapExt, LineJoinExt, TransformExt}; + +pub(crate) fn convert_fill( + gc: &mut GlobalContext, + paint_: &Paint, + fill_rule_: FillRule, + on_text: bool, + surface: &mut Surface, + state: &State, + size: Size, +) -> SourceResult { + let (paint, opacity) = convert_paint(gc, paint_, on_text, surface, state, size)?; + + Ok(Fill { + paint, + rule: fill_rule_.to_krilla(), + opacity: NormalizedF32::new(opacity as f32 / 255.0).unwrap(), + }) +} + +pub(crate) fn convert_stroke( + fc: &mut GlobalContext, + stroke: &FixedStroke, + on_text: bool, + surface: &mut Surface, + state: &State, + size: Size, +) -> SourceResult { + let (paint, opacity) = + convert_paint(fc, &stroke.paint, on_text, surface, state, size)?; + + Ok(Stroke { + paint, + width: stroke.thickness.to_f32(), + miter_limit: stroke.miter_limit.get() as f32, + line_join: stroke.join.to_krilla(), + line_cap: stroke.cap.to_krilla(), + opacity: NormalizedF32::new(opacity as f32 / 255.0).unwrap(), + dash: stroke.dash.as_ref().map(convert_dash), + }) +} + +fn convert_paint( + gc: &mut GlobalContext, + paint: &Paint, + on_text: bool, + surface: &mut Surface, + state: &State, + mut size: Size, +) -> SourceResult<(krilla::paint::Paint, u8)> { + // Edge cases for strokes. + if size.x.is_zero() { + size.x = Abs::pt(1.0); + } + + if size.y.is_zero() { + size.y = Abs::pt(1.0); + } + + match paint { + Paint::Solid(c) => { + let (c, a) = convert_solid(c); + Ok((c.into(), a)) + } + Paint::Gradient(g) => Ok(convert_gradient(g, on_text, state, size)), + Paint::Tiling(p) => convert_pattern(gc, p, on_text, surface, state), + } +} + +fn convert_solid(color: &Color) -> (color::Color, u8) { + match color.space() { + ColorSpace::D65Gray => { + let (c, a) = convert_luma(color); + (c.into(), a) + } + ColorSpace::Cmyk => (convert_cmyk(color).into(), 255), + // Convert all other colors in different colors spaces into RGB. + _ => { + let (c, a) = convert_rgb(color); + (c.into(), a) + } + } +} + +fn convert_cmyk(color: &Color) -> cmyk::Color { + let components = color.to_space(ColorSpace::Cmyk).to_vec4_u8(); + + cmyk::Color::new(components[0], components[1], components[2], components[3]) +} + +fn convert_rgb(color: &Color) -> (rgb::Color, u8) { + let components = color.to_space(ColorSpace::Srgb).to_vec4_u8(); + (rgb::Color::new(components[0], components[1], components[2]), components[3]) +} + +fn convert_luma(color: &Color) -> (luma::Color, u8) { + let components = color.to_space(ColorSpace::D65Gray).to_vec4_u8(); + (luma::Color::new(components[0]), components[3]) +} + +fn convert_pattern( + gc: &mut GlobalContext, + pattern: &Tiling, + on_text: bool, + surface: &mut Surface, + state: &State, +) -> SourceResult<(krilla::paint::Paint, u8)> { + let transform = correct_transform(state, pattern.unwrap_relative(on_text)); + + let mut stream_builder = surface.stream_builder(); + let mut surface = stream_builder.surface(); + let mut fc = FrameContext::new(pattern.frame().size()); + handle_frame(&mut fc, pattern.frame(), None, &mut surface, gc)?; + surface.finish(); + let stream = stream_builder.finish(); + let pattern = Pattern { + stream, + transform: transform.to_krilla(), + width: (pattern.size().x + pattern.spacing().x).to_pt() as _, + height: (pattern.size().y + pattern.spacing().y).to_pt() as _, + }; + + Ok((pattern.into(), 255)) +} + +fn convert_gradient( + gradient: &Gradient, + on_text: bool, + state: &State, + size: Size, +) -> (krilla::paint::Paint, u8) { + let size = match gradient.unwrap_relative(on_text) { + RelativeTo::Self_ => size, + RelativeTo::Parent => state.container_size(), + }; + + let angle = gradient.angle().unwrap_or_else(Angle::zero); + let base_transform = correct_transform(state, gradient.unwrap_relative(on_text)); + let stops = convert_gradient_stops(gradient); + match &gradient { + Gradient::Linear(_) => { + let (x1, y1, x2, y2) = { + let (mut sin, mut cos) = (angle.sin(), angle.cos()); + + // Scale to edges of unit square. + let factor = cos.abs() + sin.abs(); + sin *= factor; + cos *= factor; + + match angle.quadrant() { + Quadrant::First => (0.0, 0.0, cos as f32, sin as f32), + Quadrant::Second => (1.0, 0.0, cos as f32 + 1.0, sin as f32), + Quadrant::Third => (1.0, 1.0, cos as f32 + 1.0, sin as f32 + 1.0), + Quadrant::Fourth => (0.0, 1.0, cos as f32, sin as f32 + 1.0), + } + }; + + let linear = LinearGradient { + x1, + y1, + x2, + y2, + // x and y coordinates are normalized, so need to scale by the size. + transform: base_transform + .pre_concat(Transform::scale( + Ratio::new(size.x.to_f32() as f64), + Ratio::new(size.y.to_f32() as f64), + )) + .to_krilla(), + spread_method: SpreadMethod::Pad, + stops, + anti_alias: gradient.anti_alias(), + }; + + (linear.into(), 255) + } + Gradient::Radial(radial) => { + let radial = RadialGradient { + fx: radial.focal_center.x.get() as f32, + fy: radial.focal_center.y.get() as f32, + fr: radial.focal_radius.get() as f32, + cx: radial.center.x.get() as f32, + cy: radial.center.y.get() as f32, + cr: radial.radius.get() as f32, + transform: base_transform + .pre_concat(Transform::scale( + Ratio::new(size.x.to_f32() as f64), + Ratio::new(size.y.to_f32() as f64), + )) + .to_krilla(), + spread_method: SpreadMethod::Pad, + stops, + anti_alias: gradient.anti_alias(), + }; + + (radial.into(), 255) + } + Gradient::Conic(conic) => { + // Correct the gradient's angle. + let cx = size.x.to_f32() * conic.center.x.get() as f32; + let cy = size.y.to_f32() * conic.center.y.get() as f32; + let actual_transform = base_transform + // Adjust for the angle. + .pre_concat(Transform::rotate_at( + angle, + Abs::pt(cx as f64), + Abs::pt(cy as f64), + )) + // Default start point in krilla and typst are at the opposite side, so we need + // to flip it horizontally. + .pre_concat(Transform::scale_at( + -Ratio::one(), + Ratio::one(), + Abs::pt(cx as f64), + Abs::pt(cy as f64), + )); + + let sweep = SweepGradient { + cx, + cy, + start_angle: 0.0, + end_angle: 360.0, + transform: actual_transform.to_krilla(), + spread_method: SpreadMethod::Pad, + stops, + anti_alias: gradient.anti_alias(), + }; + + (sweep.into(), 255) + } + } +} + +fn convert_gradient_stops(gradient: &Gradient) -> Vec { + let mut stops = vec![]; + + let use_cmyk = gradient.stops().iter().all(|s| s.color.space() == ColorSpace::Cmyk); + + let mut add_single = |color: &Color, offset: Ratio| { + let (color, opacity) = if use_cmyk { + (convert_cmyk(color).into(), 255) + } else { + let (c, a) = convert_rgb(color); + (c.into(), a) + }; + + let opacity = NormalizedF32::new((opacity as f32) / 255.0).unwrap(); + let offset = NormalizedF32::new(offset.get() as f32).unwrap(); + let stop = Stop { offset, color, opacity }; + stops.push(stop); + }; + + // Convert stops. + match &gradient { + Gradient::Linear(_) | Gradient::Radial(_) => { + if let Some(s) = gradient.stops().first() { + add_single(&s.color, s.offset.unwrap()); + } + + // Create the individual gradient functions for each pair of stops. + for window in gradient.stops().windows(2) { + let (first, second) = (window[0], window[1]); + + // If we have a hue index or are using Oklab, we will create several + // stops in-between to make the gradient smoother without interpolation + // issues with native color spaces. + if gradient.space().hue_index().is_some() { + for i in 0..=32 { + let t = i as f64 / 32.0; + let real_t = Ratio::new( + first.offset.unwrap().get() * (1.0 - t) + + second.offset.unwrap().get() * t, + ); + + let c = gradient.sample(RatioOrAngle::Ratio(real_t)); + add_single(&c, real_t); + } + } + + add_single(&second.color, second.offset.unwrap()); + } + } + Gradient::Conic(conic) => { + if let Some((c, t)) = conic.stops.first() { + add_single(c, *t); + } + + for window in conic.stops.windows(2) { + let ((c0, t0), (c1, t1)) = (window[0], window[1]); + + // Precision: + // - On an even color, insert a stop every 90deg. + // - For a hue-based color space, insert 200 stops minimum. + // - On any other, insert 20 stops minimum. + let max_dt = if c0 == c1 { + 0.25 + } else if conic.space.hue_index().is_some() { + 0.005 + } else { + 0.05 + }; + + let mut t_x = t0.get(); + let dt = (t1.get() - t0.get()).min(max_dt); + + // Special casing for sharp gradients. + if t0 == t1 { + add_single(&c1, t1); + continue; + } + + while t_x < t1.get() { + let t_next = (t_x + dt).min(t1.get()); + + // The current progress in the current window. + let t = |t| (t - t0.get()) / (t1.get() - t0.get()); + + let c_next = Color::mix_iter( + [ + WeightedColor::new(c0, 1.0 - t(t_next)), + WeightedColor::new(c1, t(t_next)), + ], + conic.space, + ) + .unwrap(); + + add_single(&c_next, Ratio::new(t_next)); + t_x = t_next; + } + + add_single(&c1, t1); + } + } + } + + stops +} + +fn convert_dash(dash: &DashPattern) -> StrokeDash { + StrokeDash { + array: dash.array.iter().map(|e| e.to_f32()).collect(), + offset: dash.phase.to_f32(), + } +} + +fn correct_transform(state: &State, relative: RelativeTo) -> Transform { + // In krilla, if we have a shape with a transform and a complex paint, + // then the paint will inherit the transform of the shape. + match relative { + // Because of the above, we don't need to apply an additional transform here. + RelativeTo::Self_ => Transform::identity(), + // Because of the above, we need to first reverse the transform that will be + // applied from the shape, and then re-apply the transform that is used for + // the next parent container. + RelativeTo::Parent => state + .transform() + .invert() + .unwrap() + .pre_concat(state.container_transform()), + } +} diff --git a/crates/typst-pdf/src/resources.rs b/crates/typst-pdf/src/resources.rs deleted file mode 100644 index bdbf2f1e42..0000000000 --- a/crates/typst-pdf/src/resources.rs +++ /dev/null @@ -1,349 +0,0 @@ -//! PDF resources. -//! -//! Resources are defined in dictionaries. They map identifiers such as `Im0` to -//! a PDF reference. Each [content stream] is associated with a resource dictionary. -//! The identifiers defined in the resources can then be used in content streams. -//! -//! [content stream]: `crate::content` - -use std::collections::{BTreeMap, HashMap}; -use std::hash::Hash; - -use ecow::{eco_format, EcoString}; -use pdf_writer::{Dict, Finish, Name, Ref}; -use subsetter::GlyphRemapper; -use typst_library::diag::{SourceResult, StrResult}; -use typst_library::text::{Font, Lang}; -use typst_library::visualize::Image; -use typst_syntax::Span; -use typst_utils::Deferred; - -use crate::color::ColorSpaces; -use crate::color_font::ColorFontMap; -use crate::extg::ExtGState; -use crate::gradient::PdfGradient; -use crate::image::EncodedImage; -use crate::tiling::TilingRemapper; -use crate::{PdfChunk, Renumber, WithEverything, WithResources}; - -/// All the resources that have been collected when traversing the document. -/// -/// This does not allocate references to resources, only track what was used -/// and deduplicate what can be deduplicated. -/// -/// You may notice that this structure is a tree: [`TilingRemapper`] and -/// [`ColorFontMap`] (that are present in the fields of [`Resources`]), -/// themselves contain [`Resources`] (that will be called "sub-resources" from -/// now on). Because color glyphs and tilings are defined using content -/// streams, just like pages, they can refer to resources too, which are tracked -/// by the respective sub-resources. -/// -/// Each instance of this structure will become a `/Resources` dictionary in -/// the final PDF. It is not possible to use a single shared dictionary for all -/// pages, tilings and color fonts, because if a resource is listed in its own -/// `/Resources` dictionary, some PDF readers will fail to open the document. -/// -/// Because we need to lazily initialize sub-resources (we don't know how deep -/// the tree will be before reading the document), and that this is done in a -/// context where no PDF reference allocator is available, `Resources` are -/// originally created with the type parameter `R = ()`. The reference for each -/// dictionary will only be allocated in the next phase, once we know the shape -/// of the tree, at which point `R` becomes `Ref`. No other value of `R` should -/// ever exist. -pub struct Resources { - /// The global reference to this resource dictionary, or `()` if it has not - /// been allocated yet. - pub reference: R, - - /// Handles color space writing. - pub colors: ColorSpaces, - - /// Deduplicates fonts used across the document. - pub fonts: Remapper, - /// Deduplicates images used across the document. - pub images: Remapper, - /// Handles to deferred image conversions. - pub deferred_images: HashMap>, Span)>, - /// Deduplicates gradients used across the document. - pub gradients: Remapper, - /// Deduplicates tilings used across the document. - pub tilings: Option>>, - /// Deduplicates external graphics states used across the document. - pub ext_gs: Remapper, - /// Deduplicates color glyphs. - pub color_fonts: Option>>, - - // The fields below do not correspond to actual resources that will be - // written in a dictionary, but are more meta-data about resources that - // can't really live somewhere else. - /// The number of glyphs for all referenced languages in the content stream. - /// We keep track of this to determine the main document language. - /// BTreeMap is used to write sorted list of languages to metadata. - pub languages: BTreeMap, - - /// For each font a mapping from used glyphs to their text representation. - /// This is used for the PDF's /ToUnicode map, and important for copy-paste - /// and searching. - /// - /// Note that the text representation may contain multiple chars in case of - /// ligatures or similar things, and it may have no entry in the font's cmap - /// (or only a private-use codepoint), like the “Th” in Linux Libertine. - /// - /// A glyph may have multiple entries in the font's cmap, and even the same - /// glyph can have a different text representation within one document. - /// But /ToUnicode does not support that, so we just save the first occurrence. - pub glyph_sets: HashMap>, - /// Same as `glyph_sets`, but for color fonts. - pub color_glyph_sets: HashMap>, - /// Stores the glyph remapper for each font for the subsetter. - pub glyph_remappers: HashMap, -} - -impl Renumber for Resources { - fn renumber(&mut self, offset: i32) { - self.reference.renumber(offset); - - if let Some(color_fonts) = &mut self.color_fonts { - color_fonts.resources.renumber(offset); - } - - if let Some(tilings) = &mut self.tilings { - tilings.resources.renumber(offset); - } - } -} - -impl Default for Resources<()> { - fn default() -> Self { - Resources { - reference: (), - colors: ColorSpaces::default(), - fonts: Remapper::new("F"), - images: Remapper::new("Im"), - deferred_images: HashMap::new(), - gradients: Remapper::new("Gr"), - tilings: None, - ext_gs: Remapper::new("Gs"), - color_fonts: None, - languages: BTreeMap::new(), - glyph_sets: HashMap::new(), - color_glyph_sets: HashMap::new(), - glyph_remappers: HashMap::new(), - } - } -} - -impl Resources<()> { - /// Associate a reference with this resource dictionary (and do so - /// recursively for sub-resources). - pub fn with_refs(self, refs: &ResourcesRefs) -> Resources { - Resources { - reference: refs.reference, - colors: self.colors, - fonts: self.fonts, - images: self.images, - deferred_images: self.deferred_images, - gradients: self.gradients, - tilings: self - .tilings - .zip(refs.tilings.as_ref()) - .map(|(p, r)| Box::new(p.with_refs(r))), - ext_gs: self.ext_gs, - color_fonts: self - .color_fonts - .zip(refs.color_fonts.as_ref()) - .map(|(c, r)| Box::new(c.with_refs(r))), - languages: self.languages, - glyph_sets: self.glyph_sets, - color_glyph_sets: self.color_glyph_sets, - glyph_remappers: self.glyph_remappers, - } - } -} - -impl Resources { - /// Run a function on this resource dictionary and all - /// of its sub-resources. - pub fn traverse

(&self, process: &mut P) -> SourceResult<()> - where - P: FnMut(&Self) -> SourceResult<()>, - { - process(self)?; - if let Some(color_fonts) = &self.color_fonts { - color_fonts.resources.traverse(process)?; - } - if let Some(tilings) = &self.tilings { - tilings.resources.traverse(process)?; - } - Ok(()) - } -} - -/// References for a resource tree. -/// -/// This structure is a tree too, that should have the same structure as the -/// corresponding `Resources`. -pub struct ResourcesRefs { - pub reference: Ref, - pub color_fonts: Option>, - pub tilings: Option>, -} - -impl Renumber for ResourcesRefs { - fn renumber(&mut self, offset: i32) { - self.reference.renumber(offset); - if let Some(color_fonts) = &mut self.color_fonts { - color_fonts.renumber(offset); - } - if let Some(tilings) = &mut self.tilings { - tilings.renumber(offset); - } - } -} - -/// Allocate references for all resource dictionaries. -pub fn alloc_resources_refs( - context: &WithResources, -) -> SourceResult<(PdfChunk, ResourcesRefs)> { - let mut chunk = PdfChunk::new(); - /// Recursively explore resource dictionaries and assign them references. - fn refs_for(resources: &Resources<()>, chunk: &mut PdfChunk) -> ResourcesRefs { - ResourcesRefs { - reference: chunk.alloc(), - color_fonts: resources - .color_fonts - .as_ref() - .map(|c| Box::new(refs_for(&c.resources, chunk))), - tilings: resources - .tilings - .as_ref() - .map(|p| Box::new(refs_for(&p.resources, chunk))), - } - } - - let refs = refs_for(&context.resources, &mut chunk); - Ok((chunk, refs)) -} - -/// Write the resource dictionaries that will be referenced by all pages. -/// -/// We add a reference to this dictionary to each page individually instead of -/// to the root node of the page tree because using the resource inheritance -/// feature breaks PDF merging with Apple Preview. -/// -/// Also write resource dictionaries for Type3 fonts and PDF patterns. -pub fn write_resource_dictionaries(ctx: &WithEverything) -> SourceResult<(PdfChunk, ())> { - let mut chunk = PdfChunk::new(); - let mut used_color_spaces = ColorSpaces::default(); - - ctx.resources.traverse(&mut |resources| { - used_color_spaces.merge(&resources.colors); - - let images_ref = chunk.alloc.bump(); - let patterns_ref = chunk.alloc.bump(); - let ext_gs_states_ref = chunk.alloc.bump(); - let color_spaces_ref = chunk.alloc.bump(); - - let mut color_font_slices = Vec::new(); - let mut color_font_numbers = HashMap::new(); - if let Some(color_fonts) = &resources.color_fonts { - for (_, font_slice) in color_fonts.iter() { - color_font_numbers.insert(font_slice.clone(), color_font_slices.len()); - color_font_slices.push(font_slice); - } - } - let color_font_remapper = Remapper { - prefix: "Cf", - to_pdf: color_font_numbers, - to_items: color_font_slices, - }; - - resources - .images - .write(&ctx.references.images, &mut chunk.indirect(images_ref).dict()); - - let mut patterns_dict = chunk.indirect(patterns_ref).dict(); - resources - .gradients - .write(&ctx.references.gradients, &mut patterns_dict); - if let Some(p) = &resources.tilings { - p.remapper.write(&ctx.references.tilings, &mut patterns_dict); - } - patterns_dict.finish(); - - resources - .ext_gs - .write(&ctx.references.ext_gs, &mut chunk.indirect(ext_gs_states_ref).dict()); - - let mut res_dict = chunk - .indirect(resources.reference) - .start::(); - res_dict.pair(Name(b"XObject"), images_ref); - res_dict.pair(Name(b"Pattern"), patterns_ref); - res_dict.pair(Name(b"ExtGState"), ext_gs_states_ref); - res_dict.pair(Name(b"ColorSpace"), color_spaces_ref); - - // TODO: can't this be an indirect reference too? - let mut fonts_dict = res_dict.fonts(); - resources.fonts.write(&ctx.references.fonts, &mut fonts_dict); - color_font_remapper.write(&ctx.references.color_fonts, &mut fonts_dict); - fonts_dict.finish(); - - res_dict.finish(); - - let color_spaces = chunk.indirect(color_spaces_ref).dict(); - resources - .colors - .write_color_spaces(color_spaces, &ctx.globals.color_functions); - - Ok(()) - })?; - - used_color_spaces.write_functions(&mut chunk, &ctx.globals.color_functions); - - Ok((chunk, ())) -} - -/// Assigns new, consecutive PDF-internal indices to items. -pub struct Remapper { - /// The prefix to use when naming these resources. - prefix: &'static str, - /// Forwards from the items to the pdf indices. - to_pdf: HashMap, - /// Backwards from the pdf indices to the items. - to_items: Vec, -} - -impl Remapper -where - T: Eq + Hash + Clone, -{ - /// Create an empty mapping. - pub fn new(prefix: &'static str) -> Self { - Self { prefix, to_pdf: HashMap::new(), to_items: vec![] } - } - - /// Insert an item in the mapping if it was not already present. - pub fn insert(&mut self, item: T) -> usize { - let to_layout = &mut self.to_items; - *self.to_pdf.entry(item.clone()).or_insert_with(|| { - let pdf_index = to_layout.len(); - to_layout.push(item); - pdf_index - }) - } - - /// All items in this - pub fn items(&self) -> impl Iterator + '_ { - self.to_items.iter() - } - - /// Write this list of items in a Resource dictionary. - fn write(&self, mapping: &HashMap, dict: &mut Dict) { - for (number, item) in self.items().enumerate() { - let name = eco_format!("{}{}", self.prefix, number); - let reference = mapping[item]; - dict.pair(Name(name.as_bytes()), reference); - } - } -} diff --git a/crates/typst-pdf/src/shape.rs b/crates/typst-pdf/src/shape.rs new file mode 100644 index 0000000000..5b9232dbe6 --- /dev/null +++ b/crates/typst-pdf/src/shape.rs @@ -0,0 +1,106 @@ +use krilla::geom::{Path, PathBuilder, Rect}; +use krilla::surface::Surface; +use typst_library::diag::SourceResult; +use typst_library::visualize::{Geometry, Shape}; +use typst_syntax::Span; + +use crate::convert::{FrameContext, GlobalContext}; +use crate::paint; +use crate::util::{convert_path, AbsExt, TransformExt}; + +#[typst_macros::time(name = "handle shape")] +pub(crate) fn handle_shape( + fc: &mut FrameContext, + shape: &Shape, + surface: &mut Surface, + gc: &mut GlobalContext, + span: Span, +) -> SourceResult<()> { + surface.set_location(span.into_raw().get()); + surface.push_transform(&fc.state().transform().to_krilla()); + + if let Some(path) = convert_geometry(&shape.geometry) { + let fill = if let Some(paint) = &shape.fill { + Some(paint::convert_fill( + gc, + paint, + shape.fill_rule, + false, + surface, + fc.state(), + shape.geometry.bbox_size(), + )?) + } else { + None + }; + + let stroke = shape.stroke.as_ref().and_then(|stroke| { + if stroke.thickness.to_f32() > 0.0 { + Some(stroke) + } else { + None + } + }); + + let stroke = if let Some(stroke) = &stroke { + let stroke = paint::convert_stroke( + gc, + stroke, + false, + surface, + fc.state(), + shape.geometry.bbox_size(), + )?; + + Some(stroke) + } else { + None + }; + + // Otherwise, krilla will by default fill with a black paint. + if fill.is_some() || stroke.is_some() { + surface.set_fill(fill); + surface.set_stroke(stroke); + surface.draw_path(&path); + } + } + + surface.pop(); + surface.reset_location(); + + Ok(()) +} + +fn convert_geometry(geometry: &Geometry) -> Option { + let mut path_builder = PathBuilder::new(); + + match geometry { + Geometry::Line(l) => { + path_builder.move_to(0.0, 0.0); + path_builder.line_to(l.x.to_f32(), l.y.to_f32()); + } + Geometry::Rect(size) => { + let w = size.x.to_f32(); + let h = size.y.to_f32(); + let rect = if w < 0.0 || h < 0.0 { + // krilla doesn't normally allow for negative dimensions, but + // Typst supports them, so we apply a transform if needed. + let transform = + krilla::geom::Transform::from_scale(w.signum(), h.signum()); + Rect::from_xywh(0.0, 0.0, w.abs(), h.abs()) + .and_then(|rect| rect.transform(transform)) + } else { + Rect::from_xywh(0.0, 0.0, w, h) + }; + + if let Some(rect) = rect { + path_builder.push_rect(rect); + } + } + Geometry::Curve(c) => { + convert_path(c, &mut path_builder); + } + } + + path_builder.finish() +} diff --git a/crates/typst-pdf/src/text.rs b/crates/typst-pdf/src/text.rs new file mode 100644 index 0000000000..8d532e08c9 --- /dev/null +++ b/crates/typst-pdf/src/text.rs @@ -0,0 +1,135 @@ +use std::ops::Range; +use std::sync::Arc; + +use bytemuck::TransparentWrapper; +use krilla::surface::{Location, Surface}; +use krilla::text::GlyphId; +use typst_library::diag::{bail, SourceResult}; +use typst_library::layout::Size; +use typst_library::text::{Font, Glyph, TextItem}; +use typst_library::visualize::FillRule; +use typst_syntax::Span; + +use crate::convert::{FrameContext, GlobalContext}; +use crate::paint; +use crate::util::{display_font, AbsExt, TransformExt}; + +#[typst_macros::time(name = "handle text")] +pub(crate) fn handle_text( + fc: &mut FrameContext, + t: &TextItem, + surface: &mut Surface, + gc: &mut GlobalContext, +) -> SourceResult<()> { + *gc.languages.entry(t.lang).or_insert(0) += t.glyphs.len(); + + let font = convert_font(gc, t.font.clone())?; + let fill = paint::convert_fill( + gc, + &t.fill, + FillRule::NonZero, + true, + surface, + fc.state(), + Size::zero(), + )?; + let stroke = + if let Some(stroke) = t.stroke.as_ref().map(|s| { + paint::convert_stroke(gc, s, true, surface, fc.state(), Size::zero()) + }) { + Some(stroke?) + } else { + None + }; + let text = t.text.as_str(); + let size = t.size; + let glyphs: &[PdfGlyph] = TransparentWrapper::wrap_slice(t.glyphs.as_slice()); + + surface.push_transform(&fc.state().transform().to_krilla()); + surface.set_fill(Some(fill)); + surface.set_stroke(stroke); + surface.draw_glyphs( + krilla::geom::Point::from_xy(0.0, 0.0), + glyphs, + font.clone(), + text, + size.to_f32(), + false, + ); + + surface.pop(); + + Ok(()) +} + +fn convert_font( + gc: &mut GlobalContext, + typst_font: Font, +) -> SourceResult { + if let Some(font) = gc.fonts_forward.get(&typst_font) { + Ok(font.clone()) + } else { + let font = build_font(typst_font.clone())?; + + gc.fonts_forward.insert(typst_font.clone(), font.clone()); + gc.fonts_backward.insert(font.clone(), typst_font.clone()); + + Ok(font) + } +} + +#[comemo::memoize] +fn build_font(typst_font: Font) -> SourceResult { + let font_data: Arc + Send + Sync> = + Arc::new(typst_font.data().clone()); + + match krilla::text::Font::new(font_data.into(), typst_font.index()) { + None => { + let font_str = display_font(&typst_font); + bail!(Span::detached(), "failed to process font {font_str}"); + } + Some(f) => Ok(f), + } +} + +#[derive(TransparentWrapper, Debug)] +#[repr(transparent)] +struct PdfGlyph(Glyph); + +impl krilla::text::Glyph for PdfGlyph { + #[inline(always)] + fn glyph_id(&self) -> GlyphId { + GlyphId::new(self.0.id as u32) + } + + #[inline(always)] + fn text_range(&self) -> Range { + self.0.range.start as usize..self.0.range.end as usize + } + + #[inline(always)] + fn x_advance(&self, size: f32) -> f32 { + // Don't use `Em::at`, because it contains an expensive check whether the result is finite. + self.0.x_advance.get() as f32 * size + } + + #[inline(always)] + fn x_offset(&self, size: f32) -> f32 { + // Don't use `Em::at`, because it contains an expensive check whether the result is finite. + self.0.x_offset.get() as f32 * size + } + + #[inline(always)] + fn y_offset(&self, _: f32) -> f32 { + 0.0 + } + + #[inline(always)] + fn y_advance(&self, _: f32) -> f32 { + 0.0 + } + + fn location(&self) -> Option { + Some(self.0.span.0.into_raw().get()) + } +} diff --git a/crates/typst-pdf/src/tiling.rs b/crates/typst-pdf/src/tiling.rs deleted file mode 100644 index f8950f3445..0000000000 --- a/crates/typst-pdf/src/tiling.rs +++ /dev/null @@ -1,184 +0,0 @@ -use std::collections::HashMap; - -use ecow::eco_format; -use pdf_writer::types::{ColorSpaceOperand, PaintType, TilingType}; -use pdf_writer::{Filter, Name, Rect, Ref}; -use typst_library::diag::SourceResult; -use typst_library::layout::{Abs, Ratio, Transform}; -use typst_library::visualize::{RelativeTo, Tiling}; -use typst_utils::Numeric; - -use crate::color::PaintEncode; -use crate::resources::{Remapper, ResourcesRefs}; -use crate::{content, transform_to_array, PdfChunk, Resources, WithGlobalRefs}; - -/// Writes the actual patterns (tiling patterns) to the PDF. -/// This is performed once after writing all pages. -pub fn write_tilings( - context: &WithGlobalRefs, -) -> SourceResult<(PdfChunk, HashMap)> { - let mut chunk = PdfChunk::new(); - let mut out = HashMap::new(); - context.resources.traverse(&mut |resources| { - let Some(patterns) = &resources.tilings else { - return Ok(()); - }; - - for pdf_pattern in patterns.remapper.items() { - let PdfTiling { transform, pattern, content, .. } = pdf_pattern; - if out.contains_key(pdf_pattern) { - continue; - } - - let tiling = chunk.alloc(); - out.insert(pdf_pattern.clone(), tiling); - - let mut tiling_pattern = chunk.tiling_pattern(tiling, content); - tiling_pattern - .tiling_type(TilingType::ConstantSpacing) - .paint_type(PaintType::Colored) - .bbox(Rect::new( - 0.0, - 0.0, - pattern.size().x.to_pt() as _, - pattern.size().y.to_pt() as _, - )) - .x_step((pattern.size().x + pattern.spacing().x).to_pt() as _) - .y_step((pattern.size().y + pattern.spacing().y).to_pt() as _); - - // The actual resource dict will be written in a later step - tiling_pattern.pair(Name(b"Resources"), patterns.resources.reference); - - tiling_pattern - .matrix(transform_to_array( - transform - .pre_concat(Transform::scale(Ratio::one(), -Ratio::one())) - .post_concat(Transform::translate( - Abs::zero(), - pattern.spacing().y, - )), - )) - .filter(Filter::FlateDecode); - } - - Ok(()) - })?; - - Ok((chunk, out)) -} - -/// A pattern and its transform. -#[derive(Clone, PartialEq, Eq, Hash, Debug)] -pub struct PdfTiling { - /// The transform to apply to the pattern. - pub transform: Transform, - /// The pattern to paint. - pub pattern: Tiling, - /// The rendered pattern. - pub content: Vec, -} - -/// Registers a pattern with the PDF. -fn register_pattern( - ctx: &mut content::Builder, - pattern: &Tiling, - on_text: bool, - mut transforms: content::Transforms, -) -> SourceResult { - let patterns = ctx - .resources - .tilings - .get_or_insert_with(|| Box::new(TilingRemapper::new())); - - // Edge cases for strokes. - if transforms.size.x.is_zero() { - transforms.size.x = Abs::pt(1.0); - } - - if transforms.size.y.is_zero() { - transforms.size.y = Abs::pt(1.0); - } - - let transform = match pattern.unwrap_relative(on_text) { - RelativeTo::Self_ => transforms.transform, - RelativeTo::Parent => transforms.container_transform, - }; - - // Render the body. - let content = content::build( - ctx.options, - &mut patterns.resources, - pattern.frame(), - None, - None, - )?; - - let pdf_pattern = PdfTiling { - transform, - pattern: pattern.clone(), - content: content.content.wait().clone(), - }; - - Ok(patterns.remapper.insert(pdf_pattern)) -} - -impl PaintEncode for Tiling { - fn set_as_fill( - &self, - ctx: &mut content::Builder, - on_text: bool, - transforms: content::Transforms, - ) -> SourceResult<()> { - ctx.reset_fill_color_space(); - - let index = register_pattern(ctx, self, on_text, transforms)?; - let id = eco_format!("P{index}"); - let name = Name(id.as_bytes()); - - ctx.content.set_fill_color_space(ColorSpaceOperand::Pattern); - ctx.content.set_fill_pattern(None, name); - Ok(()) - } - - fn set_as_stroke( - &self, - ctx: &mut content::Builder, - on_text: bool, - transforms: content::Transforms, - ) -> SourceResult<()> { - ctx.reset_stroke_color_space(); - - let index = register_pattern(ctx, self, on_text, transforms)?; - let id = eco_format!("P{index}"); - let name = Name(id.as_bytes()); - - ctx.content.set_stroke_color_space(ColorSpaceOperand::Pattern); - ctx.content.set_stroke_pattern(None, name); - Ok(()) - } -} - -/// De-duplicate patterns and the resources they require to be drawn. -pub struct TilingRemapper { - /// Pattern de-duplicator. - pub remapper: Remapper, - /// PDF resources that are used by these patterns. - pub resources: Resources, -} - -impl TilingRemapper<()> { - pub fn new() -> Self { - Self { - remapper: Remapper::new("P"), - resources: Resources::default(), - } - } - - /// Allocate a reference to the resource dictionary of these patterns. - pub fn with_refs(self, refs: &ResourcesRefs) -> TilingRemapper { - TilingRemapper { - remapper: self.remapper, - resources: self.resources.with_refs(refs), - } - } -} diff --git a/crates/typst-pdf/src/util.rs b/crates/typst-pdf/src/util.rs new file mode 100644 index 0000000000..3b85d0b8a7 --- /dev/null +++ b/crates/typst-pdf/src/util.rs @@ -0,0 +1,120 @@ +//! Basic utilities for converting typst types to krilla. + +use krilla::geom as kg; +use krilla::geom::PathBuilder; +use krilla::paint as kp; +use typst_library::layout::{Abs, Point, Size, Transform}; +use typst_library::text::Font; +use typst_library::visualize::{Curve, CurveItem, FillRule, LineCap, LineJoin}; + +pub(crate) trait SizeExt { + fn to_krilla(&self) -> kg::Size; +} + +impl SizeExt for Size { + fn to_krilla(&self) -> kg::Size { + kg::Size::from_wh(self.x.to_f32(), self.y.to_f32()).unwrap() + } +} + +pub(crate) trait PointExt { + fn to_krilla(&self) -> kg::Point; +} + +impl PointExt for Point { + fn to_krilla(&self) -> kg::Point { + kg::Point::from_xy(self.x.to_f32(), self.y.to_f32()) + } +} + +pub(crate) trait LineCapExt { + fn to_krilla(&self) -> kp::LineCap; +} + +impl LineCapExt for LineCap { + fn to_krilla(&self) -> kp::LineCap { + match self { + LineCap::Butt => kp::LineCap::Butt, + LineCap::Round => kp::LineCap::Round, + LineCap::Square => kp::LineCap::Square, + } + } +} + +pub(crate) trait LineJoinExt { + fn to_krilla(&self) -> kp::LineJoin; +} + +impl LineJoinExt for LineJoin { + fn to_krilla(&self) -> kp::LineJoin { + match self { + LineJoin::Miter => kp::LineJoin::Miter, + LineJoin::Round => kp::LineJoin::Round, + LineJoin::Bevel => kp::LineJoin::Bevel, + } + } +} + +pub(crate) trait TransformExt { + fn to_krilla(&self) -> kg::Transform; +} + +impl TransformExt for Transform { + fn to_krilla(&self) -> kg::Transform { + kg::Transform::from_row( + self.sx.get() as f32, + self.ky.get() as f32, + self.kx.get() as f32, + self.sy.get() as f32, + self.tx.to_f32(), + self.ty.to_f32(), + ) + } +} + +pub(crate) trait FillRuleExt { + fn to_krilla(&self) -> kp::FillRule; +} + +impl FillRuleExt for FillRule { + fn to_krilla(&self) -> kp::FillRule { + match self { + FillRule::NonZero => kp::FillRule::NonZero, + FillRule::EvenOdd => kp::FillRule::EvenOdd, + } + } +} + +pub(crate) trait AbsExt { + fn to_f32(self) -> f32; +} + +impl AbsExt for Abs { + fn to_f32(self) -> f32 { + self.to_pt() as f32 + } +} + +/// Display the font family of a font. +pub(crate) fn display_font(font: &Font) -> &str { + &font.info().family +} + +/// Convert a typst path to a krilla path. +pub(crate) fn convert_path(path: &Curve, builder: &mut PathBuilder) { + for item in &path.0 { + match item { + CurveItem::Move(p) => builder.move_to(p.x.to_f32(), p.y.to_f32()), + CurveItem::Line(p) => builder.line_to(p.x.to_f32(), p.y.to_f32()), + CurveItem::Cubic(p1, p2, p3) => builder.cubic_to( + p1.x.to_f32(), + p1.y.to_f32(), + p2.x.to_f32(), + p2.y.to_f32(), + p3.x.to_f32(), + p3.y.to_f32(), + ), + CurveItem::Close => builder.close(), + } + } +} From 12699eb7f415bdba6797c84e3e7bf44dde75bdf9 Mon Sep 17 00:00:00 2001 From: Ian Wrzesinski <133046678+wrzian@users.noreply.github.com> Date: Wed, 2 Apr 2025 05:30:04 -0400 Subject: [PATCH 078/558] Parse multi-character numbers consistently in math (#5996) Co-authored-by: Laurenz --- crates/typst-syntax/src/parser.rs | 8 +++----- .../ref/issue-4828-math-number-multi-char.png | Bin 0 -> 465 bytes tests/ref/math-frac-precedence.png | Bin 5504 -> 3586 bytes tests/suite/math/frac.typ | 4 ++-- tests/suite/math/syntax.typ | 4 ++++ 5 files changed, 9 insertions(+), 7 deletions(-) create mode 100644 tests/ref/issue-4828-math-number-multi-char.png diff --git a/crates/typst-syntax/src/parser.rs b/crates/typst-syntax/src/parser.rs index c5d13c8b33..ecd0d78a5a 100644 --- a/crates/typst-syntax/src/parser.rs +++ b/crates/typst-syntax/src/parser.rs @@ -271,11 +271,9 @@ fn math_expr_prec(p: &mut Parser, min_prec: usize, stop: SyntaxKind) { } SyntaxKind::Text | SyntaxKind::MathText | SyntaxKind::MathShorthand => { - continuable = !p.at(SyntaxKind::MathShorthand) - && matches!( - math_class(p.current_text()), - None | Some(MathClass::Alphabetic) - ); + // `a(b)/c` parses as `(a(b))/c` if `a` is continuable. + continuable = math_class(p.current_text()) == Some(MathClass::Alphabetic) + || p.current_text().chars().all(char::is_alphabetic); if !maybe_delimited(p) { p.eat(); } diff --git a/tests/ref/issue-4828-math-number-multi-char.png b/tests/ref/issue-4828-math-number-multi-char.png new file mode 100644 index 0000000000000000000000000000000000000000..ff0a9bab97de1957f3ea99de261346c14f8ccd9d GIT binary patch literal 465 zcmV;?0WSWDP)|ER(H<~| zY15fS>tQtA{}XtTVCdm*c-i;*JA9768pl*k6|TZn_hfVZBb`zLU zu}s@P^Y-#l!KFB0edr+gWfqVu9ueHV4bU7M05<^?+C#&I=3AT8;l;r86B6U-X$jKF za=L2`P;6~j!>cYf`lp^vQWqM|=kr@!WGr_t9x0mZz~X6-bI-fy7r^iQUO)9tl5%1- z#^oR^9F0bMsV8vm3s&yKnOscn)a^gub#$+3kwt>F^Kh*g7B-VXEpJLEO)(aS^x2H5 z;KpF747*FrtuipA^fu91SfEx|IQ_Y;k4A3B?xZqAmSN`-9qo4u4+mkcQ8L^4%lZSK z8oHWdWg0`nTS2m)v3YDC_`}G^hC`YiT`~W@dW%)K3RmbaR+3c8Kj6b400000NkvXX Hu0mjf!|2wg literal 0 HcmV?d00001 diff --git a/tests/ref/math-frac-precedence.png b/tests/ref/math-frac-precedence.png index 973c433e2c0d267aa57d746e586736c2b77b1a96..fd16f2e6bf3e043a81e4e6e3146a6b15c05fc559 100644 GIT binary patch literal 3586 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 IM6N<$f*h$L1poj5 literal 5504 zcmYjVXEYp8x7A1Qql;dGQHSVt2BQrqW9>cOOz0A z^1W}p^?uxYf84X~J!{=__t|^H^>oxo2pI@5Ffd49>JS6;8i*cE_*m$f(X09d0|TfA zgD4pWE*=%!e5CH98Mq;`PCTLgaSImWJl0f9l*jbOet`i5>MMQW+r+DlFj0i+Taqj3 zf8mQ8P8L<@Le&;LKA! zOEw&;e|fYxULcdg_$2wLDE!v7JBEM{OX%xqRD6HZlhx}5=OS-~OsbUK_3+Q-7MB+j z3JUhU_*vvO;Wu1I>YbrmX=;CO4~2k{UC{tv9zz_#FI$vcygEo8Z3IM@=>2L?Pvp`t7HbG25w za=&JlUUZV!yt+G6TX%}ksJ-u_wF&)MKX7|>GBI?aei;wsukE;XlL-F(^Jf1;5ly-a z+wn@k8j0Vl1=FvAXKSw>#(wGMiaIScIQ{v3>0vfgqWJFRL1R@_iXs7=@};QOPkp^OEU zrL-z-!fBO-po5_;M-Q}NU_(V;)_=GUAJnIv7u!{0xbWF8_*V$VkD#NDW= z`EA>045z-?uc*#snm~mn*tmjjNuu1x%M|_gIT*lt*EFj1YwW z&=9xt=D3gP)9pIkr4Bt=ZFfLMDo`J!Cc$v=!kpMvDo%eh(WV3G6ht`?zBLJW9hnUg zJavM=@SAPgf7^77%;@bYih@V{CbhlT3BD7}9t)evG?8;QukH&1R`ty=I5e*+bx|_1 zVxyf7b%X_Pgay&>8>YnqjxH73s~KFiBf81{Q1iSdaj+-1Iw z?UR?<=!^T?UwjhRul=|e2h5xcnnG{RrCAwzCc?P)io3skWipv-o&f@ZC+$~-U%&oH zmT818eGL-J>H0-pohqkANF!kS?oX_bGE(sTMr=YhgLGrhjgKHM&@0zt*>^(j`iH;b zrS_8*NPc3j=^Z&B`zldvoVEM0kc|HUC!^oXU^(p;-6DBQvMZJ9J1Sk-V#>|kqF3_2 zW}fcr^0|ZEXMg6s=%~O(PRN|d?wfsH3vY{$WtMqK{!ZjYR6j9$ zzxJm8KAh9#!t8oH3Z6bA&MWLy*_kL3>g=3^5cT00)gy_=_TVr!3e=QLOO^0-pSDu0k#ASlsviieQI=1(>T^ zd+;wGz+DUfvkaaNjXGuJ{k8BvM`#=pSu2}k8&}a)qxtbs`W_W-W4iSdGe&Y z>b*6WjV-rJts}}FKmIu!wJd>X&SAtko6;2WAH(c7!cpev9XeWS(CVDTibRljrE~$&%>4qouC_q z{%X)}0mMlTMNj3a%L?Bo2c1E7(_BWu2n_?_-e+>C1*#g3pb6OvV%Jb4uYL7&WSw00 zfEEtbmcunj4XOVueK~kN5}>E8p_+F3cxvq79veom$V#iy&+6y*ox)ssx0jSaFmK3G z)%-+_$21Vb=9*LeE(>$3xnzNSw0J+qV*y?A+Ru!4g9oufkW}wr8QhI7-50Tsg3Cn} z%Sbx&jJ3`aM`@yPgA#kA^z3a^j-ih$i^n2Kh;3!regW4BnAH$6q=5h8M?5A4&-KQ&&SCacB84TiX zy$zZ;(u-0TVm+Dfx;A5nwkGp2H*wvq#Ij9@P;#jKuAj*Z00gduNRmXZ-5B7n7&fVp zrr4RH$2K3$GF1WQTMhC#S_0;(QS2XVk)%b#!+5=1rxTptQri0QP+SB+3bJnK1;iOI zpX9)Re6&~NZ4WC#gh~9r{dEKW`(J-Oc;iCjy1=FEct%!RA5X&MW~{VS{~_e0J!j7s z3566BNqW&cF*^`?v03W41W^kra3IwZjY<9j|IU4l3P27 zf+#z({^nas<`hw&GGZ%|laPM{=co!OP-1vhj~y@9vc>j01^riF`5|IS z+yhFoCA`k*#y{c(~?~_S2%6(t*p@Kdk1LZL3}^3WJrQ<`sFiy z(anaW6t%TjJsgxPtcq&`lpOnG7xPFdBbGm*m-HkAKn^rb!-T4^68iKRP>96s>efhs!-B^QeZ#tr*ph>u8?l!*CP;!m;vge z3K2uB$(y6KzU|IhbB`Pv)Ot6O8u$?%jnw6HnMxVkffR+mfj^^Hqt9gxH&ZYPY6AM+ zr4(^M%~1lv^)uTI%0y{r4$4&tKh2g_H;j+gI`ooBj+*{GItgmI8@<-R5#+naEZt znlUfS{M1!~_e=HoZB{iHf^^u_t&p*i2@-Thtb0Q|MUK{!9bq9>VoQ$MOSY@exO@4q zaJ1?|OugDLxMm!D4a!9y6AhCoQmgmA_kxHaU|FR-q%vA~+q{!o^aW#j;?teHOG!j* z=gU>Kt;4^Wu!enOUs9KHKF52%tps4kQHxYAM6$tf##M_2ymQCp|NQKxs7aU~ss>w3 zZsodFnn!oV`#ADFoz&EOphoNoTD1;hWU;x-Wr~bK)L?Q(vVK|2btl!C8>6^f3}Q)o z6q+FYJS_|%d(5%;_Z87f^E9CY)r4HaF{xzHPT>|Cab@X(>{6Fov{rr;s>1v$VBjMu z9v^GuR3**30IK#MhB;O*&5SjvTddguhMlY#W|ByHs-3NG?@C0meoGpVv6ohJt z)AZ4S5G8>@5Vqa-N$FoL?xU5ONiG3@sgXnv?W0#3`7YgA>1*$Ixw#NQ!YISshJgp` zKl4*Oh$?0T_99E6Nn~-NE?7Ji|5K{gyF#+q46e9`b{~NyPL?W@gf-No+ zExejlKY=^3FN|F?3Xg`3RU4DkAk2KuOSFjxxqLdm$6womHKGzK*mq?e zw4F6ik_-w%Rvh;6c@=VM?nFU6*^xoylEuhr9T^f3Ctm|(En%Xko|Av;Urw_z?CDj| znaCACD(yoKK6lkMm~uLq%A)2~fUNqwafXk;-+U%b#ah2ORaSG&D^h84t_7#AW!3x_ zX|1n`!<;r&^=Zb&N9KQGm)!ge;IyDJ(p~RTLw;ggC`<5WA0axji{sXa-@7D7W<)ap z>EaEN{Zo71*hd_wNIwoS{{Ti`o7Vsz|vd3+M!$uyqncGT* z+;Ps*4eQtA<)#p;eyk^;QpbIbe90j+V;4%fcPJMiLYg^%f+9*D;T{>z!pLb${f-@g% zp(m&RsZ7*APUW}CUs_e&b*uPjqxX&VGAukB9(Y5keZ)U&vm?Z;wVvsfwQAM@$q;i? zE#yIB@~2&&z>NijIB#MwB7+=uAQ@m=ZytR!PMK{TKeVVQ-JYX zigVQqf*ohoS)xOS)A|Y^Qo{(BU~mOSeF^7hQ69wha3Hgz2P`5>02OG`%#bE0TLb=v z&wwibuwrX8NQh?))vM-cbW7F-?|bo8)aMs@7`sv?CF=8a%-g=YBYsRI8W>*)b16Sp zpW(<{^^>G4g@Mnh)2J406;AqX{1YcfEQzAKlG3NU-dD(%0s+V=>RgaGQY87Ak}wXw zPiyHzpBzsfRe89oGv8xmn$aE(Ax-WShKHWj zo7f(_!bDtJaXb$>)jSE$^)v|ejituop0q>xmy%_S@9tjNO}$nf?fnGudhC$twu)`m zr2?6NGFD+mw34Yc>!fj3gfKiM8PuTySu|%7I!N+dN6E??{C>(Cg%T1z%V-;cl}UF2 zGzR?0J0>ANTvJV;(mll5orgLZhlNT@&Ogu;893bq1Ec0G-Qv6_MyIo_E_I&BHHyB~ zlr^gc`5gv~?WA}M17UG*{oY4ImI+GiBeA7`Hy%=;5r^&C7-QAGo{j8G0o3IoZ<^>I z|09!8{-*iKxn{TT8Zs-@zTUF4#8zKxl30=`H`BW};8e9f0t z<*kelMd7Uzb-bJMNa``FmITSTZ5?oXVI!3a5+_R(@LCII?|t6DjR1~ZzbaRuZ}7@C znzhv!vSj-Fp|Y~F8eUfO>mINv=14SaIymGyx2hWQCGtOZ%Ky^6X;~rwL3TVw3X~Pu zYnVqI0V#jn{odX|oz#2h{)|Nn0_C?Hy;TF#2BDn}X>`S#YgzHWN7Hahr(^=cd3BuO zdvbdAmjd>QxgSne+5$dWw;y1l@WnVyYPc{sYnaIvM5oo!dNExg37#h9Xz?m)*Y!I# zGKjFC5kHuvbkKr>c2JhfY|SEh5Rs8rery>Xn{j(W!gS51bb8}_P!bp;5b z?LIvlW0F9qE0jfRy_0p$_OL_lK8wU|mE)vLkv`z{Fw6%GzZ=|eCMOqiL((tAacf2y zO+_5P9QOP+s4>f-3qqi>cv)2%LlYQFTCO?2-JDU;DA)}#>bbw;ReTd6T&N~%{b;q0dmnIWRUO}dy_K**-CT^>3M;sP4@ayZ4S& zG_&|%wG%N#940enS)`Qh2^2?ZRqPid5m~t0q^zstc_G&uu{^=->|^xEFR%e7-$X)e zk>@l(R%&bvJ{o@c86GL`Nyj=$7GBjpmTaW}YS9ktRL zT>N;eshu_GFJBEr-i$1+-#J23nMFipexK&3&L^TCMM3uMrCsthJ^q|O>yrc5#l-JE zBW6*&Mb!Yv)6Oim*Wn>Xai0nL{_Xmu=>UE(=FWPCr~;)%g9?v}wFQ zD5nG!?MU_d>pmT@XYrYWmu4QcL8vGbqJ`Ww8jLh>n!zhJUu+BX8I|Aaq7xAGc}t2% xe8)mhwtdzvU%vHU3C^?F^MB?g>QU~-L&V!-oomkhmuP1d0|wQB)GFIX{SRe(a;^XX diff --git a/tests/suite/math/frac.typ b/tests/suite/math/frac.typ index 7f513930a6..3bd00eab2b 100644 --- a/tests/suite/math/frac.typ +++ b/tests/suite/math/frac.typ @@ -37,8 +37,8 @@ $ 1/2/3 = (1/2)/3 = 1/(2/3) $ // Test precedence. $ a_1/b_2, 1/f(x), zeta(x)/2, "foo"[|x|]/2 \ 1.2/3.7, 2.3^3.4 \ - 🏳️‍🌈[x]/2, f [x]/2, phi [x]/2, 🏳️‍🌈 [x]/2 \ - +[x]/2, 1(x)/2, 2[x]/2 \ + f [x]/2, phi [x]/2 \ + +[x]/2, 1(x)/2, 2[x]/2, 🏳️‍🌈[x]/2 \ (a)b/2, b(a)[b]/2 \ n!/2, 5!/2, n !/2, 1/n!, 1/5! $ diff --git a/tests/suite/math/syntax.typ b/tests/suite/math/syntax.typ index 7091d908c3..32b9c098c3 100644 --- a/tests/suite/math/syntax.typ +++ b/tests/suite/math/syntax.typ @@ -28,6 +28,10 @@ $ dot \ dots \ ast \ tilde \ star $ $floor(phi.alt.)$ $floor(phi.alt. )$ +--- issue-4828-math-number-multi-char --- +// Numbers should parse the same regardless of number of characters. +$1/2(x)$ vs. $1/10(x)$ + --- math-unclosed --- // Error: 1-2 unclosed delimiter $a From 417f5846b68777b8a4d3b9336761bd23c48a26b5 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Wed, 2 Apr 2025 11:41:45 +0200 Subject: [PATCH 079/558] Support comparison functions in `array.sorted` (#5627) Co-authored-by: +merlan #flirora Co-authored-by: Laurenz --- Cargo.lock | 7 + Cargo.toml | 1 + crates/typst-library/Cargo.toml | 1 + crates/typst-library/src/foundations/array.rs | 149 +++++++++++++++--- tests/suite/foundations/array.typ | 6 + 5 files changed, 140 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c13c648191..f9c0cb1895 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -913,6 +913,12 @@ dependencies = [ "weezl", ] +[[package]] +name = "glidesort" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2e102e6eb644d3e0b186fc161e4460417880a0a0b87d235f2e5b8fb30f2e9e0" + [[package]] name = "half" version = "2.4.1" @@ -3052,6 +3058,7 @@ dependencies = [ "ecow", "flate2", "fontdb", + "glidesort", "hayagriva", "icu_properties", "icu_provider", diff --git a/Cargo.toml b/Cargo.toml index cbe69a05d5..b9ec250549 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ fastrand = "2.3" flate2 = "1" fontdb = { version = "0.23", default-features = false } fs_extra = "1.3" +glidesort = "0.1.2" hayagriva = "0.8.1" heck = "0.5" hypher = "0.1.4" diff --git a/crates/typst-library/Cargo.toml b/crates/typst-library/Cargo.toml index 71729b63a6..b210637a80 100644 --- a/crates/typst-library/Cargo.toml +++ b/crates/typst-library/Cargo.toml @@ -29,6 +29,7 @@ csv = { workspace = true } ecow = { workspace = true } flate2 = { workspace = true } fontdb = { workspace = true } +glidesort = { workspace = true } hayagriva = { workspace = true } icu_properties = { workspace = true } icu_provider = { workspace = true } diff --git a/crates/typst-library/src/foundations/array.rs b/crates/typst-library/src/foundations/array.rs index b647473ab2..c1fcb6b496 100644 --- a/crates/typst-library/src/foundations/array.rs +++ b/crates/typst-library/src/foundations/array.rs @@ -808,7 +808,7 @@ impl Array { /// function. The sorting algorithm used is stable. /// /// Returns an error if two values could not be compared or if the key - /// function (if given) yields an error. + /// or comparison 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 @@ -832,33 +832,134 @@ impl Array { /// determine the keys to sort by. #[named] key: Option, + /// If given, uses this function to compare elements in the array. + /// + /// This function should return a boolean: `{true}` indicates that the + /// elements are in order, while `{false}` indicates that they should be + /// swapped. To keep the sort stable, if the two elements are equal, the + /// function should return `{true}`. + /// + /// If this function does not order the elements properly (e.g., by + /// returning `{false}` for both `{(x, y)}` and `{(y, x)}`, or for + /// `{(x, x)}`), the resulting array will be in unspecified order. + /// + /// When used together with `key`, `by` will be passed the keys instead + /// of the elements. + /// + /// ```example + /// #( + /// "sorted", + /// "by", + /// "decreasing", + /// "length", + /// ).sorted( + /// key: s => s.len(), + /// by: (l, r) => l >= r, + /// ) + /// ``` + #[named] + by: Option, ) -> SourceResult { - let mut result = Ok(()); - let mut vec = self.0; - let mut key_of = |x: Value| match &key { - // NOTE: We are relying on `comemo`'s memoization of function - // evaluation to not excessively reevaluate the `key`. - Some(f) => f.call(engine, context, [x]), - None => Ok(x), - }; - vec.make_mut().sort_by(|a, b| { - // Until we get `try` blocks :) - match (key_of(a.clone()), key_of(b.clone())) { - (Ok(a), Ok(b)) => ops::compare(&a, &b).unwrap_or_else(|err| { - if result.is_ok() { - result = Err(err).at(span); + match by { + Some(by) => { + let mut are_in_order = |mut x, mut y| { + if let Some(f) = &key { + // We rely on `comemo`'s memoization of function + // evaluation to not excessively reevaluate the key. + x = f.call(engine, context, [x])?; + y = f.call(engine, context, [y])?; } - Ordering::Equal - }), - (Err(e), _) | (_, Err(e)) => { - if result.is_ok() { - result = Err(e); + match by.call(engine, context, [x, y])? { + Value::Bool(b) => Ok(b), + x => { + bail!( + span, + "expected boolean from `by` function, got {}", + x.ty(), + ) + } } - Ordering::Equal - } + }; + // If a comparison function is provided, we use `glidesort` + // instead of the standard library sorting algorithm to prevent + // panics in case the comparison function does not define a + // valid order (see https://github.com/typst/typst/pull/5627). + let mut result = Ok(()); + let mut vec = self.0.into_iter().enumerate().collect::>(); + glidesort::sort_by(&mut vec, |(i, x), (j, y)| { + // Because we use booleans for the comparison function, in + // order to keep the sort stable, we need to compare in the + // right order. + if i < j { + // If `x` and `y` appear in this order in the original + // array, then we should change their order (i.e., + // return `Ordering::Greater`) iff `y` is strictly less + // than `x` (i.e., `compare(x, y)` returns `false`). + // Otherwise, we should keep them in the same order + // (i.e., return `Ordering::Less`). + match are_in_order(x.clone(), y.clone()) { + Ok(false) => Ordering::Greater, + Ok(true) => Ordering::Less, + Err(err) => { + if result.is_ok() { + result = Err(err); + } + Ordering::Equal + } + } + } else { + // If `x` and `y` appear in the opposite order in the + // original array, then we should change their order + // (i.e., return `Ordering::Less`) iff `x` is strictly + // less than `y` (i.e., `compare(y, x)` returns + // `false`). Otherwise, we should keep them in the same + // order (i.e., return `Ordering::Less`). + match are_in_order(y.clone(), x.clone()) { + Ok(false) => Ordering::Less, + Ok(true) => Ordering::Greater, + Err(err) => { + if result.is_ok() { + result = Err(err); + } + Ordering::Equal + } + } + } + }); + result.map(|()| vec.into_iter().map(|(_, x)| x).collect()) } - }); - result.map(|_| vec.into()) + + None => { + let mut key_of = |x: Value| match &key { + // We rely on `comemo`'s memoization of function evaluation + // to not excessively reevaluate the key. + Some(f) => f.call(engine, context, [x]), + None => Ok(x), + }; + // If no comparison function is provided, we know the order is + // valid, so we can use the standard library sort and prevent an + // extra allocation. + let mut result = Ok(()); + let mut vec = self.0; + vec.make_mut().sort_by(|a, b| { + match (key_of(a.clone()), key_of(b.clone())) { + (Ok(a), Ok(b)) => ops::compare(&a, &b).unwrap_or_else(|err| { + if result.is_ok() { + result = Err(err).at(span); + } + Ordering::Equal + }), + (Err(e), _) | (_, Err(e)) => { + if result.is_ok() { + result = Err(e); + } + Ordering::Equal + } + } + }); + result.map(|()| vec.into()) + } + } } /// Deduplicates all items in the array. diff --git a/tests/suite/foundations/array.typ b/tests/suite/foundations/array.typ index 61b5decb3f..0c820d7f2c 100644 --- a/tests/suite/foundations/array.typ +++ b/tests/suite/foundations/array.typ @@ -359,6 +359,12 @@ #test((2, 1, 3, 10, 5, 8, 6, -7, 2).sorted(), (-7, 1, 2, 2, 3, 5, 6, 8, 10)) #test((2, 1, 3, -10, -5, 8, 6, -7, 2).sorted(key: x => x), (-10, -7, -5, 1, 2, 2, 3, 6, 8)) #test((2, 1, 3, -10, -5, 8, 6, -7, 2).sorted(key: x => x * x), (1, 2, 2, 3, -5, 6, -7, 8, -10)) +#test(("I", "the", "hi", "text").sorted(by: (x, y) => x.len() < y.len()), ("I", "hi", "the", "text")) +#test(("I", "the", "hi", "text").sorted(key: x => x.len(), by: (x, y) => y < x), ("text", "the", "hi", "I")) + +--- array-sorted-invalid-by-function --- +// Error: 2-39 expected boolean from `by` function, got string +#(1, 2, 3).sorted(by: (_, _) => "hmm") --- array-sorted-key-function-positional-1 --- // Error: 12-18 unexpected argument From ed2106e28d4b0cc213a4789d5e59c59ad08e9f29 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Wed, 2 Apr 2025 13:47:42 +0200 Subject: [PATCH 080/558] Disallow empty font lists (#6049) --- crates/typst-library/src/text/mod.rs | 16 ++++++++++++++-- tests/suite/text/font.typ | 4 ++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/typst-library/src/text/mod.rs b/crates/typst-library/src/text/mod.rs index 3aac15ba58..462d16060c 100644 --- a/crates/typst-library/src/text/mod.rs +++ b/crates/typst-library/src/text/mod.rs @@ -42,7 +42,7 @@ use ttf_parser::Tag; use typst_syntax::Spanned; use typst_utils::singleton; -use crate::diag::{bail, warning, HintedStrResult, SourceResult}; +use crate::diag::{bail, warning, HintedStrResult, SourceResult, StrResult}; use crate::engine::Engine; use crate::foundations::{ cast, dict, elem, Args, Array, Cast, Construct, Content, Dict, Fold, IntoValue, @@ -891,9 +891,21 @@ cast! { } /// Font family fallback list. +/// +/// Must contain at least one font. #[derive(Debug, Default, Clone, PartialEq, Hash)] pub struct FontList(pub Vec); +impl FontList { + pub fn new(fonts: Vec) -> StrResult { + if fonts.is_empty() { + bail!("font fallback list must not be empty") + } else { + Ok(Self(fonts)) + } + } +} + impl<'a> IntoIterator for &'a FontList { type IntoIter = std::slice::Iter<'a, FontFamily>; type Item = &'a FontFamily; @@ -911,7 +923,7 @@ cast! { self.0.into_value() }, family: FontFamily => Self(vec![family]), - values: Array => Self(values.into_iter().map(|v| v.cast()).collect::>()?), + values: Array => Self::new(values.into_iter().map(|v| v.cast()).collect::>()?)?, } /// Resolve a prioritized iterator over the font families. diff --git a/tests/suite/text/font.typ b/tests/suite/text/font.typ index 60a1cd94df..6e21dfd234 100644 --- a/tests/suite/text/font.typ +++ b/tests/suite/text/font.typ @@ -149,3 +149,7 @@ The number 123. #set text(-1pt) a + +--- empty-text-font-array --- +// Error: 17-19 font fallback list must not be empty +#set text(font: ()) From bf8751c06352c305a8132a2bd0a06ced557a3819 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Fri, 4 Apr 2025 10:35:51 +0200 Subject: [PATCH 081/558] Switch to released `krilla` version (#6137) --- Cargo.lock | 37 +++++++++++++++++++++---------------- Cargo.toml | 6 ++---- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f9c0cb1895..8c485ea7de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -787,9 +787,9 @@ checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" [[package]] name = "font-types" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d868ec188a98bb014c606072edd47e52e7ab7297db943b0b28503121e1d037bd" +checksum = "1fa6a5e5a77b5f3f7f9e32879f484aa5b3632ddfbe568a16266c904a6f32cdaf" dependencies = [ "bytemuck", ] @@ -1360,8 +1360,9 @@ dependencies = [ [[package]] name = "krilla" -version = "0.3.0" -source = "git+https://github.com/LaurenzV/krilla?rev=14756f7#14756f7067cb1a80b73b712cae9f98597153e623" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69ee6128ebf52d7ce684613b6431ead2959f2be9ff8cf776eeaaad0427c953e9" dependencies = [ "base64", "bumpalo", @@ -1388,8 +1389,9 @@ dependencies = [ [[package]] name = "krilla-svg" -version = "0.3.0" -source = "git+https://github.com/LaurenzV/krilla?rev=14756f7#14756f7067cb1a80b73b712cae9f98597153e623" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3462989578155cf620ef8035f8921533cc95c28e2a0c75de172f7219e6aba84e" dependencies = [ "flate2", "fontdb", @@ -1837,8 +1839,9 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "pdf-writer" -version = "0.12.1" -source = "git+https://github.com/typst/pdf-writer?rev=0d513b9#0d513b9050d2f1a0507cabb4898aca971af6da98" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ea27c5015ab81753fc61e49f8cde74999346605ee148bb20008ef3d3150e0dc" dependencies = [ "bitflags 2.8.0", "itoa", @@ -2097,9 +2100,9 @@ dependencies = [ [[package]] name = "read-fonts" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f004ee5c610b8beb5f33273246893ac6258ec22185a6eb8ee132bccdb904cdaa" +checksum = "600e807b48ac55bad68a8cb75cc3c7739f139b9248f7e003e01e080f589b5288" dependencies = [ "bytemuck", "font-types", @@ -2425,9 +2428,9 @@ checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "skrifa" -version = "0.28.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16e7936ca3627fdb516e97aca3c8ab5103f94ae32fe5ce80a0a02edcbacb7b53" +checksum = "6fa1e5622e4f7b98877e8a19890efddcac1230cec6198bd9de91ec0e00010dc8" dependencies = [ "bytemuck", "read-fonts", @@ -2522,8 +2525,9 @@ dependencies = [ [[package]] name = "subsetter" -version = "0.2.0" -source = "git+https://github.com/typst/subsetter?rev=460fdb6#460fdb66d6e0138b721b1ca9882faf15ce003246" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35539e8de3dcce8dd0c01f3575f85db1e5ac1aea1b996d2d09d89f148bc91497" dependencies = [ "fxhash", ] @@ -3755,8 +3759,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xmp-writer" -version = "0.3.1" -source = "git+https://github.com/LaurenzV/xmp-writer?rev=a1cbb887#a1cbb887a84376fea4d7590d41c194a332840549" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce9e2f4a404d9ebffc0a9832cf4f50907220ba3d7fffa9099261a5cab52f2dd7" [[package]] name = "xz2" diff --git a/Cargo.toml b/Cargo.toml index b9ec250549..16c6a7d637 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,8 +72,8 @@ if_chain = "1" image = { version = "0.25.5", default-features = false, features = ["png", "jpeg", "gif"] } indexmap = { version = "2", features = ["serde"] } kamadak-exif = "0.6" -krilla = { git = "https://github.com/LaurenzV/krilla", rev = "14756f7", default-features = false, features = ["raster-images", "comemo", "rayon"] } -krilla-svg = { git = "https://github.com/LaurenzV/krilla", rev = "14756f7" } +krilla = { version = "0.4.0", default-features = false, features = ["raster-images", "comemo", "rayon"] } +krilla-svg = "0.1.0" kurbo = "0.11" libfuzzer-sys = "0.4" lipsum = "0.9" @@ -87,7 +87,6 @@ oxipng = { version = "9.0", default-features = false, features = ["filetime", "p palette = { version = "0.7.3", default-features = false, features = ["approx", "libm"] } parking_lot = "0.12.1" pathdiff = "0.2" -pdf-writer = "0.12.1" phf = { version = "0.11", features = ["macros"] } pixglyph = "0.6" png = "0.17" @@ -114,7 +113,6 @@ sigpipe = "0.1" siphasher = "1" smallvec = { version = "1.11.1", features = ["union", "const_generics", "const_new"] } stacker = "0.1.15" -subsetter = "0.2" syn = { version = "2", features = ["full", "extra-traits"] } syntect = { version = "5", default-features = false, features = ["parsing", "regex-fancy", "plist-load", "yaml-load"] } tar = "0.4" From 387a8b48951b0e7e283c81557852e3eba3afb446 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Fri, 4 Apr 2025 13:53:14 +0200 Subject: [PATCH 082/558] Display color spaces in the order in which they are presented in the doc (#6140) --- crates/typst-library/src/visualize/gradient.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/typst-library/src/visualize/gradient.rs b/crates/typst-library/src/visualize/gradient.rs index d59175a4ee..45f388ccdb 100644 --- a/crates/typst-library/src/visualize/gradient.rs +++ b/crates/typst-library/src/visualize/gradient.rs @@ -120,12 +120,12 @@ use crate::visualize::{Color, ColorSpace, WeightedColor}; /// #let spaces = ( /// ("Oklab", color.oklab), /// ("Oklch", color.oklch), -/// ("linear-RGB", color.linear-rgb), /// ("sRGB", color.rgb), +/// ("linear-RGB", color.linear-rgb), /// ("CMYK", color.cmyk), +/// ("Grayscale", color.luma), /// ("HSL", color.hsl), /// ("HSV", color.hsv), -/// ("Grayscale", color.luma), /// ) /// /// #for (name, space) in spaces { From ea336a6ac71ba9d84da6caa5d64291c87b0bca44 Mon Sep 17 00:00:00 2001 From: Markus Langgeng Iman Saputra Date: Fri, 4 Apr 2025 15:50:13 +0000 Subject: [PATCH 083/558] Add Indonesian translation (#6108) Co-authored-by: Malo <57839069+MDLC01@users.noreply.github.com> --- crates/typst-library/src/text/lang.rs | 4 +++- crates/typst-library/translations/id.txt | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 crates/typst-library/translations/id.txt diff --git a/crates/typst-library/src/text/lang.rs b/crates/typst-library/src/text/lang.rs index c75e5225ff..2cc66a2615 100644 --- a/crates/typst-library/src/text/lang.rs +++ b/crates/typst-library/src/text/lang.rs @@ -14,7 +14,7 @@ macro_rules! translation { }; } -const TRANSLATIONS: [(&str, &str); 38] = [ +const TRANSLATIONS: [(&str, &str); 39] = [ translation!("ar"), translation!("bg"), translation!("ca"), @@ -31,6 +31,7 @@ const TRANSLATIONS: [(&str, &str); 38] = [ translation!("el"), translation!("he"), translation!("hu"), + translation!("id"), translation!("is"), translation!("it"), translation!("ja"), @@ -82,6 +83,7 @@ impl Lang { pub const HEBREW: Self = Self(*b"he ", 2); pub const HUNGARIAN: Self = Self(*b"hu ", 2); pub const ICELANDIC: Self = Self(*b"is ", 2); + pub const INDONESIAN: Self = Self(*b"id ", 2); pub const ITALIAN: Self = Self(*b"it ", 2); pub const JAPANESE: Self = Self(*b"ja ", 2); pub const LATIN: Self = Self(*b"la ", 2); diff --git a/crates/typst-library/translations/id.txt b/crates/typst-library/translations/id.txt new file mode 100644 index 0000000000..bea5ee18cc --- /dev/null +++ b/crates/typst-library/translations/id.txt @@ -0,0 +1,8 @@ +figure = Gambar +table = Tabel +equation = Persamaan +bibliography = Daftar Pustaka +heading = Bagian +outline = Daftar Isi +raw = Kode +page = halaman From d55abf084263c15b4eac8efcf4faa3aafdd3af11 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 7 Apr 2025 19:46:46 +0200 Subject: [PATCH 084/558] Update community section in README (#6150) --- README.md | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 41f4651523..9526f3df48 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Typst's CLI is available from different sources: - You can install Typst through different package managers. Note that the versions in the package managers might lag behind the latest release. - - Linux: + - Linux: - View [Typst on Repology][repology] - View [Typst's Snap][snap] - macOS: `brew install typst` @@ -177,22 +177,22 @@ If you prefer an integrated IDE-like experience with autocompletion and instant preview, you can also check out [Typst's free web app][app]. ## Community -The main place where the community gathers is our [Discord server][discord]. -Feel free to join there to ask questions, help out others, share cool things -you created with Typst, or just to chat. +The main places where the community gathers are our [Forum][forum] and our +[Discord server][discord]. The Forum is a great place to ask questions, help +others, and share cool things you created with Typst. The Discord server is more +suitable for quicker questions, discussions about contributing, or just to chat. +We'd be happy to see you there! -Aside from that there are a few places where you can find things built by -the community: - -- The official [package list](https://typst.app/docs/packages) -- The [Awesome Typst](https://github.com/qjcg/awesome-typst) repository +[Typst Universe][universe] is where the community shares templates and packages. +If you want to share your own creations, you can submit them to our +[package repository][packages]. If you had a bad experience in our community, please [reach out to us][contact]. ## Contributing -We would love to see contributions from the community. If you experience bugs, -feel free to open an issue. If you would like to implement a new feature or bug -fix, please follow the steps outlined in the [contribution guide][contributing]. +We love to see contributions from the community. If you experience bugs, feel +free to open an issue. If you would like to implement a new feature or bug fix, +please follow the steps outlined in the [contribution guide][contributing]. To build Typst yourself, first ensure that you have the [latest stable Rust][rust] installed. Then, clone this repository and build the @@ -243,6 +243,8 @@ instant preview. To achieve these goals, we follow three core design principles: [docs]: https://typst.app/docs/ [app]: https://typst.app/ [discord]: https://discord.gg/2uDybryKPe +[forum]: https://forum.typst.app/ +[universe]: https://typst.app/universe/ [tutorial]: https://typst.app/docs/tutorial/ [show]: https://typst.app/docs/reference/styling/#show-rules [math]: https://typst.app/docs/reference/math/ From 14928ef9628d084af370463ccbf2f3bae3f70794 Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Mon, 7 Apr 2025 20:47:29 +0300 Subject: [PATCH 085/558] Fix typo in module docs (#6146) Co-authored-by: Alberto Corbi --- crates/typst-library/src/foundations/module.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-library/src/foundations/module.rs b/crates/typst-library/src/foundations/module.rs index 8d9626a1a6..d6d5e831d8 100644 --- a/crates/typst-library/src/foundations/module.rs +++ b/crates/typst-library/src/foundations/module.rs @@ -7,7 +7,7 @@ use typst_syntax::FileId; use crate::diag::{bail, DeprecationSink, StrResult}; use crate::foundations::{repr, ty, Content, Scope, Value}; -/// An module of definitions. +/// A module of definitions. /// /// A module /// - be built-in From bd2e76e11d487d1e825217db155e45d3fb6f6584 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 7 Apr 2025 20:20:27 +0200 Subject: [PATCH 086/558] Bump OpenSSL (#6153) --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8c485ea7de..ab2d2cc832 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1702,9 +1702,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.70" +version = "0.10.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61cfb4e166a8bb8c9b55c500bc2308550148ece889be90f609377e58140f42c6" +checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" dependencies = [ "bitflags 2.8.0", "cfg-if", @@ -1743,9 +1743,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.105" +version = "0.9.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b22d5b84be05a8d6947c7cb71f7c849aa0f112acd4bf51c2a7c1c988ac0a9dc" +checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07" dependencies = [ "cc", "libc", diff --git a/Cargo.toml b/Cargo.toml index 16c6a7d637..12870b8091 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,7 +82,7 @@ native-tls = "0.2" notify = "8" once_cell = "1" open = "5.0.1" -openssl = "0.10" +openssl = "0.10.72" oxipng = { version = "9.0", default-features = false, features = ["filetime", "parallel", "zopfli"] } palette = { version = "0.7.3", default-features = false, features = ["approx", "libm"] } parking_lot = "0.12.1" From 14a0565d95b40bb58a07da554b7d05d4712efcd7 Mon Sep 17 00:00:00 2001 From: +merlan #flirora Date: Mon, 7 Apr 2025 14:42:29 -0400 Subject: [PATCH 087/558] Show warnings from eval (#6100) Co-authored-by: Laurenz --- crates/typst-cli/src/query.rs | 3 +++ crates/typst-eval/src/lib.rs | 4 ++-- crates/typst-library/src/foundations/mod.rs | 12 +++++++++++- crates/typst-library/src/model/bibliography.rs | 6 ++++-- crates/typst-library/src/routines.rs | 1 + tests/suite/foundations/eval.typ | 6 ++++++ 6 files changed, 27 insertions(+), 5 deletions(-) diff --git a/crates/typst-cli/src/query.rs b/crates/typst-cli/src/query.rs index 610f23cd40..7806e456f8 100644 --- a/crates/typst-cli/src/query.rs +++ b/crates/typst-cli/src/query.rs @@ -2,6 +2,7 @@ use comemo::Track; use ecow::{eco_format, EcoString}; use serde::Serialize; use typst::diag::{bail, HintedStrResult, StrResult, Warned}; +use typst::engine::Sink; use typst::foundations::{Content, IntoValue, LocatableSelector, Scope}; use typst::layout::PagedDocument; use typst::syntax::Span; @@ -58,6 +59,8 @@ fn retrieve( let selector = eval_string( &typst::ROUTINES, world.track(), + // TODO: propagate warnings + Sink::new().track_mut(), &command.selector, Span::detached(), EvalMode::Code, diff --git a/crates/typst-eval/src/lib.rs b/crates/typst-eval/src/lib.rs index 5eae7c1df6..586da26be2 100644 --- a/crates/typst-eval/src/lib.rs +++ b/crates/typst-eval/src/lib.rs @@ -101,6 +101,7 @@ pub fn eval( pub fn eval_string( routines: &Routines, world: Tracked, + sink: TrackedMut, string: &str, span: Span, mode: EvalMode, @@ -121,7 +122,6 @@ pub fn eval_string( } // Prepare the engine. - let mut sink = Sink::new(); let introspector = Introspector::default(); let traced = Traced::default(); let engine = Engine { @@ -129,7 +129,7 @@ pub fn eval_string( world, introspector: introspector.track(), traced: traced.track(), - sink: sink.track_mut(), + sink, route: Route::default(), }; diff --git a/crates/typst-library/src/foundations/mod.rs b/crates/typst-library/src/foundations/mod.rs index 8e3aa060dd..d42be15b1e 100644 --- a/crates/typst-library/src/foundations/mod.rs +++ b/crates/typst-library/src/foundations/mod.rs @@ -77,6 +77,7 @@ pub use { indexmap::IndexMap, }; +use comemo::TrackedMut; use ecow::EcoString; use typst_syntax::Spanned; @@ -297,5 +298,14 @@ pub fn eval( for (key, value) in dict { scope.bind(key.into(), Binding::new(value, span)); } - (engine.routines.eval_string)(engine.routines, engine.world, &text, span, mode, scope) + + (engine.routines.eval_string)( + engine.routines, + engine.world, + TrackedMut::reborrow_mut(&mut engine.sink), + &text, + span, + mode, + scope, + ) } diff --git a/crates/typst-library/src/model/bibliography.rs b/crates/typst-library/src/model/bibliography.rs index b11c617897..51e3b03b06 100644 --- a/crates/typst-library/src/model/bibliography.rs +++ b/crates/typst-library/src/model/bibliography.rs @@ -6,7 +6,7 @@ use std::num::NonZeroUsize; use std::path::Path; use std::sync::{Arc, LazyLock}; -use comemo::Tracked; +use comemo::{Track, Tracked}; use ecow::{eco_format, EcoString, EcoVec}; use hayagriva::archive::ArchivedStyle; use hayagriva::io::BibLaTeXError; @@ -20,7 +20,7 @@ use typst_syntax::{Span, Spanned}; use typst_utils::{Get, ManuallyHash, NonZeroExt, PicoStr}; use crate::diag::{bail, error, At, FileError, HintedStrResult, SourceResult, StrResult}; -use crate::engine::Engine; +use crate::engine::{Engine, Sink}; use crate::foundations::{ elem, Bytes, CastInfo, Content, Derived, FromValue, IntoValue, Label, NativeElement, OneOrMultiple, Packed, Reflect, Scope, Show, ShowSet, Smart, StyleChain, Styles, @@ -999,6 +999,8 @@ impl ElemRenderer<'_> { (self.routines.eval_string)( self.routines, self.world, + // TODO: propagate warnings + Sink::new().track_mut(), math, self.span, EvalMode::Math, diff --git a/crates/typst-library/src/routines.rs b/crates/typst-library/src/routines.rs index b283052a48..6f0cb32b10 100644 --- a/crates/typst-library/src/routines.rs +++ b/crates/typst-library/src/routines.rs @@ -55,6 +55,7 @@ routines! { fn eval_string( routines: &Routines, world: Tracked, + sink: TrackedMut, string: &str, span: Span, mode: EvalMode, diff --git a/tests/suite/foundations/eval.typ b/tests/suite/foundations/eval.typ index f85146b235..85f43911cd 100644 --- a/tests/suite/foundations/eval.typ +++ b/tests/suite/foundations/eval.typ @@ -52,3 +52,9 @@ _Tiger!_ #eval(mode: "math", "f(a) = cases(a + b\, space space x >= 3,a + b\, space space x = 5)") $f(a) = cases(a + b\, space space x >= 3,a + b\, space space x = 5)$ + +--- issue-6067-eval-warnings --- +// Test that eval shows warnings from the executed code. +// Warning: 7-11 no text within stars +// Hint: 7-11 using multiple consecutive stars (e.g. **) has no additional effect +#eval("**", mode: "markup") From 43c3d5d3afc945639a576535e48806112c161743 Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Mon, 7 Apr 2025 22:47:02 +0300 Subject: [PATCH 088/558] Improved ratio and relative length docs (#5750) Co-authored-by: PgBiel <9021226+PgBiel@users.noreply.github.com> Co-authored-by: Laurenz --- crates/typst-library/src/layout/ratio.rs | 32 +++++++++++++--- crates/typst-library/src/layout/rel.rs | 49 ++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/crates/typst-library/src/layout/ratio.rs b/crates/typst-library/src/layout/ratio.rs index 1c0dcd2984..cf826c2b53 100644 --- a/crates/typst-library/src/layout/ratio.rs +++ b/crates/typst-library/src/layout/ratio.rs @@ -8,15 +8,35 @@ use crate::foundations::{repr, ty, Repr}; /// A ratio of a whole. /// -/// Written as a number, followed by a percent sign. +/// A ratio is written as a number, followed by a percent sign. Ratios most +/// often appear as part of a [relative length]($relative), to specify the size +/// of some layout element relative to the page or some container. /// -/// # Example /// ```example -/// #set align(center) -/// #scale(x: 150%)[ -/// Scaled apart. -/// ] +/// #rect(width: 25%) /// ``` +/// +/// However, they can also describe any other property that is relative to some +/// base, e.g. an amount of [horizontal scaling]($scale.x) or the +/// [height of parentheses]($math.lr.size) relative to the height of the content +/// they enclose. +/// +/// # Scripting +/// Within your own code, you can use ratios as you like. You can multiply them +/// with various other types as shown below: +/// +/// | Multiply by | Example | Result | +/// |-----------------|-------------------------|-----------------| +/// | [`ratio`] | `{27% * 10%}` | `{2.7%}` | +/// | [`length`] | `{27% * 100pt}` | `{27pt}` | +/// | [`relative`] | `{27% * (10% + 100pt)}` | `{2.7% + 27pt}` | +/// | [`angle`] | `{27% * 100deg}` | `{27deg}` | +/// | [`int`] | `{27% * 2}` | `{54%}` | +/// | [`float`] | `{27% * 0.37037}` | `{10%}` | +/// | [`fraction`] | `{27% * 3fr}` | `{0.81fr}` | +/// +/// When ratios are displayed in the document, they are rounded to two +/// significant digits for readability. #[ty(cast)] #[derive(Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct Ratio(Scalar); diff --git a/crates/typst-library/src/layout/rel.rs b/crates/typst-library/src/layout/rel.rs index 76d7367853..7fe5d9c05c 100644 --- a/crates/typst-library/src/layout/rel.rs +++ b/crates/typst-library/src/layout/rel.rs @@ -14,17 +14,58 @@ use crate::layout::{Abs, Em, Length, Ratio}; /// addition and subtraction of a length and a ratio. Wherever a relative length /// is expected, you can also use a bare length or ratio. /// -/// # Example +/// # Relative to the page +/// A common use case is setting the width or height of a layout element (e.g., +/// [block], [rect], etc.) as a certain percentage of the width of the page. +/// Here, the rectangle's width is set to `{25%}`, so it takes up one fourth of +/// the page's _inner_ width (the width minus margins). +/// /// ```example -/// #rect(width: 100% - 50pt) +/// #rect(width: 25%) +/// ``` /// -/// #(100% - 50pt).length \ -/// #(100% - 50pt).ratio +/// Bare lengths or ratios are always valid where relative lengths are expected, +/// but the two can also be freely mixed: +/// ```example +/// #rect(width: 25% + 1cm) /// ``` /// +/// If you're trying to size an element so that it takes up the page's _full_ +/// width, you have a few options (this highly depends on your exact use case): +/// +/// 1. Set page margins to `{0pt}` (`[#set page(margin: 0pt)]`) +/// 2. Multiply the ratio by the known full page width (`{21cm * 69%}`) +/// 3. Use padding which will negate the margins (`[#pad(x: -2.5cm, ...)]`) +/// 4. Use the page [background](page.background) or +/// [foreground](page.foreground) field as those don't take margins into +/// account (note that it will render the content outside of the document +/// flow, see [place] to control the content position) +/// +/// # Relative to a container +/// When a layout element (e.g. a [rect]) is nested in another layout container +/// (e.g. a [block]) instead of being a direct descendant of the page, relative +/// widths become relative to the container: +/// +/// ```example +/// #block( +/// width: 100pt, +/// fill: aqua, +/// rect(width: 50%), +/// ) +/// ``` +/// +/// # Scripting +/// You can multiply relative lengths by [ratios]($ratio), [integers]($int), and +/// [floats]($float). +/// /// A relative length has the following fields: /// - `length`: Its length component. /// - `ratio`: Its ratio component. +/// +/// ```example +/// #(100% - 50pt).length \ +/// #(100% - 50pt).ratio +/// ``` #[ty(cast, name = "relative", title = "Relative Length")] #[derive(Default, Copy, Clone, Eq, PartialEq, Hash)] pub struct Rel { From 9829bd8326fc67ebf78593bf4e860390c5ae8804 Mon Sep 17 00:00:00 2001 From: alluring-mushroom <86041465+alluring-mushroom@users.noreply.github.com> Date: Tue, 8 Apr 2025 05:56:20 +1000 Subject: [PATCH 089/558] Document exceptions and alternatives to using `type` (#6027) Co-authored-by: Zedd Serjeant Co-authored-by: Laurenz --- crates/typst-library/src/foundations/ty.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/typst-library/src/foundations/ty.rs b/crates/typst-library/src/foundations/ty.rs index 40f7003c3d..9d76902839 100644 --- a/crates/typst-library/src/foundations/ty.rs +++ b/crates/typst-library/src/foundations/ty.rs @@ -39,11 +39,25 @@ use crate::foundations::{ /// #type(image("glacier.jpg")). /// ``` /// -/// The type of `10` is `int`. Now, what is the type of `int` or even `type`? +/// The type of `{10}` is `int`. Now, what is the type of `int` or even `type`? /// ```example /// #type(int) \ /// #type(type) /// ``` +/// +/// Unlike other types like `int`, [none] and [auto] do not have a name +/// representing them. To test if a value is one of these, compare your value to +/// them directly, e.g: +/// ```example +/// #let val = none +/// #if val == none [ +/// Yep, it's none. +/// ] +/// ``` +/// +/// Note that `type` will return [`content`] for all document elements. To +/// programmatically determine which kind of content you are dealing with, see +/// [`content.func`]. #[ty(scope, cast)] #[derive(Copy, Clone, Eq, PartialEq, Hash)] pub struct Type(Static); From 94a497a01ffd60743b0a2ae67367be168bbde076 Mon Sep 17 00:00:00 2001 From: Approximately Equal Date: Mon, 7 Apr 2025 13:18:52 -0700 Subject: [PATCH 090/558] Add HTML meta tags for document authors and keywords (#6134) --- crates/typst-html/src/lib.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/typst-html/src/lib.rs b/crates/typst-html/src/lib.rs index aa769976e7..7d78a5da44 100644 --- a/crates/typst-html/src/lib.rs +++ b/crates/typst-html/src/lib.rs @@ -263,13 +263,13 @@ fn handle( /// Wrap the nodes in `` and `` if they are not yet rooted, /// supplying a suitable ``. fn root_element(output: Vec, info: &DocumentInfo) -> SourceResult { + let head = head_element(info); 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()])) + Ok(HtmlElement::new(tag::html).with_children(vec![head.into(), body.into()])) } /// Generate a `` element. @@ -302,6 +302,24 @@ fn head_element(info: &DocumentInfo) -> HtmlElement { ); } + if !info.author.is_empty() { + children.push( + HtmlElement::new(tag::meta) + .with_attr(attr::name, "authors") + .with_attr(attr::content, info.author.join(", ")) + .into(), + ) + } + + if !info.keywords.is_empty() { + children.push( + HtmlElement::new(tag::meta) + .with_attr(attr::name, "keywords") + .with_attr(attr::content, info.keywords.join(", ")) + .into(), + ) + } + HtmlElement::new(tag::head).with_children(children) } From c21c1c391b48f843c8671993a28eaf1fe0d40b89 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Thu, 10 Apr 2025 11:27:42 +0200 Subject: [PATCH 091/558] Use `measure` `width` argument in `layout` doc (#6160) --- crates/typst-library/src/layout/layout.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/typst-library/src/layout/layout.rs b/crates/typst-library/src/layout/layout.rs index cde3187d3a..88252e5e3c 100644 --- a/crates/typst-library/src/layout/layout.rs +++ b/crates/typst-library/src/layout/layout.rs @@ -22,7 +22,8 @@ use crate::layout::{BlockElem, Size}; /// #let text = lorem(30) /// #layout(size => [ /// #let (height,) = measure( -/// block(width: size.width, text), +/// width: size.width, +/// text, /// ) /// This text is #height high with /// the current page width: \ From 7e072e24930d8a7524f700b62cabd97ceb4f45e6 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 17 Apr 2025 14:10:27 +0000 Subject: [PATCH 092/558] Add test for flattened accents in math (#6188) --- tests/ref/math-accent-flattened.png | Bin 0 -> 464 bytes tests/suite/math/accent.typ | 8 ++++++++ 2 files changed, 8 insertions(+) create mode 100644 tests/ref/math-accent-flattened.png diff --git a/tests/ref/math-accent-flattened.png b/tests/ref/math-accent-flattened.png new file mode 100644 index 0000000000000000000000000000000000000000..f7764cb74144af9ca15f4a817511bfc5b2b291cd GIT binary patch literal 464 zcmV;>0WbcEP)8mP!bM$Vnv$5`(6eC>tD#OcbrCA+=02DzbvC49pjT#BwPi zkRnWQ(CWaeG`cvo!I#UsF7Ny_-~N=#^Zf@t+wYfWjmit*5Dwvg3BM|zlhfGEmr-)- z>S6x$VnFaWSbd%xlGDg&cLprZlKKvG|04b(82It=_t4%3q^fHka%@+mXI-!RCDZ&Y z<9AsCp9>C8xwpO}7V<^H?69mznqk2gZpM3k1G~eZ-|9wf<~LH0+Jm zgG4b(<}g)=s{TOW2iHAnG8WqlJVDrVrLls}Z>pJX&zO&dL3k2@0n%Q?F zauVA|&|TNRXQDaAjzK4yfaaE{cqvqNdXg>{2)uBny{GNkm5ev1u41XacjSB=PZ?MN z2Ai@5TJg5JHQjpa!+%_^m6_VFYciSfnn-vOAsoUX4EzOh@I@3%PB~xz0000 Date: Fri, 18 Apr 2025 17:27:07 +0300 Subject: [PATCH 093/558] Fix frac syntax section typo (#6193) --- crates/typst-library/src/math/frac.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-library/src/math/frac.rs b/crates/typst-library/src/math/frac.rs index f5c4514d68..dd5986b5f8 100644 --- a/crates/typst-library/src/math/frac.rs +++ b/crates/typst-library/src/math/frac.rs @@ -15,7 +15,7 @@ use crate::math::Mathy; /// # Syntax /// This function also has dedicated syntax: Use a slash to turn neighbouring /// expressions into a fraction. Multiple atoms can be grouped into a single -/// expression using round grouping parenthesis. Such parentheses are removed +/// expression using round grouping parentheses. Such parentheses are removed /// from the output, but you can nest multiple to force them. #[elem(title = "Fraction", Mathy)] pub struct FracElem { From 14241ec1aae43ce3bff96411f62af76a01c7f709 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Thu, 1 May 2025 17:43:07 +0200 Subject: [PATCH 094/558] Use the right field name for `figure.caption.position` (#6226) --- crates/typst-library/src/model/figure.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-library/src/model/figure.rs b/crates/typst-library/src/model/figure.rs index 78a79a8e26..5a137edbd8 100644 --- a/crates/typst-library/src/model/figure.rs +++ b/crates/typst-library/src/model/figure.rs @@ -457,7 +457,7 @@ impl Outlinable for Packed { /// customize the appearance of captions for all figures or figures of a /// specific kind. /// -/// In addition to its `pos` and `body`, the `caption` also provides the +/// In addition to its `position` and `body`, the `caption` also provides the /// figure's `kind`, `supplement`, `counter`, and `numbering` as fields. These /// parts can be used in [`where`]($function.where) selectors and show rules to /// build a completely custom caption. From b322da930fe35ee3d19896de6ab653e2f321e301 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Tue, 6 May 2025 10:26:55 +0200 Subject: [PATCH 095/558] Respect RTL cell layouting order in grid layout (#6232) Co-authored-by: PgBiel <9021226+PgBiel@users.noreply.github.com> --- crates/typst-layout/src/grid/layouter.rs | 60 +++----- crates/typst-layout/src/grid/rowspans.rs | 16 +- tests/ref/grid-rtl-counter.png | Bin 0 -> 272 bytes tests/ref/grid-rtl-rowspan-counter-equal.png | Bin 0 -> 272 bytes .../ref/grid-rtl-rowspan-counter-mixed-1.png | Bin 0 -> 360 bytes .../ref/grid-rtl-rowspan-counter-mixed-2.png | Bin 0 -> 361 bytes .../grid-rtl-rowspan-counter-unequal-1.png | Bin 0 -> 361 bytes .../grid-rtl-rowspan-counter-unequal-2.png | Bin 0 -> 360 bytes tests/suite/layout/grid/rtl.typ | 140 ++++++++++++++++++ 9 files changed, 173 insertions(+), 43 deletions(-) create mode 100644 tests/ref/grid-rtl-counter.png create mode 100644 tests/ref/grid-rtl-rowspan-counter-equal.png create mode 100644 tests/ref/grid-rtl-rowspan-counter-mixed-1.png create mode 100644 tests/ref/grid-rtl-rowspan-counter-mixed-2.png create mode 100644 tests/ref/grid-rtl-rowspan-counter-unequal-1.png create mode 100644 tests/ref/grid-rtl-rowspan-counter-unequal-2.png diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index dc9e2238d4..99b85eddb1 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -11,7 +11,7 @@ use typst_library::layout::{ use typst_library::text::TextElem; use typst_library::visualize::Geometry; use typst_syntax::Span; -use typst_utils::{MaybeReverseIter, Numeric}; +use typst_utils::Numeric; use super::{ generate_line_segments, hline_stroke_at_column, layout_cell, vline_stroke_at_row, @@ -574,7 +574,7 @@ impl<'a> GridLayouter<'a> { // Reverse with RTL so that later columns start first. let mut dx = Abs::zero(); - for (x, &col) in self.rcols.iter().enumerate().rev_if(self.is_rtl) { + for (x, &col) in self.rcols.iter().enumerate() { let mut dy = Abs::zero(); for row in rows { // We want to only draw the fill starting at the parent @@ -643,18 +643,13 @@ impl<'a> GridLayouter<'a> { .sum() }; let width = self.cell_spanned_width(cell, x); - // In the grid, cell colspans expand to the right, - // so we're at the leftmost (lowest 'x') column - // spanned by the cell. However, in RTL, cells - // expand to the left. Therefore, without the - // offset below, cell fills would start at the - // rightmost visual position of a cell and extend - // over to unrelated columns to the right in RTL. - // We avoid this by ensuring the fill starts at the - // very left of the cell, even with colspan > 1. - let offset = - if self.is_rtl { -width + col } else { Abs::zero() }; - let pos = Point::new(dx + offset, dy); + let mut pos = Point::new(dx, dy); + if self.is_rtl { + // In RTL cells expand to the left, thus the + // position must additionally be offset by the + // cell's width. + pos.x = self.width - (dx + width); + } let size = Size::new(width, height); let rect = Geometry::Rect(size).filled(fill); fills.push((pos, FrameItem::Shape(rect, self.span))); @@ -1236,10 +1231,9 @@ impl<'a> GridLayouter<'a> { } let mut output = Frame::soft(Size::new(self.width, height)); - let mut pos = Point::zero(); + let mut offset = Point::zero(); - // Reverse the column order when using RTL. - for (x, &rcol) in self.rcols.iter().enumerate().rev_if(self.is_rtl) { + for (x, &rcol) in self.rcols.iter().enumerate() { if let Some(cell) = self.grid.cell(x, y) { // Rowspans have a separate layout step if cell.rowspan.get() == 1 { @@ -1257,25 +1251,17 @@ impl<'a> GridLayouter<'a> { let frame = layout_cell(cell, engine, disambiguator, self.styles, pod)? .into_frame(); - let mut pos = pos; + let mut pos = offset; if self.is_rtl { - // In the grid, cell colspans expand to the right, - // so we're at the leftmost (lowest 'x') column - // spanned by the cell. However, in RTL, cells - // expand to the left. Therefore, without the - // offset below, the cell's contents would be laid out - // starting at its rightmost visual position and extend - // over to unrelated cells to its right in RTL. - // We avoid this by ensuring the rendered cell starts at - // the very left of the cell, even with colspan > 1. - let offset = -width + rcol; - pos.x += offset; + // In RTL cells expand to the left, thus the position + // must additionally be offset by the cell's width. + pos.x = self.width - (pos.x + width); } output.push_frame(pos, frame); } } - pos.x += rcol; + offset.x += rcol; } Ok(output) @@ -1302,8 +1288,8 @@ impl<'a> GridLayouter<'a> { pod.backlog = &heights[1..]; // Layout the row. - let mut pos = Point::zero(); - for (x, &rcol) in self.rcols.iter().enumerate().rev_if(self.is_rtl) { + let mut offset = Point::zero(); + for (x, &rcol) in self.rcols.iter().enumerate() { if let Some(cell) = self.grid.cell(x, y) { // Rowspans have a separate layout step if cell.rowspan.get() == 1 { @@ -1314,17 +1300,19 @@ impl<'a> GridLayouter<'a> { let fragment = layout_cell(cell, engine, disambiguator, self.styles, pod)?; for (output, frame) in outputs.iter_mut().zip(fragment) { - let mut pos = pos; + let mut pos = offset; if self.is_rtl { - let offset = -width + rcol; - pos.x += offset; + // In RTL cells expand to the left, thus the + // position must additionally be offset by the + // cell's width. + pos.x = self.width - (offset.x + width); } output.push_frame(pos, frame); } } } - pos.x += rcol; + offset.x += rcol; } Ok(Fragment::frames(outputs)) diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs index 21992ed02d..5ab0417d8b 100644 --- a/crates/typst-layout/src/grid/rowspans.rs +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -3,7 +3,6 @@ use typst_library::engine::Engine; use typst_library::foundations::Resolve; use typst_library::layout::grid::resolve::Repeatable; use typst_library::layout::{Abs, Axes, Frame, Point, Region, Regions, Size, Sizing}; -use typst_utils::MaybeReverseIter; use super::layouter::{in_last_with_offset, points, Row, RowPiece}; use super::{layout_cell, Cell, GridLayouter}; @@ -23,6 +22,10 @@ pub struct Rowspan { /// specified for the parent cell's `breakable` field. pub is_effectively_unbreakable: bool, /// The horizontal offset of this rowspan in all regions. + /// + /// This is the offset from the text direction start, meaning that, on RTL + /// grids, this is the offset from the right of the grid, whereas, on LTR + /// grids, it is the offset from the left. pub dx: Abs, /// The vertical offset of this rowspan in the first region. pub dy: Abs, @@ -118,10 +121,11 @@ impl GridLayouter<'_> { // Nothing to layout. return Ok(()); }; - let first_column = self.rcols[x]; let cell = self.grid.cell(x, y).unwrap(); let width = self.cell_spanned_width(cell, x); - let dx = if self.is_rtl { dx - width + first_column } else { dx }; + // In RTL cells expand to the left, thus the position + // must additionally be offset by the cell's width. + let dx = if self.is_rtl { self.width - (dx + width) } else { dx }; // Prepare regions. let size = Size::new(width, *first_height); @@ -185,10 +189,8 @@ impl GridLayouter<'_> { /// Checks if a row contains the beginning of one or more rowspan cells. /// If so, adds them to the rowspans vector. pub fn check_for_rowspans(&mut self, disambiguator: usize, y: usize) { - // We will compute the horizontal offset of each rowspan in advance. - // For that reason, we must reverse the column order when using RTL. - let offsets = points(self.rcols.iter().copied().rev_if(self.is_rtl)); - for (x, dx) in (0..self.rcols.len()).rev_if(self.is_rtl).zip(offsets) { + let offsets = points(self.rcols.iter().copied()); + for (x, dx) in (0..self.rcols.len()).zip(offsets) { let Some(cell) = self.grid.cell(x, y) else { continue; }; diff --git a/tests/ref/grid-rtl-counter.png b/tests/ref/grid-rtl-counter.png new file mode 100644 index 0000000000000000000000000000000000000000..fb0df44ad40da59bfc8ee7d98b1445de8c70d3a3 GIT binary patch literal 272 zcmeAS@N?(olHy`uVBq!ia0vp^6+o=Y0VEjK$QP*tsq3CDjv*Ddl7HAcG$dYm6xi*q zE4Q@*#5lR}$N9$H%qfMZf+Rj4Ul(fcY4Y(nv{N3;QLEU8< zD>nBvMb>BE7Z5$5uXcuyIY=aW&y(;2^ACqNtodcEuP&Tic=DpR>E*9$tG7JM57(nv{N3;QLEU8< zD>nBvMb>BE7Z5$5uXcuyIY=aW&y(;2^ACqNtodcEuP&Tic=DpR>E*9$tG7JM57}9D*9cp;sUck_{3~S`;=krMT5lXeI?g1rAn75JqAY#E}hMh4fD= zC^+cO&ie+z?dM&b1i$a}Y@h83(4ztZ3oNj}0t@U0HjmUE!Qs3;_5Ce_?o#hv!=wuK zihQl6>8f)7fd@FckmckGXtEc_L7yA(I=im`bB7+_HGM<*t? z%Ra)5fn1g$4O)w}(6^5;`niq?aHm*uAOZ_4u)qTUGx!I#s*xWevRUx}0000{9D*9cp+_JMk_{3~S`;=krMT5lXeI?g1rAn75JqAY#E}hMh4fD= zC^+cO&U*)ew4R6jg8F`@_x9R8K7MIFfdv*=V1Wfz1KS6Bw_*L(6&%dA)7;&#x&Rk@ z_i(dBu3e#z<)ml$eJxsL`x#(n-yIyDOLKGq)Y*x8hL5i+;~l~)_#eMvn!z{E@Ka)G z1n30L5h?-DioM_&o+zCM*lYkqw%oz1&0+gc1Ex>vDbMg(yb#gzuYfigH*m4SLgI?@ zB`_Nv%gb{KEU>`;5q1{Wrja8>3}Eg9cC=%BY01DJ(1i=b`6LA)Ji; zbg-xpuJvWo3>eUuuLM37!kDKfHo%SM$bkqfu)qQf{O92>(vXoKDr-mq00000NkvXX Hu0mjfwc?u% literal 0 HcmV?d00001 diff --git a/tests/ref/grid-rtl-rowspan-counter-unequal-1.png b/tests/ref/grid-rtl-rowspan-counter-unequal-1.png new file mode 100644 index 0000000000000000000000000000000000000000..c091f3a806bb3bbd5cc37d2e5372c59005093466 GIT binary patch literal 361 zcmV-v0ha!WP){9D*9cp+_JMk_{3~S`;=krMT5lXeI?g1rAn75JqAY#E}hMh4fD= zC^+cO&U*)ew4R6jg8F`@_x9R8K7MIFfdv*=V1Wfz1KS6Bw_*L(6&%dA)7;&#x&Rk@ z_i(dBu3e#z<)ml$eJxsL`x#(n-yIyDOLKGq)Y*x8hL5i+;~l~)_#eMvn!z{E@Ka)G z1n30L5h?-DioM_&o+zCM*lYkqw%oz1&0+gc1Ex>vDbMg(yb#gzuYfigH*m4SLgI?@ zB`_Nv%gb{KEU>`;5q1{Wrja8>3}Eg9cC=%BY01DJ(1i=b`6LA)Ji; zbg-xpuJvWo3>eUuuLM37!kDKfHo%SM$bkqfu)qQf{O92>(vXoKDr-mq00000NkvXX Hu0mjfwc?u% literal 0 HcmV?d00001 diff --git a/tests/ref/grid-rtl-rowspan-counter-unequal-2.png b/tests/ref/grid-rtl-rowspan-counter-unequal-2.png new file mode 100644 index 0000000000000000000000000000000000000000..fffccc5664edfcd379a237268f14dd21e18fa39a GIT binary patch literal 360 zcmV-u0hj)XP)}9D*9cp;sUck_{3~S`;=krMT5lXeI?g1rAn75JqAY#E}hMh4fD= zC^+cO&ie+z?dM&b1i$a}Y@h83(4ztZ3oNj}0t@U0HjmUE!Qs3;_5Ce_?o#hv!=wuK zihQl6>8f)7fd@FckmckGXtEc_L7yA(I=im`bB7+_HGM<*t? z%Ra)5fn1g$4O)w}(6^5;`niq?aHm*uAOZ_4u)qTUGx!I#s*xWevRUx}0000 ([\##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789], [?], table.hline(start: 4, end: 5, stroke: red))).flatten() ) + +--- grid-rtl-counter --- +// Test interaction between RTL and counters +#set text(dir: rtl) +#let test = counter("test") +#grid( + columns: (1fr, 1fr), + inset: 5pt, + align: center, + [ + a: // should produce 1 + #test.step() + #context test.get().first() + ], + [ + b: // should produce 2 + #test.step() + #context test.get().first() + ], +) + +--- grid-rtl-rowspan-counter-equal --- +// Test interaction between RTL and counters +#set text(dir: rtl) +#let test = counter("test") +#grid( + columns: (1fr, 1fr), + inset: 5pt, + align: center, + grid.cell(rowspan: 2, [ + a: // should produce 1 + #test.step() + #context test.get().first() + ]), + grid.cell(rowspan: 2, [ + b: // should produce 2 + #test.step() + #context test.get().first() + ]), +) + +--- grid-rtl-rowspan-counter-unequal-1 --- +// Test interaction between RTL and counters +#set text(dir: rtl) +#let test = counter("test") +#grid( + columns: (1fr, 1fr), + inset: 5pt, + align: center, + grid.cell(rowspan: 5, [ + b: // will produce 2 + #test.step() + #context test.get().first() + ]), + grid.cell(rowspan: 2, [ + a: // will produce 1 + #test.step() + #context test.get().first() + ]), + grid.cell(rowspan: 3, [ + c: // will produce 3 + #test.step() + #context test.get().first() + ]), +) + +--- grid-rtl-rowspan-counter-unequal-2 --- +// Test interaction between RTL and counters +#set text(dir: rtl) +#let test = counter("test") +#grid( + columns: (1fr, 1fr), + inset: 5pt, + align: center, + grid.cell(rowspan: 2, [ + a: // will produce 1 + #test.step() + #context test.get().first() + ]), + grid.cell(rowspan: 5, [ + b: // will produce 2 + #test.step() + #context test.get().first() + ]), + grid.cell(rowspan: 3, [ + c: // will produce 3 + #test.step() + #context test.get().first() + ]), +) + +--- grid-rtl-rowspan-counter-mixed-1 --- +// Test interaction between RTL and counters +#set text(dir: rtl) +#let test = counter("test") +#grid( + columns: (1fr, 1fr), + inset: 5pt, + align: center, + [ + a: // will produce 1 + #test.step() + #context test.get().first() + ], + grid.cell(rowspan: 2, [ + b: // will produce 2 + #test.step() + #context test.get().first() + ]), + [ + c: // will produce 3 + #test.step() + #context test.get().first() + ], +) + +--- grid-rtl-rowspan-counter-mixed-2 --- +// Test interaction between RTL and counters +#set text(dir: rtl) +#let test = counter("test") +#grid( + columns: (1fr, 1fr), + inset: 5pt, + align: center, + grid.cell(rowspan: 2, [ + b: // will produce 2 + #test.step() + #context test.get().first() + ]), + [ + a: // will produce 1 + #test.step() + #context test.get().first() + ], + [ + c: // will produce 3 + #test.step() + #context test.get().first() + ] +) From 9b09146a6b5e936966ed7ee73bce9dd2df3810ae Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Tue, 6 May 2025 16:03:48 +0200 Subject: [PATCH 096/558] Use list spacing for attach spacing in tight lists (#6242) --- crates/typst-library/src/model/enum.rs | 9 +++++---- crates/typst-library/src/model/list.rs | 9 +++++---- crates/typst-library/src/model/terms.rs | 8 +++++--- .../ref/issue-6242-tight-list-attach-spacing.png | Bin 0 -> 410 bytes tests/suite/model/list.typ | 8 ++++++++ 5 files changed, 23 insertions(+), 11 deletions(-) create mode 100644 tests/ref/issue-6242-tight-list-attach-spacing.png diff --git a/crates/typst-library/src/model/enum.rs b/crates/typst-library/src/model/enum.rs index 2d95996ab4..f1f93702bc 100644 --- a/crates/typst-library/src/model/enum.rs +++ b/crates/typst-library/src/model/enum.rs @@ -259,10 +259,11 @@ impl Show for Packed { .spanned(self.span()); if tight { - let leading = ParElem::leading_in(styles); - let spacing = - VElem::new(leading.into()).with_weak(true).with_attach(true).pack(); - realized = spacing + realized; + let spacing = self + .spacing(styles) + .unwrap_or_else(|| ParElem::leading_in(styles).into()); + let v = VElem::new(spacing.into()).with_weak(true).with_attach(true).pack(); + realized = v + realized; } Ok(realized) diff --git a/crates/typst-library/src/model/list.rs b/crates/typst-library/src/model/list.rs index d93ec9172a..3c3afd3385 100644 --- a/crates/typst-library/src/model/list.rs +++ b/crates/typst-library/src/model/list.rs @@ -166,10 +166,11 @@ impl Show for Packed { .spanned(self.span()); if tight { - let leading = ParElem::leading_in(styles); - let spacing = - VElem::new(leading.into()).with_weak(true).with_attach(true).pack(); - realized = spacing + realized; + let spacing = self + .spacing(styles) + .unwrap_or_else(|| ParElem::leading_in(styles).into()); + let v = VElem::new(spacing.into()).with_weak(true).with_attach(true).pack(); + realized = v + realized; } Ok(realized) diff --git a/crates/typst-library/src/model/terms.rs b/crates/typst-library/src/model/terms.rs index e197ff3186..3df74cd9e7 100644 --- a/crates/typst-library/src/model/terms.rs +++ b/crates/typst-library/src/model/terms.rs @@ -189,13 +189,15 @@ impl Show for Packed { .styled(TermsElem::set_within(true)); if tight { - let leading = ParElem::leading_in(styles); - let spacing = VElem::new(leading.into()) + let spacing = self + .spacing(styles) + .unwrap_or_else(|| ParElem::leading_in(styles).into()); + let v = VElem::new(spacing.into()) .with_weak(true) .with_attach(true) .pack() .spanned(span); - realized = spacing + realized; + realized = v + realized; } Ok(realized) diff --git a/tests/ref/issue-6242-tight-list-attach-spacing.png b/tests/ref/issue-6242-tight-list-attach-spacing.png new file mode 100644 index 0000000000000000000000000000000000000000..48920008b1350f8b604d4e06842d25b282ae1afd GIT binary patch literal 410 zcmV;L0cHM)P)0004DNkl_mzthIoO<(BZ&hpc2JIEVmAj-D~C-E!p%iy#TpKiDY3^`zc26O_^cr~*i$nC=fnOZLAW@u z4gf5kZj1@SXHC5TQ1Mz;cS#N=Rseud8kRTZGrsj6P+jrmwLlbBSYd_#0i2By8sZG5zg4v90VZUCk*?O0PyXO zLgqz2W6G<6ohvO6g%ws<;l_k>sj><9b^1Iucx(V3zlXQ5Ap8jc^$y{Tv#pZ=l-h;i zo0bs(aAtuYIhc~YlkUsne#XQ*RpXI&Z7zisR=8o|U!MOHPD9hDwg3PC07*qoM6N<$ Eg2vyu4*&oF literal 0 HcmV?d00001 diff --git a/tests/suite/model/list.typ b/tests/suite/model/list.typ index 9bed930bbf..796a7b0696 100644 --- a/tests/suite/model/list.typ +++ b/tests/suite/model/list.typ @@ -304,3 +304,11 @@ World - C - = D E + +--- issue-6242-tight-list-attach-spacing --- +// Nested tight lists should be uniformly spaced when list spacing is set. +#set list(spacing: 1.2em) +- A + - B + - C +- C From 54c5113a83d317ed9c37a129ad90165c100bd25d Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Mon, 12 May 2025 10:06:18 +0200 Subject: [PATCH 097/558] Catch indefinite loop in realization due to cycle between show and grouping rule (#6259) --- crates/typst-realize/src/lib.rs | 11 +++++++++++ tests/suite/styling/show.typ | 8 ++++++++ 2 files changed, 19 insertions(+) diff --git a/crates/typst-realize/src/lib.rs b/crates/typst-realize/src/lib.rs index 151ae76ba1..7d2460a89e 100644 --- a/crates/typst-realize/src/lib.rs +++ b/crates/typst-realize/src/lib.rs @@ -655,6 +655,7 @@ fn visit_grouping_rules<'a>( let matching = s.rules.iter().find(|&rule| (rule.trigger)(content, &s.kind)); // Try to continue or finish an existing grouping. + let mut i = 0; while let Some(active) = s.groupings.last() { // Start a nested group if a rule with higher priority matches. if matching.is_some_and(|rule| rule.priority > active.rule.priority) { @@ -670,6 +671,16 @@ fn visit_grouping_rules<'a>( } finish_innermost_grouping(s)?; + i += 1; + if i > 512 { + // It seems like this case is only hit when there is a cycle between + // a show rule and a grouping rule. The show rule produces content + // that is matched by a grouping rule, which is then again processed + // by the show rule, and so on. The two must be at an equilibrium, + // otherwise either the "maximum show rule depth" or "maximum + // grouping depth" errors are triggered. + bail!(content.span(), "maximum grouping depth exceeded"); + } } // Start a new grouping. diff --git a/tests/suite/styling/show.typ b/tests/suite/styling/show.typ index e8ddf55348..f3d9efd551 100644 --- a/tests/suite/styling/show.typ +++ b/tests/suite/styling/show.typ @@ -258,3 +258,11 @@ I am *strong*, I am _emphasized_, and I am #[special]. = Hello *strong* + +--- issue-5690-oom-par-box --- +// Error: 3:6-5:1 maximum grouping depth exceeded +#show par: box + +Hello + +World From 26c19a49c8a73b1e7f7c299b9e25e57acfcd7eac Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Mon, 12 May 2025 10:07:43 +0200 Subject: [PATCH 098/558] Use the infer crate to determine if pdf embeds should be compressed (#6256) --- Cargo.lock | 7 ++++ Cargo.toml | 1 + crates/typst-pdf/Cargo.toml | 1 + crates/typst-pdf/src/embed.rs | 70 ++++++++++++++++++++++++++++++++++- 4 files changed, 78 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index ab2d2cc832..4b70e06bc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1259,6 +1259,12 @@ dependencies = [ "serde", ] +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" + [[package]] name = "inotify" version = "0.11.0" @@ -3127,6 +3133,7 @@ dependencies = [ "comemo", "ecow", "image", + "infer", "krilla", "krilla-svg", "serde", diff --git a/Cargo.toml b/Cargo.toml index 12870b8091..bc563b980c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,7 @@ icu_segmenter = { version = "1.4", features = ["serde"] } if_chain = "1" image = { version = "0.25.5", default-features = false, features = ["png", "jpeg", "gif"] } indexmap = { version = "2", features = ["serde"] } +infer = { version = "0.19.0", default-features = false } kamadak-exif = "0.6" krilla = { version = "0.4.0", default-features = false, features = ["raster-images", "comemo", "rayon"] } krilla-svg = "0.1.0" diff --git a/crates/typst-pdf/Cargo.toml b/crates/typst-pdf/Cargo.toml index f6f08b5bc3..5745d05305 100644 --- a/crates/typst-pdf/Cargo.toml +++ b/crates/typst-pdf/Cargo.toml @@ -23,6 +23,7 @@ bytemuck = { workspace = true } comemo = { workspace = true } ecow = { workspace = true } image = { workspace = true } +infer = { workspace = true } krilla = { workspace = true } krilla-svg = { workspace = true } serde = { workspace = true } diff --git a/crates/typst-pdf/src/embed.rs b/crates/typst-pdf/src/embed.rs index 6ed65a2b69..f0cd9060a5 100644 --- a/crates/typst-pdf/src/embed.rs +++ b/crates/typst-pdf/src/embed.rs @@ -34,6 +34,8 @@ pub(crate) fn embed_files( }, }; let data: Arc + Send + Sync> = Arc::new(embed.data.clone()); + // TODO: update when new krilla version lands (https://github.com/LaurenzV/krilla/pull/203) + let compress = should_compress(&embed.data).unwrap_or(true); let file = EmbeddedFile { path, @@ -41,7 +43,7 @@ pub(crate) fn embed_files( description, association_kind, data: data.into(), - compress: true, + compress, location: Some(span.into_raw().get()), }; @@ -52,3 +54,69 @@ pub(crate) fn embed_files( Ok(()) } + +fn should_compress(data: &[u8]) -> Option { + let ty = infer::get(data)?; + match ty.matcher_type() { + infer::MatcherType::App => None, + infer::MatcherType::Archive => match ty.mime_type() { + #[rustfmt::skip] + "application/zip" + | "application/vnd.rar" + | "application/gzip" + | "application/x-bzip2" + | "application/vnd.bzip3" + | "application/x-7z-compressed" + | "application/x-xz" + | "application/vnd.ms-cab-compressed" + | "application/vnd.debian.binary-package" + | "application/x-compress" + | "application/x-lzip" + | "application/x-rpm" + | "application/zstd" + | "application/x-lz4" + | "application/x-ole-storage" => Some(false), + _ => None, + }, + infer::MatcherType::Audio => match ty.mime_type() { + #[rustfmt::skip] + "audio/mpeg" + | "audio/m4a" + | "audio/opus" + | "audio/ogg" + | "audio/x-flac" + | "audio/amr" + | "audio/aac" + | "audio/x-ape" => Some(false), + _ => None, + }, + infer::MatcherType::Book => None, + infer::MatcherType::Doc => None, + infer::MatcherType::Font => None, + infer::MatcherType::Image => match ty.mime_type() { + #[rustfmt::skip] + "image/jpeg" + | "image/jp2" + | "image/png" + | "image/webp" + | "image/vnd.ms-photo" + | "image/heif" + | "image/avif" + | "image/jxl" + | "image/vnd.djvu" => None, + _ => None, + }, + infer::MatcherType::Text => None, + infer::MatcherType::Video => match ty.mime_type() { + #[rustfmt::skip] + "video/mp4" + | "video/x-m4v" + | "video/x-matroska" + | "video/webm" + | "video/quicktime" + | "video/x-flv" => Some(false), + _ => None, + }, + infer::MatcherType::Custom => None, + } +} From 22a117a091f2d5936533d361098e7483f2997568 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Mon, 12 May 2025 11:16:38 +0200 Subject: [PATCH 099/558] Prohibit some line break opportunities between LTR-ISOLATE and OBJECT-REPLACEMENT-CHARACTER (#6251) Co-authored-by: Max Co-authored-by: Laurenz --- crates/typst-layout/src/inline/linebreak.rs | 25 ++++++++++++++++-- .../ref/issue-5489-matrix-stray-linebreak.png | Bin 0 -> 644 bytes tests/suite/layout/inline/linebreak.typ | 8 ++++++ 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 tests/ref/issue-5489-matrix-stray-linebreak.png diff --git a/crates/typst-layout/src/inline/linebreak.rs b/crates/typst-layout/src/inline/linebreak.rs index 31512604f3..ada048c7d5 100644 --- a/crates/typst-layout/src/inline/linebreak.rs +++ b/crates/typst-layout/src/inline/linebreak.rs @@ -690,13 +690,34 @@ fn breakpoints(p: &Preparation, mut f: impl FnMut(usize, Breakpoint)) { let breakpoint = if point == text.len() { Breakpoint::Mandatory } else { + const OBJ_REPLACE: char = '\u{FFFC}'; match lb.get(c) { - // Fix for: https://github.com/unicode-org/icu4x/issues/4146 - LineBreak::Glue | LineBreak::WordJoiner | LineBreak::ZWJ => continue, LineBreak::MandatoryBreak | LineBreak::CarriageReturn | LineBreak::LineFeed | LineBreak::NextLine => Breakpoint::Mandatory, + + // https://github.com/typst/typst/issues/5489 + // + // OBJECT-REPLACEMENT-CHARACTERs provide Contingent Break + // opportunities before and after by default. This behaviour + // is however tailorable, see: + // https://www.unicode.org/reports/tr14/#CB + // https://www.unicode.org/reports/tr14/#TailorableBreakingRules + // https://www.unicode.org/reports/tr14/#LB20 + // + // Don't provide a line breaking opportunity between a LTR- + // ISOLATE (or any other Combining Mark) and an OBJECT- + // REPLACEMENT-CHARACTER representing an inline item, if the + // LTR-ISOLATE could end up as the only character on the + // previous line. + LineBreak::CombiningMark + if text[point..].starts_with(OBJ_REPLACE) + && last + c.len_utf8() == point => + { + continue; + } + _ => Breakpoint::Normal, } }; diff --git a/tests/ref/issue-5489-matrix-stray-linebreak.png b/tests/ref/issue-5489-matrix-stray-linebreak.png new file mode 100644 index 0000000000000000000000000000000000000000..2d278bd5c9cadb4b26a5f26dd0565f6a4bfafedf GIT binary patch literal 644 zcmV-~0(FAT!-&LSWKIn|;D1$L9BQHvo)N3y(o! zS^G3^ywWxJ5Y)`U>Po{Kj5rF?Ij!(01~NLFa2Ngby2A*Mfxxfs9@;=E=}=+VWfyTy zDLf8Rpj);=g+Xcye&U=~c=84WJ<1lSysCzuK@Zu@B(?CoAF_@h2uUI05ri6KJ>i?Z zaPIuG{t1Zu^VGu81pscAW#cuE;v-~jkKzL$T8L5$;~^CL!D~H2uJmddiVWMpND28$Q z9Fi2@V5|9T^`HX94_n7lygY3NE&yP;c+c?b=?&7-`o^8CFR7l>TLXtnp#%lTCG97n eg|)C29=6|_=gtD%*Z=JR0000 Date: Mon, 12 May 2025 20:12:35 +0200 Subject: [PATCH 100/558] Expand text link boxes vertically by half the leading spacing (#6252) --- crates/typst-layout/src/inline/shaping.rs | 4 +- crates/typst-layout/src/modifiers.rs | 48 ++++++++++++++---- tests/ref/bibliography-basic.png | Bin 7552 -> 7676 bytes tests/ref/bibliography-before-content.png | Bin 17122 -> 17010 bytes tests/ref/bibliography-grid-par.png | Bin 8757 -> 8821 bytes tests/ref/bibliography-indent-par.png | Bin 9096 -> 9120 bytes tests/ref/bibliography-math.png | Bin 4610 -> 4567 bytes tests/ref/bibliography-multiple-files.png | Bin 16310 -> 16308 bytes tests/ref/bibliography-ordering.png | Bin 11795 -> 11741 bytes tests/ref/block-consistent-width.png | Bin 920 -> 947 bytes tests/ref/cite-footnote.png | Bin 13532 -> 13383 bytes tests/ref/cite-form.png | Bin 10863 -> 10698 bytes tests/ref/cite-group.png | Bin 4745 -> 4806 bytes tests/ref/cite-grouping-and-ordering.png | Bin 841 -> 869 bytes tests/ref/figure-basic.png | Bin 7911 -> 7850 bytes tests/ref/footnote-basic.png | Bin 395 -> 417 bytes tests/ref/footnote-block-at-end.png | Bin 617 -> 643 bytes tests/ref/footnote-block-fr.png | Bin 833 -> 867 bytes .../ref/footnote-break-across-pages-block.png | Bin 1263 -> 1280 bytes .../ref/footnote-break-across-pages-float.png | Bin 1428 -> 1459 bytes .../footnote-break-across-pages-nested.png | Bin 1315 -> 1342 bytes tests/ref/footnote-break-across-pages.png | Bin 5473 -> 5489 bytes tests/ref/footnote-duplicate.png | Bin 7510 -> 7555 bytes tests/ref/footnote-entry.png | Bin 1793 -> 1831 bytes tests/ref/footnote-float-priority.png | Bin 1433 -> 1450 bytes tests/ref/footnote-in-caption.png | Bin 6154 -> 6044 bytes tests/ref/footnote-in-columns.png | Bin 1248 -> 1283 bytes tests/ref/footnote-in-list.png | Bin 2507 -> 2541 bytes tests/ref/footnote-in-place.png | Bin 1110 -> 1132 bytes tests/ref/footnote-in-table.png | Bin 12727 -> 12817 bytes tests/ref/footnote-invariant.png | Bin 1080 -> 1099 bytes tests/ref/footnote-multiple-in-one-line.png | Bin 699 -> 739 bytes .../footnote-nested-break-across-pages.png | Bin 1324 -> 1369 bytes tests/ref/footnote-nested.png | Bin 2539 -> 2579 bytes tests/ref/footnote-ref-call.png | Bin 515 -> 547 bytes tests/ref/footnote-ref-forward.png | Bin 1202 -> 1227 bytes tests/ref/footnote-ref-in-footnote.png | Bin 2524 -> 2580 bytes tests/ref/footnote-ref-multiple.png | Bin 4407 -> 4425 bytes tests/ref/footnote-ref.png | Bin 1466 -> 1497 bytes tests/ref/footnote-space-collapsing.png | Bin 749 -> 772 bytes tests/ref/footnote-styling.png | Bin 828 -> 850 bytes tests/ref/issue-1433-footnote-in-list.png | Bin 524 -> 558 bytes tests/ref/issue-1597-cite-footnote.png | Bin 508 -> 531 bytes tests/ref/issue-2531-cite-show-set.png | Bin 981 -> 989 bytes tests/ref/issue-3481-cite-location.png | Bin 500 -> 508 bytes tests/ref/issue-3699-cite-twice-et-al.png | Bin 2297 -> 1857 bytes .../ref/issue-4454-footnote-ref-numbering.png | Bin 802 -> 841 bytes ...ue-4618-bibliography-set-heading-level.png | Bin 5129 -> 5175 bytes ...ue-5256-multiple-footnotes-in-footnote.png | Bin 796 -> 820 bytes ...354-footnote-empty-frame-infinite-loop.png | Bin 2342 -> 1105 bytes ...ssue-5435-footnote-migration-in-floats.png | Bin 448 -> 475 bytes ...ssue-5496-footnote-in-float-never-fits.png | Bin 399 -> 409 bytes ...ssue-5496-footnote-never-fits-multiple.png | Bin 1211 -> 1230 bytes tests/ref/issue-5496-footnote-never-fits.png | Bin 399 -> 409 bytes ...sue-5496-footnote-separator-never-fits.png | Bin 226 -> 242 bytes ...03-cite-group-interrupted-by-par-align.png | Bin 1487 -> 1216 bytes tests/ref/issue-5503-cite-in-align.png | Bin 393 -> 396 bytes tests/ref/issue-622-hide-meta-cite.png | Bin 2470 -> 2429 bytes tests/ref/issue-758-link-repeat.png | Bin 1836 -> 1848 bytes tests/ref/issue-785-cite-locate.png | Bin 9441 -> 9284 bytes tests/ref/issue-footnotes-skip-first-page.png | Bin 524 -> 567 bytes tests/ref/linebreak-cite-punctuation.png | Bin 10391 -> 10455 bytes tests/ref/linebreak-link-end.png | Bin 2081 -> 2051 bytes tests/ref/linebreak-link-justify.png | Bin 12210 -> 11661 bytes tests/ref/linebreak-link.png | Bin 6423 -> 3994 bytes tests/ref/link-basic.png | Bin 6240 -> 5991 bytes tests/ref/link-bracket-balanced.png | Bin 3948 -> 2506 bytes tests/ref/link-bracket-unbalanced-closing.png | Bin 2183 -> 1648 bytes tests/ref/link-show.png | Bin 2599 -> 2563 bytes tests/ref/link-to-label.png | Bin 962 -> 1013 bytes tests/ref/link-to-page.png | Bin 981 -> 892 bytes tests/ref/link-trailing-period.png | Bin 2989 -> 2958 bytes tests/ref/link-transformed.png | Bin 1247 -> 1239 bytes tests/ref/math-equation-numbering.png | Bin 4699 -> 4615 bytes tests/ref/measure-citation-deeply-nested.png | Bin 711 -> 711 bytes tests/ref/measure-citation-in-flow.png | Bin 729 -> 726 bytes tests/ref/par-semantic-align.png | Bin 3082 -> 3104 bytes tests/ref/quote-cite-format-author-date.png | Bin 2131 -> 2119 bytes .../quote-cite-format-label-or-numeric.png | Bin 2170 -> 2144 bytes tests/ref/quote-cite-format-note.png | Bin 2889 -> 2800 bytes tests/ref/quote-inline.png | Bin 1437 -> 1476 bytes tests/ref/ref-basic.png | Bin 4001 -> 4006 bytes tests/ref/ref-form-page-unambiguous.png | Bin 2859 -> 2929 bytes tests/ref/ref-form-page.png | Bin 3592 -> 3561 bytes tests/ref/ref-supplements.png | Bin 8266 -> 8167 bytes tests/ref/show-text-citation-smartquote.png | Bin 403 -> 434 bytes tests/ref/show-text-citation.png | Bin 524 -> 496 bytes tests/ref/show-text-in-citation.png | Bin 795 -> 811 bytes tests/ref/table-header-citation.png | Bin 626 -> 632 bytes 89 files changed, 40 insertions(+), 12 deletions(-) diff --git a/crates/typst-layout/src/inline/shaping.rs b/crates/typst-layout/src/inline/shaping.rs index 8236d1e367..ca723c0a58 100644 --- a/crates/typst-layout/src/inline/shaping.rs +++ b/crates/typst-layout/src/inline/shaping.rs @@ -20,7 +20,7 @@ use unicode_bidi::{BidiInfo, Level as BidiLevel}; use unicode_script::{Script, UnicodeScript}; use super::{decorate, Item, Range, SpanMapper}; -use crate::modifiers::{FrameModifiers, FrameModify}; +use crate::modifiers::FrameModifyText; /// The result of shaping text. /// @@ -327,7 +327,7 @@ impl<'a> ShapedText<'a> { offset += width; } - frame.modify(&FrameModifiers::get_in(self.styles)); + frame.modify_text(self.styles); frame } diff --git a/crates/typst-layout/src/modifiers.rs b/crates/typst-layout/src/modifiers.rs index ac5f40b04c..b0371d63e1 100644 --- a/crates/typst-layout/src/modifiers.rs +++ b/crates/typst-layout/src/modifiers.rs @@ -1,6 +1,6 @@ use typst_library::foundations::StyleChain; -use typst_library::layout::{Fragment, Frame, FrameItem, HideElem, Point}; -use typst_library::model::{Destination, LinkElem}; +use typst_library::layout::{Abs, Fragment, Frame, FrameItem, HideElem, Point, Sides}; +use typst_library::model::{Destination, LinkElem, ParElem}; /// Frame-level modifications resulting from styles that do not impose any /// layout structure. @@ -52,14 +52,7 @@ pub trait FrameModify { impl FrameModify for Frame { fn modify(&mut self, modifiers: &FrameModifiers) { - if let Some(dest) = &modifiers.dest { - let size = self.size(); - self.push(Point::zero(), FrameItem::Link(dest.clone(), size)); - } - - if modifiers.hidden { - self.hide(); - } + modify_frame(self, modifiers, None); } } @@ -82,6 +75,41 @@ where } } +pub trait FrameModifyText { + /// Resolve and apply [`FrameModifiers`] for this text frame. + fn modify_text(&mut self, styles: StyleChain); +} + +impl FrameModifyText for Frame { + fn modify_text(&mut self, styles: StyleChain) { + let modifiers = FrameModifiers::get_in(styles); + let expand_y = 0.5 * ParElem::leading_in(styles); + let outset = Sides::new(Abs::zero(), expand_y, Abs::zero(), expand_y); + modify_frame(self, &modifiers, Some(outset)); + } +} + +fn modify_frame( + frame: &mut Frame, + modifiers: &FrameModifiers, + link_box_outset: Option>, +) { + if let Some(dest) = &modifiers.dest { + let mut pos = Point::zero(); + let mut size = frame.size(); + if let Some(outset) = link_box_outset { + pos.y -= outset.top; + pos.x -= outset.left; + size += outset.sum_by_axis(); + } + frame.push(pos, FrameItem::Link(dest.clone(), size)); + } + + if modifiers.hidden { + frame.hide(); + } +} + /// Performs layout and modification in one step. /// /// This just runs `layout(styles).modified(&FrameModifiers::get_in(styles))`, diff --git a/tests/ref/bibliography-basic.png b/tests/ref/bibliography-basic.png index 0844eaf81ab407044d1d0b527749d44807d691c4..86d02cc697884337c0bc077f6532359da5451858 100644 GIT binary patch literal 7676 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 literal 17122 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 diff --git a/tests/ref/bibliography-indent-par.png b/tests/ref/bibliography-indent-par.png index 02278124b81abe597746918206f3a211949a7e5c..031e289743ec3bbc86a185baf1725649157f93b2 100644 GIT binary patch delta 8914 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 P_GlU^I?A<*Ht_!e-Ez^E delta 8938 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 x+r1P|($I1Wz diff --git a/tests/ref/bibliography-math.png b/tests/ref/bibliography-math.png index 6b60fef4a2cb9f91c71783df66d09c1999e90ca8..6d6d88d53a0d4ba6dfb5b44789df9eba98dae224 100644 GIT binary patch literal 4567 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 diff --git a/tests/ref/bibliography-multiple-files.png b/tests/ref/bibliography-multiple-files.png index 3be3763f49a222b39e2dfef80b8704ea1fdf3bf3..b2e7b0144ae5cd81de570f3c5fcc29bad80c85cd 100644 GIT binary patch literal 16308 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 iss4kUf0Xfw4uOFlFTOCQ@ozsqn6$V8utwA<SZn diff --git a/tests/ref/bibliography-ordering.png b/tests/ref/bibliography-ordering.png index c19b7e7d00d078cad06db5590149caae7593fe89..ad5e86019490b2e659da36cc79dc572832017499 100644 GIT binary patch literal 11741 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 literal 11795 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 BNqPVP diff --git a/tests/ref/block-consistent-width.png b/tests/ref/block-consistent-width.png index 045603cb800ef6260f520b7bc9ee44093f371877..f181bd335b3231559ef800313c59e6eee46aa452 100644 GIT binary patch delta 866 zcmV-o1D*Vs2eSu|B!AaXOjJex|Nnr1fPa5~_xJhT-{+N=pO=}T?(g&a`~3O&`sU~D zb$5Tj!pgqD$bW%}qou8>tFsmuAlKR9*xKSiLP|eDNl{TzQc_Z3VPQ&2N^x;<%gf6* zH#c*0b8l~NZEbCLcXxq-fq{dIgM)*7e0-mupNor&tgNh~qmyF+6D`}@+s)0*x3{ge}|r+ zsGp&!p`)u}WNe9xlg`lCGBi9oIyw^*6BHB_>+9?I`1n>hJ8(aeSUs^xw*Oh{r$7Ev-$b?m45@$>WZ&(F`()6?|y^rE7o zczAfezP_@uvV??$_4W1e@bK8!*xK6KU0q#NR8*dxo`QmclOF*af0dP$o12@Gl9I;8 z#+jL!prD|Rj*hmrwhs>v00004GM&u;00EatL_t(|+U?k7QyXCvhT*61mI`fYON+Y` zJh($}4esu4!Ly46m*5uovA{ye=bhPonSpcvfotY`ICJKJq9{s%KFMlKIk&=2MkAxj z!FG_*@DRc853Z~zf8Q{cf5ERTEUJ*qf4Rj&g;Z-19 zgHY~p$r=#mi?BV;aHW2I!{qO`85m@u1wydAEO&h}>K%k)^~-92PBx07-mLpKiY8A{ z6n%XZzpKC6+cNMx=)W*q_U{&+SYEQ2tnklYPLkT%(yR)ce~=gnPcLsX08CCT_H0^V zhk!@*w=-k)O{&010^qugr)H+Hz4Z!zW~}hfU&b0$f|CV7a4{lRS73OC&o3?wFEIP> ztR9>qilUcM$jQr}mEipYTozm5e=5SM00xa&*$eY?61=yIs}c^dISqjO;ZgG9K!0r= zc1}-r4A&P{RJglKHCUIP0Z)$u^RYf&TT@-7KZE7=M*9M{cXUcH*9GEG#GY&53}-qz sNp73t53ca|cU#}+>qB23ilRP758}-I)ERzEVgLXD07*qoM6N<$g8n$ea{vGU delta 855 zcmV-d1E~D72bc$tBo50^OjJex|Nnr1fPa5~lMn$Le{*wlH#axS%gb?baY{-`Qc_Y; zQBglZNkBqM*xKUP+2Ix#AgQafrKP1JA|fFnAtxs%A0HnmC@2;d7Hn*6t*x!Vz`$Bs zT0cKOyu7@qsHnfczfMk0k&%&=m6e;Do05`}#>U2(nVF!VppK4?wzjqp4-YyzIx;jo z&d}JBf0LJJX>F{nvF`8lmzklJm!I9==ZcJ!qNJ?%_xbz#{H?CCl$Du_i;JJ1pL~3L zf`Wpco}N@xR9#(N+S=OK*x2y!@b&fegoK3M-QD2e;Ej!qw6wH|iHXO@$MW*>{QUg2 zwY9suyV23nnwpxJn3$lUrJ6`Z*OmHZEbgVcY%R{frE>UkC%vuh+bY^va+(izP@;Pc%q`B^z`)8 z)6>t-&-3&1@$vEH<>lk!$l73e6B82v002XOBKMyF00D?eL_t(|+U?loP6I&{hT#t>?(R-; zDZVhcLvd*F;!@mww|H@v%O$12|4q6>Am_P(H@JKKfr zA;ZHXh5ujJ(P6noyRI@Om0n8c z40m=RedjwIpkO!(z>_I1{^a!RGhA1Xj9m_4T{Hln?w*L_=NDHuH1zd0m^0J9!+H6N zh1D^!@bd1`=~cr97iT9`KQ!4{2B3UsP!Ng=3k0F6vV!XXnI3>u^#!;lhNmVcV4-hq hqghQ?A3_LG)(0tUE3<&}loS8}002ovPDHLkV1h_cz~}$~ diff --git a/tests/ref/cite-footnote.png b/tests/ref/cite-footnote.png index dd2cf8bdb7bcc974a7f0a860e7f745310ff5dd28..e89b1dc123bfb2612189dfce81b1e760c7e5e9a8 100644 GIT binary patch literal 13383 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 uX?$D1Q4?88(r`~F80L`qCvv{u+4=zjq2k!T|T literal 10863 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 diff --git a/tests/ref/cite-group.png b/tests/ref/cite-group.png index 02772f499029d5e93c65e7ac3d62aaf4ca2023ad..d512d07e02f04b19962bb82b11b9b4c8f74244b4 100644 GIT binary patch literal 4806 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 literal 4745 zcmV;45_av0P)4T1ggVY>L$&Lb8^oOq9}$?$kkvAcBa5mWD4S?v!C;`* z>ldK`bo(KtfUfL*h2ypBTdC(S=bJ zM;<*&1MOLNT<^Xt$dsdg+jC0q$ny72rL5)ceLlmo%Wsp zO9k}t)iIe&R4NrhpCrq&$KyGk$K$bDtp+0n^xgXp?vY^_x?QneuLCr3VZB^!eB{c} z$Is@WDT?y@{V;U9U9DD&&?hAl37`>05#~~<yEAC`kw75e^?F&B&E;~#;Sixu&uBC*E(Jm0d46%vaU4M7 z|LC(sl4L5CTJnV@91in5kI)!GBQ&-m=okN5@{VR0rdF#V^io-^)_%V~JL+`0(daKb z=JTRN7>DtHL=Z&9yNH*59E1|Y2-;n&DJho4VvE65*-PdjJjJR=o2aP5k~PAi^U{MrBi3liIO38ZK=`x zykNVYd(H}hWkxEM3fJLqsL^N$XhDHMfa5rm$s}1SJy=^8fo8W};&#P-Hxf-rg4XGD zXjyTbJsuAM{YQhrz~yqG>p*C3x7#vHCDa|4sYRS?NzhuYHl0qp-EOEzd=(MYe?yN&foQWCU6q2S9Yv>&KG2S}IW#l}IF}pbrm! zlwa@OyhShHUa9RhTCI;Zo2~cd+oicH^9xt!=Bdrn_Hoc~9q6M&p%A7=KA$Hu`XpH_ z7RgenR4N_c#}gu@(m#U6F^|XNG6Rdx=PMKn1T+x=O+XXQ< zY!c9Nzkah3&R{U~dOg@XxaQ8MuRGN`7Dq>8{Kb7g==M0~S>aW=icL82WHKqTcQ7r; zDUnE|QmGKof^Z(KR%xAar}5ow)|aT%xw<-pAXBm^Lo=g5jYX^2R>X}84AM>=FH=DTMNkF|c`Fuq8+B|D zv}=L-0uyxTYHSf_Vya<3d%N4aoa5m<)R>raU3p@T_oL^-|GCe7uIqpOuj`75LE*Sy z!2*s8m!uMONARjp=#Hn6N<`jh6VNZfYYWhi3>uoSyP%)|N)y$R8=xWnA(hU5d~rxp ziF3nGq|{@5a9$xA#3R`uqROu?x_fBDTUPv7HZP9Dmvw&7HZ3 zkB{ev6rcx^latr4U$04}Wy_Wkc@H9$)`tEjY~4mkrHdCY1qKE}D*fzLe@La}zX&QV zJ7Y^KkwYMv0I8Iln=5CulLZ6>SQk0L%gf8H>n$xULMjatXmYjgNu?b-b`%yC3eaK# zv;ZykoPg&2MgSfG=Z zy%tpe*Jj0O*t&PimoJAzC9O_GPKYl+40nB&Kqvfu|K5Z@ zD4^N?0R6_+w?P@WIYUgK2St()Bdx8iT-4UqLYaCRX!bJVBTXv7-dRZ{_@mV!p^!># zZS5;otbkOSJ8vpgPw2e)kJelmDm2#qi7#C1?P|vE`5cZB0z@=>V z$kF`x#C=qR!}GBS<;0&gH8mqfjL6H&gXV?<5ul$KHC43hA#G>{nk+3B^tU6=@`IPA zn?UdXPyX{umo!$~2( z0R32KKw}Lqs;jFvZ{F+^&~LpH2g(=zdC3IY)JLOM<#7?92N1r5hlgve^!V}P=|f`+ zG~)B`jhVm;5TFs`_V(UPKbRfy`kVQuPE*oi0{!5@gHfYKq4$8!rd4S}LxTW40AY_% zT&J=qr9%2|1sWWWX6NqPpL+6C5&13hEFXMORZ;mND}0XUo@9e-pgA$P4+&X>(&QoK zj2_wmnjASw1Zx!{+jh_*j{>h#30Q_>bb@AslV%+l%I@8}S5#C)7b^i;On?@k#hyLT zwjxAW3%qQ+5jSTvq!OYh0eT3VJ$p8te5_svYA!C6su6O-3|JsQa`TFj1m)r6J7#d$$?b*ZCvvB2~$jd$df+J4@O`y!PPlG|9NF^#~6@5By!A~84rghDdLP7 zGXTO@Qi-DQGu4gGKrdaow7I#N9#`wut@AF>lxuf&brIL8x_h61<~(7#6Eui3&{{Hy zESTOPUIZHIhfWV%sNpHFZk!>uK%<5pJ8qH!nnLAUw{B-;pWOQ9E`Z*=CE5fUUetW) zH8wVKbf_4e3rhRF2sDyW4rom((Vi4$&X!cl$jpw}{kz-~ry!LWY*SHDSy*&>cWfM= zl+=SJsdVGU4g5UqbxDc4)jHUw?F1Y|bgwX{Ffq>QPKwF~&7KY3Y z3Rnt@E8pMur;q-2Uir}!lv_z9Ts?-3Kq@i3lifRKc2J5IRvMe?(*f! z#P{@*@Gj7t7*v6y;G*$sM@NURq!K+27%}7wH0AspL8x;mr1dV)Y-jS^jP*s}0W0kj z(9FW&nC8MIsRR>CzX~q`jh2}Vi0&srDunV_TcA0nHSy00@a30x&?q4Z`$x;C+oNLv z+T=Rg+S*LlMYWfu!wp*-NsSkQM%xHYtVt!ZO(Xy}iBSN86K1rY;S#0zL7EGZfHpMtAStZjws+nWs z*9Ieqc2a=;#*_pasF_pgSFT*C1WNGwO!j4nuO4`#jla@hkYl&I>83<$ zK{!09L3k?x`mrI_qQfOLo^@7MWmP>*z-VD$3$z-96SeIfgwm!ej1goD7cNwFUF>lI z`myoILqpG+HH*_=$PrH3bd|Dyz-sDh2LU~4(j-dfID*>S+Xd*y#5|*XVUXE_wEuDd zx~uDI-2aWF6x=_HWDYlM-17FWcWrkxwf57ePe(^8Ko3w_qOvM*{ETVybw~3lE~#1( z7-9=F77`LdKM=12O*0>6WhqTG%H`3cM+3JB^e1hdYk$2lY<&c8MJv#p8^{g#mban_ zG?{_1W5-fIz#~HI2z(Eo7&0&31sZ*=cEWHHP?+O;=n0))ef6NH=Xz~zJ)fI5KexJf zd|O^#dG%_Ka{lyxBv-(#@bL6tPHs*vQT*t^#;dHZu1@WAmqvdGdJNJiLIM^ajP@lY zQc-ygxHdK{5_G!C3eaK#v;ZykjDe?&;*J~h|OmAG^xa< zM(XLl=-xC|=p+%mDimqD6==;dF)D?Om9ZmRyKA4HvTzC=an3xz9m=KH8=3sa`TK$8iuLjiCoqHP4x>$za z#&bYJ^n`_lscXp0F=NIc`nLrdI`li=9oy2{ZUwsWqh_`y_X26A3KQtk(o*hBu@DM&-BAy?C1801G~GOWMTCamJS6}v0gGFMA>!cnRi=**hs!MU z48;U!0b1;t0d0Gv;wo(4zI_;^5~aw*LDm!u?^Q4MRMJKkoDi9`p4ICh^{4#k)(fBQ z-jiq>uw4K5Eyh=nEYCUqXZ0;{rq$~p>|_=#yb1+zgp^(cnjVYvAdQWUbs&}Kyk^dv zHLarZY{rojj5@TEO4`g>v;?A4r%v_g%vtyc8VnAbRD$b3Id<^yF=wEO5BX7PGf0Db z&jAe|uV&6N9)#m~c%;&~rV9sBGG@-6f9?9`&O4f8n$Bai=R|$#Eui=8*+T+biD@Du zBfll7bhf6^0cbsomXYD8Og#iN+iKIMOv{+v(})!^6XdhKAD9*;ra) z-rwcL#l_*_;eXoN+K-Qq+}zx=v$NFH)L~<5PEc61wY$#F&dbf!NJvPaprAxXPPe(i z?(XpK@9~h3kk8TCFfux-th8ceY@nj578oFljg|25@b~xkm6es8oSaHZO8WZxot>SR zmzSKLrt0eIgM))*XK(QE@L6jhm(ie8VWz^bot`- zjtPfjyQs24^M%2&ce19WvoC;`jG76tHq)$ delta 818 zcmV-21I_&92FV7HB!8+`}_R*`ug|x`SER z@9**M@AK~N@a^vM>FVz4>gwp}?dj?1=;-L?=k4L);ojfn-QVZj-saof;@aHg+}zyR z+vD5Y+t}LT+S=OK*xT0E+t=6E)z;e7)YQ_`+0W70&(F`y&VSa<&d$rt)y>V#$;;Eo z%F@Tk%*4jd#l^+K#LUCP!@$DIzro4Azr(z~#<#h_xw*NvxWKiyzqhxyw6(jjv%9mi zv#+nOs;soDtgxx8v#6@Fr>Cc-rmCNzsi2^sou8+jo}`?frk$OgoSdAQo1>eXo0plP znVFfEn4p-Ln17X*pO%-KmzS56mY$ZDmXwv5m6er|lbeu|nvjr?kB^Uyj*^Rwm5Pj% zi;IhiiHU}Ykc5VghK7cNg^hxPhl7KIfP#vDfr)l_fOmL&b$5SucYAbod2w@ladLKU zaCmBLbZcyIX=`(6X>Da^Z)0U`VPk7pT4GmOVO3XOR)1DjP*Pe?QCUt{=5Iy^)&G(0deIu;lpKJw%&00046NklcZ8jeRej(gNJKjrLtT@cL7Qzc3dMrJ^MFM{DVH^pC^*GiP z7Pd}uVe^aKD}?b`hiJZ$E*#fP#ULKz_i*M~6@M4Tt*3Fegbo;_567EKCL@EE&9HFa zzVEFCU}#47T9`v8k|YiKvob!Mof7)v+AFpo*4>L2>B6etix4djl7yMO-MxGj zP^yG&LR9prdi5);Ci4fmZO-J0?s7G7)SV|&Bys(yju9OWC?G=A9T*K<*af`GH28rc w04w4wk47F4mjif)jlT}a@REFdWwL+i6P{~FK%j2VrvLx|07*qoM6N<$f&*K|2mk;8 diff --git a/tests/ref/figure-basic.png b/tests/ref/figure-basic.png index 69388755f537155b7bbeef92c17046453f3eeb79..eed77cc3ab5f12182e8d569721b74665e9d667bb 100644 GIT binary patch literal 7850 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 O0000C(~w diff --git a/tests/ref/footnote-block-at-end.png b/tests/ref/footnote-block-at-end.png index 09880aaba44519f4a2cfff67b3e29ad71e9a7efe..15d0bb59325836de2e82028f619f8fa857fb2948 100644 GIT binary patch delta 598 zcmV-c0;&D!1cL>TBqJ|SOjJex|No1Ni}UmI{{H^!>+Adb`&Cs{>gwu3LPEH>xRF5^ ze@IA3G&D5s?(W;$+j)6;Zf{pEWMpK}(9pcRyorg4q@<*^wY6wyXvW6IX=!Pb zlaulB@jgC2Ha0fF!NJwl)s~i)+S=NFe|~;0E-v`^_~qs0b#--ZZEeWN$kWx`Yi)IU ze1y-@*@cIXtgg0%hK`SsnYFjSuCTa;g@vi9sn^%nCMG7>+T!Tw=$Dt5>FMdt(Aa8h zbnNWx`uh4$Pfu1>R{Z?@tgNir+vD@|_WS$%_xJhc=k4zA^WER)0001Wxc3SGe*ggw zNkl41m9i+Qkq%J_+-Q8WNx74uN<^HdMkZ?>wLgq)l=V;E}WOo0V zWI#mJ;JSklv9P%X5s~d3Nbs%i?H)ovWMlu}2#VU#Sd4WKwSs;ay!q#Lab*e}6T@4A+M1y1Xfgi0V>72zQ={h=_=Ys4DFhp~e(9 zTu@ePh;YT8k8dA?9p!a=aw2Rle3wL^ydDiXwz=^A1OC!yr^-ryTA@xG<8$-IzVYw( z+G=8??V}WS2C=wgDjdLUES>;L;l2T^EE@{HzrM^&PmRTf`)#YIH~ReKC^tGz>IX^z kz<-m^aW0?d@lDi7iZe`E7A}veg8%>k07*qoM6N<$f&fuddjJ3c delta 592 zcmV-W0+9<3>bSVLtgNhHU|{w2_4fAm zo}Qj?aBz){jeLB3l$4a|>FJl3mw9=4G&D5s?(W;$+tk$5ySux$x3`9dhPt}Ce}8|N zn3$!drAkUlZf-va+(DpP#C#s)vV%zP`S-wSTpwq@;Cqb>-#dlarHa zX=(BC@jgC2XlQ80#>Umv)xp8RHa0evmX_Mu+J1h1E-o(k`1oyYZOF*T-rwVJadm2J zblKbE>g(^=+Tv?%b<@?|yu7@LiHT%nWYEyi!o$aokCUvfwuFX`kCB;re1xvBxS*h* zg@uKwsj2Mj?0-*BPgYh|*Vor3CML_v%g@o-=;-K3NJ#qn`uzO-LPA1SRaN`@`|j`a z_xJhT-{$}T0C*v>(f|Me_(?=TRCwC$)WwbhQ4odUGjpNo9%Ni#7K;i%=0=UV(VL-OHWWnH*0**b&x-(iyE%w! zj|FhB|IFJaBC2DDN2gG6a(o6A_WS}Wex6cLVXb9YhVT3QDMUon+zLv#c_JbrA|j%y zv{r-~Gh8?c^6zegnerST>W@H5{yv3lc^(LRu3Y%x5g(!X1>=M|oN$|qiODIMu(f!9 zo1IGxwn=oB!rmyZZ=}Lm%p~IppcL*M!PTWu`1k8`dVFj++1KSd-953_mmDOSe;5M* eX`VU})srGA4NMarQ{y-Q0000E-w4~`=FqpZEbBoZh$meX>!HpOoN$hy4f2qH+0GBAUHvI&+$!$oZQ^pwY9aj zwzk5;!g0gn2oDi;$?K@9vZSW2sjIWHw7ig#nr5xhK4^eoqkqL!k+5Z}&SaG4`1tsimX>~ge%jjF zHa0dsK0YTWCx74H-`?KdiHV6yN=j5zROaU9=I8C*-{gwu~laulB@xj5t)z#I; z#>Qx9XpN1H>+9=$e0-FYl=b!XaBy&LaCpYZ&~LorHh)@gG*@ddQeYrAKwF-;jgObP zyTi%L)2^_%@9*#W`ugnb?CtIC&(F`Nr>Cf>sMpum{9dxf0004WNkl#+=>CfWu+xT5QK$Be_=0;@$$BwGF^5=YmQ#WnUl$+qaDwwDnBw9^CVw` WMRaF6&GO{{0000+9={jg9f~@n~ph z_xJg~z{LCe{Pp$s5F8-u?C?H5KI!S{J3Bj2ilOG_=2TQv-{0Tf-rmd0%V}w8#>U2p ziHS-|N}8LWX06e5b$Qj++Hbt#Hd=2qS8G0KfMBD=Wo2c}&410Co13kzt!%p4o1CDS znVp=TriqJ_Vq|Q;!O32r!AN_RRgte~u+((P>Se3WRgthobdF4coz>OV!NI{cHa3=) zmU6}BOoN#qI6+&UxEn7wKW>0DT4{WId~k4Zl$4b9_4Rko@LQd?X|~j0rNL{s*8>Iz zc+c@5H$ZN^-G5`N$bNo)+S=OS;Ns5C(5|q!jgOZwQeaJnoZQ^pb;;|swY8|KvZSW2 zvb4OZtFx}Iu7!n#ii(PFZ*K?>5ul);ZEbCF!{frj!l$RFwzjsAkdR17NYBsD`1tty z`}-~~F3`}>CnqO(czEyc??XdFjEszOa&oGws_g9S?SJj<_V)HtQc{|ln(FH6larI5 zpP$*;*~rMq=;-MB`uf<|*l}@j{{H@`sHoT1*E!v{jsO4wRY^oaRCwC$)>Tu2K^TVN zXFvQT#P05H?C$RF?!dwZq*H&iGaPx~aKRl;>~PTMAgkT7^?QCtb zqGGv4*Tg~uzhTjAeu2p)a$ARvZ#X_>V36gPmbG^_^FDFwP_-PuJIsdr1%#L8W~N5^ z1cVo7rzaVU3g8-MKTw#$6s9nRDa<`gUur@KiGNgTyY<}&{yY{NJZa$1aRVR~{GA>_ zjFV%C;WU^4mmmB+KHD8m8aE*%l6(e*(D@3AF6J+ZU0d~-%-~PjSbur7iU{xT!RHkb zK0FW?(Xg=D1Rs-A|l({+tbzEj*ysj zcYk_(gw)pFK|w*Msj=$o?;s!`nw+G`%hOj`VYIZg$H&Kui;JM5s_X3VzQD-0xxvK7 z&aSYyaBy&Ze0<8x)KXMjqou8ZgNwt(%|1Rp)YjcLHa15`M^#l-Z*OnV(9p@r$%%=H zYHDiH(a|+EHGj3YznPn(l$M^ewY`>>mfGCp?d|R5<>kuD(Pd?2Y;0^?US`qL+uYvf zhKP_~VQHeItk>D$c6flx%+jo`wu6O@!oE=EB0l%gf7YX=zAENE#X%S65e}qN2LGy1>A|wY9Z}hlhiMgL89pnwpx<&d$`- z)QpUbprD{lO-))_TAiJpc6N3qCMM_S=VoSRfq{W*Yip#Wq+wxUySuwMI5?Y|o0XN7 z`T6;)tADG2fPl!z$lcxD;Nak%o}Tpd^uE5nZEbCtnVF7`j-Q{Ot*x!=>+7bbrjwJC z+S=Oc>gxFT`0VWLoSdBFt$*kArEhuRWbR4w|DAomXLje#%svc+ z5F*IEbb;de3nwThq^tzPP(jh^5z2t`3wK!<;$*ng8|b#IOrxO|CFCsUVm@XG%Y%gUuJSoSR;gb;jx{6gS( z{Xz(f)HK@vA}P3h0V%j*A=1+*!PUt4%6|x+KM(lEnOwx+=_#Dx)VzF3Fm2_M9W$^@ z**|)~P#5Ru1BEd;78G;hIKeRm7Bd)JnVGRtu@Kka4+_V#W)B$LPaEB!Xt{M89G!h& zc=WKV9~2!@Y#~H&7)K&+UI-zC5JCtcgb*UcjrxVa@%bemCN#u@!?y+;O>05%wv&Ft|Qud!%4WjgO2Bx7$Cb&qRBoqdaaXmU!xH!{&9q zG#3=pPM*M~n)RtrZEp2;VB-eTgEm3H_ml{65Ra@7$F4ve)gG@W)XsEJ*FM@Ak W7Sbbe55#}~0000gwwC_4V}h^!)t% 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 diff --git a/tests/ref/footnote-break-across-pages-nested.png b/tests/ref/footnote-break-across-pages-nested.png index f87658ce8697ddd422869029b0f687191bc62108..79b09cfb9ff5de59e4081de721311ebe15255b0a 100644 GIT binary patch delta 1315 zcmV+;1>E|h3cd=EEPvhK=kD+Gu&}Vt&(HVw`RnWJC@3iX{r#n-rSkP@z`nk|ySuxrtgP|z@r#R#&CSi$*49u^P>qd^aBy&wlas%{zqYov zX=!OVI5_6#?PzFd$;ruETU&g5e66jml$4bB_xGfxuCB1S`+xiVU0q!uARzPe_GV^g z>+JBZuC7K#M&{<`A|fK<;^MTlw49urx3{-TOG}cHlFZD^NJvP(!O6qJ!-|TEhlhtu zOibV3-`UyOgoK1XK0eIO*2Bfk%gxo()6>ez%F@)_czS}6kdTj$kA;Virl_!~tFx-A zs&jL5N=i!U>3`|5v%7qLhTGlcLPSi6h=|3<&+P5-9v&WFUtgG*nAX_f*xKS%S6{ii z!^q0gy1c~X<>}el-i(fxjgOa?nW4_m*xujcUS3|s#l<8fBrq^A#>U3g)z!hl!8SHF z+S=NFetxH^v2%5Oy1Kf1dwZRooz>Uhyu7@qsHoD?(tps<&^b9dE-o(k`1l$c8dq0W zmX?<7?d?1~JjBGrVPRo`fq~J{(QIsNSy@?tfPk5qnQw1z`T6-}Wo7Q}?oLild3kxB zo}Q+rrYR{YprD|utE;uOwcXv_udlDTxVSMfG1S!5adC0j*w{coKw4T_TwGk-+}wqQ zg`%ROgMWjAFE20l_V$d7jFpv@pP!%J-rnTovxmg1c(UlYr5wQ`AAiEc1D`uzcp2*X#R2#3+Mrxk zh5>=5&)hcA@cAaUn)@JO68akfMSSSY+E7fsC);kE1VtV$xB zlM7VKTwH?T>A^DLfXE;Z+Ob}z;~mqr20H5oAfazCb1G!K?}v!%rFKZTdhLc45~97l zCURor5}}Dn{*Vw7Jk|mc*7SDBSl z#gQBi5$zR~92gOi6F-IUlP4k~A|fIpA|fIpA|e{5A*bLv4yX9V&@kBM1Q{S@bu|Oijb_qeq}z zTw8}js)-PIxzj)?5E2!w@qoPUxs24eI&ou|F(owM0e`4#Hz+_5G5U9rT8SLWc0 z#EHk{&Xx)5G)*pi>a5WVARsyhqtYN?@CUy4Fswp()uxR`2q+B* z3FzVDqgxvv85Mi8ByT=cYkPsBTZVZ;JMRAGN#Q3#VMoR7J7(*rUZ`dVU>vt|-9+@y Z`vp*jBF^;uHhcg8002ovPDHLkV1imZ)cODb delta 1288 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 literal 5473 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 Y%`*Qoxv$Z{t5c$f8hYv#_n(IT7n4q*5dZ)H diff --git a/tests/ref/footnote-duplicate.png b/tests/ref/footnote-duplicate.png index b5a73f74b8dd39cce61113ea61ca3f22144a1ea0..e95228e48cea321b96de52c872c2d2eeaa010440 100644 GIT binary patch delta 7121 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 diff --git a/tests/ref/footnote-entry.png b/tests/ref/footnote-entry.png index dd09acb92e86b61c689976c95e8263beb4f778cc..9e0108f883cda4e11ab6a4448556f51eec265919 100644 GIT binary patch delta 1816 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 U@jJ2cy#N3J07*qoM6N<$f{#`dI{*Lx diff --git a/tests/ref/footnote-in-caption.png b/tests/ref/footnote-in-caption.png index 79b2b5d0f955479b46cdab66ffcfb4f936beb893..cd4f837bb49d21aa9708ec6661488829ef04070f 100644 GIT binary patch literal 6044 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 diff --git a/tests/ref/footnote-in-columns.png b/tests/ref/footnote-in-columns.png index 281ec8836b441f0e3ee4dd266dd49e70f773cfae..8b5f1201dde28b2c9bce100837d1d18084fef55e 100644 GIT binary patch delta 1263 zcmVMc6N6A`~36s_F7t6(9qDz%F4XFysoaUjg5^nGc(uM*X!%+R8&+$ zLqo~Q$?xy)kdTmqf`UXuL|0c=j*gDy<>h>QeEa+RY;0^(Q-4#ImX=^(U^h26r>Cce zhK6%l$4aRw7j38 zslvj-ot>S?$jHph%%rBSkCB;_mY#2KZ)9X-y}!rL(b>4V!d+cm)79P3(%NBTYqq$+ zsH(DxjFenlTz^49K^+|(8X6jhhlib?r+a&QrlzKbh>)qPv#zkXn3|%Cjg`j8(0Y7? zNl8h8fq`;#e8k4ib$5T7oTQ6@FIW@ct|baW695Mp9t=;-Lh#l^wF!O_vtPk&EOxw*OE;NV6^MuUTctgNiI zwzh6=ZiZ+=$ zd3kw~l7EsYC@A9M;?>pFq@<*roSgCT@%Hxi@bK^g0s^V2srvf*?Ck8QsHm^6ug}lV z{{H@mh=}+1_gq-t?*IS-kV!;ARCwC$*XL6cK^(^MXYW!W5UCLhprBZ23JMmmfCYQ+ zz4zWbsMtG7vCu(E;6eiN{yT?PPUhZtyZXDyuz#QT-+AWd?sjG&Ns>gzu7`nG9RmOm zySp1yy!rXseec@KS7LGJHg;_Tm1J~iHE#k{+E*2F-zkcJ^ z5Z$n=r1Yv*JNm@cO`V#HZaAr=)S=xQ9e&i%)&>%-T0gL)B+Xyn9?Y8;uD+S!bAW(t zv41Zs8%uq;2xK_2e5X&H$xM=`j3h}Cnb%>8#%S#pbUlnAyUx?dgNTaT?S>^3MTyGcw}H?|n5)^ec_Re8 z`=N)Xay3;IMy|8cAiFX_;Y<@ zYD))CBRJev_sP!JD2gIaF7p!H?gW^msw#vrMPVRf{cvmGu7{mGK|Y-4^w*qY$Bt7E zH@5&d%c_o24_`PB0Bq->c+m2;Tl8@i90uT-OFxVwIm-)EE%}}7%hJI)EKe@;RDZ@) zc{h^&7>N)Bnc)Zz<(rWH9a~HaED;E<%!Va~SHT+omIbiJoB~+kL4Tk8!=(Sk32VeO z!4jU_EwIG=!mY`$g2&O|hp1l>lJHl0x@sXxk6^>tp)$tVh~F0}8Y5P@8fL}sFNZL; zcONECGT9tFFxrS=dln%)5vIg%h-hf|uyqT8M3c>WL&N!TC2}?5!@=>rJdOD9gtQ)> zCQAR~ajMGIL?L~UqA`l;>~aw_Mh<((NzoX^O|ua+Mz+l94uZyL=8POCPa`iik|d24 ZzX2_&&P-=D;S&G=002ovPDHLkV1lHeu@L|O delta 1228 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 M07*qoM6N<$f_gK$u>b%7 delta 2468 zcmX|?dpy(o1IFER*4PmtBb7sPkL5BEn@i+Q3|Ue%BFU26zL-%-NXRwgH!MV%FipQK zEw_BV4CNY%?MN4c37;LrCG=8Sa=4Zn{zT_N>< zVNu-s`ufOZ^2*A}Nqjg@UFp%l-c-Y)J}D`wwJ4$S7zk(>#UFyWyDjqW1)tH?C8>NZV=|d0_X(6NkilTs+1XJj6om}6Q>TEasNv&=mP%x^SXh9R_VPWo z^FTY|R1BBBIM&=X!gcq(5Mfd#np)YeWNBLRK7Q+ahMVHK@yAiyC(eFWyqR?4HF&kz z^zdO4jkehc9y@jnLJSyt5ytWe> z^P)S`j6Jk*@48_Z%=X6fRZRnZJM(~nM7PdP#Q)B)2@1vEMqv|~WnzhY5 zn46#P-t<7b?LkqMKus2?>Gia@sHljCG%>m5b-9)vxxP*8@%Pi?Zmlm-ga8%?KRrLQ zU(++iJfn^|`J*v+?gT~->KJAcqT#BkB9o_jCeP*7DX!}Vc2Aj{@Z$Q`UI=7#Yz%!N z+n#*XxwIIts&8w9gSDPYM&XkWyDm|eaUmh)^(@yJSVOr#KU`9L6&ED>rh-Ijveu!U z0V^xBOpZ+pKT*btTtH<)JMLrHSWbg-vjnktAYaGNO(Pc}FE4*PJ-rd5h39RJ|2Si} z_4DVjxsZ3#V~-hTe0)52Rn~HWTiLWQi`@wzeC;TUH&-4?V2SSNBc?XxBb|K0gthB< zR9HFWe6;%=EZ|FzS<5HUu}e?R)+|H&W=M8flcS-*!M_m!Vxi+-VDr@p4Gj$;M9x`e zsafnkX=%=J)e!AtGLTIvm?r685qvFz83GERco{GYTK5XEoo51MEmZia>wqAC7awM^ zeCk?$>yaN&(R95$LvI~0i<4XvbZ%f70q@T^gS!wG8A>=(d|LAd7jnQMwnyFY+^ zIQ^tjARSdPz{b-i+HRtA4pW)4ADgat+1fJSX27TDv3p#my_B%^nUV^Fb8jWnW;@g6 z%#{s4b|MwPM4N<*AXmXa(c7|ZVvrn|JOBLp*Cm!R`kA&zGqv61cD?5Gd(rigW7|a!Df1KQ^g~@bOV- zGWF2dk9c-pY}ZKOa+n+aXM&fPk;q3>r1ZBj9ys?|)-ub4I%Iea;nx9#UAe;O^Y#=y zgB~Q@pO;m5Y#tvI7k5r4ADwd{kR8>Q^Wed!4Q>17wS^H=MM-3=1>hcys~@o)#)BU? z_$NbuPEKNR0ZI}${5oHCn z6%w$62aTg2z3neGt0-z*7_JjWFp^c}p$AV!XC+9bcP0k;CNF(>Mo7p${m1Tqx>b31 zw>?c>BiB9=0tJ-X8BeE{($t0r{-Fo|;49_nkg~(2dvV1`fnCcJ#RPY~-^_$5LB*8K z<8H&Jh0rhwi2-*p)0^M4bakg_H#FnJ49$;uUBPt*Q<6=Qt*hL(<>Tv{X4~Sx z5$&RUjgaJ3HK^m7$4auYT>SiYyxE^WUkb?!3Je^WEQs65aAzJNW!PN2bmK!|d$iNM!fYdz)c=^hpCf>(~6b`c*cC z!r^49cm%Slzv5 z4-Ytx(Yig(s{5v-VB4a!nGwoH6~J$_)1*Id8Hlh&(SrtX4u^v*(cuPHR#oL|`+i?B zc@t{54!idu=BxS}IpK-%aR*0ob#=81CQrxDetf&7vNDK~DuHIqvZJ;3@h4PHMuytl zoYwK6q@*M-?`>uqjmDP3Qu8o%NL<(9?9*%mUEN(=)~WupRyk(khk;*9{5R_PtmN~B zhXLygM8U^a46Q^P0STN4_$m^YdD*5kvU7XTUHmz*6n_gJVGHWjW_gO)j6t2(ofsI#raw@pn=o;6L4jnai3=uBkG zc8441fJt3NbLdA@?qPNBK3oMSIF_mTYKk|N@xjK%qU(kJetzb6s43 O|JhkPSv^JhC;tzWKCl%4 diff --git a/tests/ref/footnote-in-place.png b/tests/ref/footnote-in-place.png index b500ac80f47b0d90ad779dd813601a5f5ad2cf3b..2f9681e231e740189037a146baf5275a4c3fac8e 100644 GIT binary patch delta 1091 zcmV-J1ibs!2U38v$IG@NREzif*x1-lpQXlTK~!9qep)z#HsUtitb-8MEhK0ZElbaa`Sne6QB(b3Ty930Bb)HpaeJv}{P zVPXFM{$yli#l^*Ydwb^Q=IQC_!^6Yg-rlaRuD!jzfPjF0fQXQinyIU^jE)R;pJ*+9QJ zBd`Df0)Hb(L_t(|+U?e5SKCk=$MGK&sgpgT~Bne!|vTU~Shm7GeCm8B{mnmGltP2duZJENJ zmaSlD_pRGVOI_c(#n0n#c}g~h2RVc-eL(yD&ws{;v%jl&^B_S3G7rL{DAJdb5JH&A zozNf``pHr?$kHfdwWqE=l9yL6K#} zWZ}z~^bXtq#LLSZVF%L7p9){OItHEfC-BjgL|TPK1ykY9hYEByuxQPvR6K>DXk9~n zBY!GNQ-6nj82aX(To|Cl2|Rt&_E`6DQ{w=H(8wV6!nGL7JrG{MdhhGr1prt8tjq(H zdv_T;F_MUd{44DH;H*M@IbtD1!hxNWVRomwW;+03X?1h)emH)it_DI7i3oN8fUN*H zdW1>XUb{_2p0~MWN&LV;bp4F3&i8td{C`qW2>{X9FpeK%dXJ0zXg|bbKvsE2-yWvP zBxJ72o)*|qr-gY;QWWsoJx<{}9RL<`2w%Ga*!CD-R<7R!N>7|LKHTvV=)ZV@%i-$o zdDi!wLwIddAx2)6+Ti9*Fg)6m zb7#@}!`I5?9rp$*H*g6@f~czI5Vi`xM-$wx6OSk~p|;6JP>GvE5tb?g8D002ov JPDHLkV1i!{EQnwpv{EG#@c zJa2DrmX?;^-{05Q*U-?=+S=NKgM-P*$+@|?tgNhletvj(c)Gf}Y;0_CadFbp(sOfj zVq#)WPEO+D;+UA2@$vCXOG}=fp4iyfaBy&?rKM9-Q+#}Uhku8MwY9bT`}@Jc!9qep z)z#Jb`1q!#rZzSFMdi!^6kP(Za;cnw+GQm7Unw+ODv;qou8^tghMF z*}c8JK|w))fPa9Nmz{!xiLkP|sjIV$j+SwAdwY9(#l^*BWMu2?@JLEh+1umV+~ofL z{$XKZJv}|`?(*#I@myYJh>DUxLQ3G_mVN`awfW{r&xgg@voDt9N&Iq@<*Fc6Po`T6;$ zr>D=)&tqd_jg5`m+}z{i+7KK zxyk?l0)GujL_t(|+U?hMPg`LWfbj<@QcBCJSdk6c#*~e@ySux)Fn49baQBwBl=fcw zC6^HXSyD)v+6Q;k`n9ecv0=ky1Zh*k8Q{9X)yU znz&+bYFB&EkD@5V0bhFxZKqpGT`6d{BVv)}kc0>;r! zN-O~>T82r0ge5Ft2}@YQ5|*%pB`jeH|EJ;fi3FshowQg2(&VfcdU$3+V_DyF`v*df z5b=#LNqzd@J{s#Z^D>Z4TtoSJQ{je(DjMth_2E@GH%yVOy|t}{?w;_xLN7&&asWt& z-hUw6yHWpG^Kes37ZzthF?R|4;ENy2s?D3c2Xe1IvAAk8C>aLIk%Y4JT_sXIa;%}8YBlHuSk$lX* zWbEWIAOpjxCyqnr9^=dE(Ie1v`mFKc!-t^r)EVQ$ib8qwmYQ`*2)}C@qF`+%#u^(O ze47)%onw*-7aDtU?!0Mkb88r*sTVF%>e{{oqg}sziLwRW+N5l*GIz;}q=fA*Dkds1 nSTqtbS(Xl)Wz2*S;!nQ;ry>9lrafK*00000NkvXXu0mjfw?0#P diff --git a/tests/ref/footnote-in-table.png b/tests/ref/footnote-in-table.png index e110eac6d4cbe9fb78c999cecc4b801cbe2e58ee..2c7e423095c7426aaf7cb81f145adf28f3ffb2de 100644 GIT binary patch literal 12817 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 literal 12727 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¥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 delta 1059 zcmV+;1l;?}2)GE4B!AIROjJex|Nj600RH~|C@3i1-{U3g)z!np!{XxNzrVlv`T1H}TE4!%udlD4pP%#d_8uM{>gwv|=k49y-CSH;o12?B zI5m6calSAc+k+<)BM+S=NFets@4F0HMtmX?XlSUYsE&?~va+&#e0-FYl=b!XaBy&+9?L`}_U<{nyvmr>Cd4PftPs00JUOL_t(|+U?utQW{|x#qpEng~bYj8buR( zdhfmW-hX@VJ(?a%5^Dqzlp@I0<_`Q4c+be3-wiwiJO7>iav&nI$Sqq6iqvu+C}zHW z2gBUouRnf)V5X{i5pU{wzoJo#`N7=EVD zoehG(103vtea>4Tvjz4Q2KxI#id&>8Zr-_@X@7xzM{nH*BCxMJ4qzqh%M61f`WYPS zdMbf{1N(*sef719TcijIJGQq6K#*@l&X*v4SL5k3(&6ZH(&4FL*mn<@epLbM()UM_ z@BFG;qzEPt9cb?WLGCczC>T6A;ew&@)X56cFRpaK@B&Cpz`o?mc>kxM>J||Z5fKp) z5q}X85s^|@LZW|OM2e}GG?+Yc-f#w!=7Ql2Cih;v#2HK)V>p9JV+?07X^i0vCcR#- zUMV6XA|fIpBG%0>2&Ge4kRD8V$%CTd@)ckc?DJl`iYgD|@Qv$09pkVu#KR9@Uqe$9 z<{v&%+)_Ml&DtCoHdm)7!Lcy~imEUu{C}&R8Zg8XUuKnvfWp_(Iu4FCc~De_s})$;bHGp=+1(6=n$Rv# zG_SAogCm^;!_MQU&w#->=Bn$mSCI!rY=r}c&etv|KJ+~ffMKxrNgEg@K4ynN5gc?B dwumeS{{XfEe-zC(A87yp002ovPDHLkV1l`(C9MDe diff --git a/tests/ref/footnote-multiple-in-one-line.png b/tests/ref/footnote-multiple-in-one-line.png index 12def79ba83c57057c790866268f258f7196b23c..41faa1fb13bdc257ac3fa3b5fa8e1cb0116275b8 100644 GIT binary patch delta 695 zcmV;o0!aP41>*&fIDh8n?fd)u>FVyjz{vFU^uNK$m6er~larE?l5K5mprD{#U0sKV zhdw?&?CtUE?C|R9>PSdPkCB<;;^L8$n}mjrVq|QIi<6w5rlX^y>FMc-iHV4ah_SP~ zczAfVx4%tKS7T*uX=!Q8%ge~f$jr{xuCTa;hmWkTwx6M?U4LI^mzkkLL`-UIbS^F~ z`1tsKetwpgmNqsv+S=OVQ1uRaaM6zP`Sxsi}Q^edy`!b#--NVPQ>8O-oBll$4b9_4ROY zaMRP%*4EZ&Xn$ypjg7>_#KFPA@$vB_BqV%%e1Cs`r>Cd-`ueJ>s#H`|{r&yx>+9y` z=KK5m^YiwLi;MH~^I)lQUH||AT1iAfRCwC$+Qm`>K@>*epXr$(3GVLh?(XjH5F+sY zdkP4}F2yF(P&wZVoT|A!-J2@_0000000000ASGPaRewUjbp!H^F-8fFF-|&!vwp%^ zIX;EUN~Mi;BSpgnb$hp&ef|D~JK95%88VV`LppeLS_b?HU)>f-U4u=Mf`u1{k^R=T zO!**kc{4tdI9$QJ$MCaXhwEx{b!_xI98&h3PvO%uX=FI|6>ezMTT)7$*Q6Ls3dB86Ryf933iq^F3iozf3RjhD zXZPIBjIS>)?Sxm(x2=S;mlv1nZG;t5ERFu-fa`L71OG~fC46}R000000000000000 zEbp_t&+@*trQhPp@`__w;N;XQEDK7iYcxMMV=!mN3tGFj65dkq#!C3;`QgP%_~l6~ d?*sU&-T}FpQgx0KH}C)e002ovPDHLkV1f*Tbz}el delta 655 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 diff --git a/tests/ref/footnote-ref-call.png b/tests/ref/footnote-ref-call.png index afc103211207345dd73cd3bc982520aad1cb698f..635865e41bd5e3af9e649263f12b7d2f20e62464 100644 GIT binary patch delta 523 zcmV+m0`&cZ1fv9yBYyz}P)t-s|NsB>_4W7n`Q6{=-{9xw=k53R_~z&A`T6?O)!oj} z*vZS&*xKUU-sacX-*Itq*x1;NjEqiBPOPk~s;soY!phOp+r-Aset?K%W^SIKs9<7h z&(Yb?(%MT+Reynr%FNV4L`-mUdPquA+}-85yTesiUyqTQsDG-m)YjfmQd*gtql}K0 z+S}t=Tx587c$}P^y1KfSmX^}e(&*^u?Ck8mz{ru4o3609sjIVyijtF+og^eA%F4>I zv9ZO)#a&%pu&}U|m6a|oE}EK}{{H^Cxw+QX*6!}^`1trjLPDjbrSkIf)6>)G>FL+k z*WuyeNJvQc_kZ{M`~3a={qFDcii(Ph3rrCJ007xZL_t(|+U?cHZUQk7fZ>Vl6xdBj zPw2h(-g~c0Ti*XskSxwz5QBjEH1}VY#u^~xmNDr`&K3}HNyuUM5&n7Qf2vRn!e~Sd zyWFbXp}YnBKI=E`i#;lLDkA6?7TmDS8wy9F@kC6R|9=^6HH9gBibv^L&I{d4LQb(e z3eM-@D7axv*Wn^`&My=$78dOR)EmRm7&lxlL8}StZeLsAhAUMty#uVEH^dG98o=~1 zolc-Lz+SGyi&JUr@cQ!$mbH8`gB_mDHCWDbS?xal@rE(Fn1o!DJr!9YNSsOdIsgCw N07*qoL{Q3F$`1ttu_xbnt_x1Jl^Yioa^78NR z^X~8S?(XjG?d|OB?CI(0=;-L;;^N`q;osor-QVZj-R0fg-P+sZ*xKUP*x%UL*w@$B z)YjhC*4ES2-O(?(%RC}($CS^%+1lt%+$%t)5^-q#ec=cxx2%WHj zNJ2zRLPA0=E`Kg0BqSZS1abfX0K!Q`K~#9!?bOAt0#Oh}(F5-_?%inP?(XjH{Qtj@ zbQm*&3Xoc<`A+5L78qmf6Yyzdk*%LV=z1xg)RT4l*ntP;4QtW5MI&C<1 ztonB?_huk2r?eX@L}fVqRz`(E88sgl#@JUFKZWgp&cD4m_1?(Xj2-`~Z> z#i*#LNJvO|d3hfnAM^9}=I8C@<>k=O(9+V<)6>(;%*@zbnf`WqY?|<(&I5?%JuS7;p<>u=4_VywoA~`uZ`1tsCc6P3?xTB@5la-yCoTQtb zrI?tQgM)*YmzSofus%RYqNJ>3W^UZx<}xxe^z`)L;NZc-%g4#lB_$=>+~7q;MeOYC z{r&xAXK$XMsLRdOT3T8xEG&kGhH7kdgoK1qQBh1xOn<_}%-h}NOG`@~9UXmredgxo zoSdA1fPm@g>Fez9?d|QAm6e&9nU9Z;xw*OA+}x?DslUI!-QC^I&CS%*)I&op<_xJhT-{QKtUsny_*4DXfh_|C5zAA^u@)CK zfPe8F!(Ro5hljp9Ve2+0>kIR%yR(yWa>@KqBlded){06EAT~VOB2xtVb#g^TM@LDw zhH&t(ancL?%%`wSe(Ca+5ddiK={-^a0O`}5fZ∈HOV~3bP0-%Txg9$2rWePQxka z7V_2z0xHVczuWSqSsk40Fzf3ES|`u!(SM!{4()LeUR3C^YBDNp?yJ`h_#ED~)3{H! zQZ+P1vd;D1G1Y;76>^BzSv9PfAacj{1ozD<->=wiS(~WU;(-%lkrQg-r3c3IH1UHt z19Ae_`TA75A|Ym}Dm;mA3|fhgbG@Mb4f-+;;&~c3pq!wK(h^_8N<*e1EtMsj&wtJZ z1AQNp5}nVR+FEoSYS*DL$6zALzJL+4X>(cf77r{T1P>EAL_Nm&sD7t`Yli z{a*2>ak1gY1`8ow-JJ@t;YamWVkPSv%wb}~{+e9U7nPLMf7MSS5Zq^u>qY7S0000< KMNUMnLSTYPeU+vF delta 1171 zcmV;E1Z?}u39<>0FMsau^WER)_xJh!{{FYOx7ym;*x1-{adG?m{K&}2#KgqAySu)= zzIl0hSXfxVz`*tO^;cI{A0Hozii*0rx`>E~`uh6k=jYPW($mw^%*@PQUS4HoWss1N zXJ=<-W@e0xjDLTBZ*OnT&dy-QC^I&CR*Fxt5leP=8QRU|?Xdu&{o9exRVB z{r&x)pP!nVn&{~0M@L8F+~B6Brk9tOgM))|a&oS&uHN3>q@<*ai;Lyv z>O@9PrKhjw=zr{IO( z)z#J3*4CJqn39r`fq{WqT3ReDEQExF($dvYQBl{|*MCe*Oq`sY=H}*ARaNuz_U7m9 zb8~Z*m6hq~>Fw?99v&V$J3I37@=s4sQ&UqTBP03w`D$uvy}i9jNlE+r`<|YjkB^Vs z+}x?DsjsiEva+&6LqpWm)RU8w@$vCOLPBF>W22*^+1c4^Yir@*;faZfX=!QD(9q@O z-|p`2ot>R1C@6eE-b z>Y}2eH#aw>rKOaVlw^EENB{r=FiAu~RCwC$)m2YpQ4~Po#Vu_qb&3|JxI5$S?(XjH zu8bQD?q1wWmESg`nasmXhNs?!ko&!zrU9cPx;rV|5!h z$z%dzYH08->(0;Q6U+*}hr(q0u&xs?=HP zdQw8JU$b7G80%CrQdB6{=<6%g!toRPe19H(_{g};{K?)oMXFi8Mmd#?WhrimHEZ-N z3nTRYy*T0MG2h=*wYQm*ZSq?Y%74Uo z9Np8AI>Y+1)Mw@8Z>51Fkx1r*=~*G3fLTcoGoJ*Xzc4;J#Ea2`A$I&pICr+{jRNQ7 zA#1OH!)H#T3W8zkYW*8NbrMyQ$J0tprhmgS+3y>_3io%oz@%k-aG>9XKLrN{E>6=B lotCZ}mHxOyBAJ7J0T9}t2>3RBF$@3z002ovPDHLkV1m=Kf*k+= diff --git a/tests/ref/footnote-ref-in-footnote.png b/tests/ref/footnote-ref-in-footnote.png index 94498598360ade600e1c4f7797ec1b57d7c45edc..73901b479b0d7275941f12b304dded257172dc0e 100644 GIT binary patch delta 2571 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 diff --git a/tests/ref/footnote-ref-multiple.png b/tests/ref/footnote-ref-multiple.png index 899afca16e4d3445d6c4e1592a3353f2b0603720..59e9fecef63f228a9255dd9b70d60d77976c7c83 100644 GIT binary patch literal 4425 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 delta 1448 zcmV;Z1y}mn3%U!CB!7QUOjJex|NoSfls7jw?Ck9R{{H3V<^25o_4W1c?(UtPo#5c$ z`}_N!pP$gs(9_e?%F4>c#l^n9zM7hv$jHb@NJzuO!=Rv`KtMn!C@5K3S*E6@e0+Ru zZEe=p*6;7{o12@5hljkpyjxpabaZqgA|lSt&cedNq@<*nn17h*>FGj3Ld(m`=H}*H zTwGdOTDP~iwzjrwYiqN!vn?$x-QC?{VqzsFC8DCDqobpPgM*NekaBWzm6esKsHiF` zD!;$KMn*<|e}Bix(Wj@UcXxMJS641BE-^7NQ&UqsJUmfRQPR@V9v&Wxi;Ey2Anon# zva+(~=k4?J_J7;m<=)=jd3kwXUtcLHDQs+P+JBgwY6hoV@60& zo1LZK-`{6vXFx(qYHW1a*xIbDuA`-`uCTb8oTQVLopyG1U0q#5L`=-i*0Hm@l9Zg- z+Tw6>dNwvTK0ZDpBO^UMJ*%s$O-)VQ+}x6qlC-q6b$@krb8~Y~PftTbLvC(v_V)Ik zo}O4(SYcsdoSd9eQc|j_s=B(mxw*L~Cnvzbz{bYL)z#H|dwcWq^V!+i+S=NHfPk;B zudc4HRaI5z=jXk>y<}u$Gcz->v9X4RhB-Mo_xJgPgoOM1{IIaFFE1}?X=(BC@xj5t zg@uLD(SOlpWo4O}nP_NeIyyR~rKQBg#Dao?K|w*gySvxd*Kcoc*x1-{adG72~gevXcg>gwwE_xE66V5zC8kB^UxjEwT~^5Nm(h=_=jlaui9@QR9x)YR1a z`udHHjnB`|`T6;3YHH2R&Ghv2mzS5@+uQy9{eRux=kD+G>+9Gz52AKQ%0?)kj&N&B#LZR@j))#Gf)`3q&Q~_XqUE2y>+E5`6h(Z;dgW?)#pkek zFHD?0Ld)>9&agR`-^Sd3fcp|E^nYpIJAZu|)|J7BKMg=l2>Wd0Yh#8mF)_)d3*LgU zsp?^K>lyN$_MyXg@6&KVoJ@Fp6ljjLxP>OyJ9Ji8FhOq}uFXPnMfIyDR|5q_PDj~m zrzAbpGbFd(fJE_auT|PM^cTnW0!F^sIC73D-}B^~)F=6=pC7-e}FdJa`ZdmdZDb8&wEcif7mfXynxG$@J}8=(R~jOy(KMFa zxn=qfaI}*aM_oOT^eZZ)S+gX(V1Id(o%0x=XC(hmGTGdn;Y&7K5Gu$|Gj*T>6(iJ; zYcx&y98OLmI0hK`DJ+WKgxgBN`A`kSmyq%|S%!-J9J`D_4;Y+|z)P)qDAA!bmd`-| zY!B1}V@S6GNtHZlTj*W5?yXFCY9MIZCmGzMuia@raqaqz+(S3BPrpEuXMeTzN=*qJ zU|YGWMb?dd77Bc*sU@~0pbhN5_jJ1A7R;D2Q`tcZg~Fc#JmTAtJyJgAXYzdAT75_` zZ$g4}TKq{`zrk>KAXPn_CXIi?n>JGV4kD;M=-=?7$&}U?6!fL;qJP7p*!AMG7yKP| z|51W|e09!9{OS4Ar^19n%pntog@!0Iu23kxLmvT?KNXh!kY&990000y(s~{QUg-`uf}3+sMes^z`&NI5_q7_2}s6 z<>lqSzrW+-PSdPOiWB^X=$ROqNu2-K0ZG2@$tdI!Dwh`)z#I;#(&0hb8|U4IfaFVhK7dV z;NZ8nw_spk)6>(Aj*jf?>_bCCSy@@7rKRTP=AWORVPRqK@9&zLnp|95T3TA#+S)cY zHhzA7mX?;Tu(+e8t&^3Vnw+Hg`1md^F3il#o12^2+1Y`CfwQx-y}iAFfPg_kLHYUl zWo2d3($Z~hZGWJkp!@s#kdm5yfQYH9v#_$dGcz+=TU(u-o&Ns*!Nbddf{K5EiKVBn z+}`Hd+vA_1soUMVc<+dl1taePg2>!xd@rPmD8!Go!%l+zx%% z@~pwJwSTRYlFdaUl}gE{rlDdlAU8Y+Twk1nTIa|Rpu(r1))njqZbN4vR?+MawBhFJ z62v@SUrQ^)RNKk{=iv!`xU@`zywYe}KEoEj1+cwrwSY)LSXS>`eaAnblO}4qd zl1Y=T%+K9D03}vXGYd^hT98NxYvx$suMndSx} e&qX1`k9-4mgH_dRsXc)J0000y3?#@$vD&!NJwl)$Hu-rKP1|VPT)2pXTP~ zSy@@Px3^$mU^zKC;NakfhK7ZOg^G%bj*gDg)6-g7TAG@g@9*ziTwG;kWzy2po12^Y z`T67HQx9XliV9Ktf7GLw`d)K0Y=!Hrm?Setv$I zmX`SV_{hk}^z`(iqN1p%sIjrJb8~a>@bHt9lj`c~NJvO&X=%&L%g4vZU0q$t$;r^r z(7(UG=;-MD{QTS7+c-ElE-o(n`}?4vplxk!e0+RyadB^NZ;y|UkCB<%-R0Ta~!(shXUm z$H~#c#LSbGouj3#uCTb+*xIbDuBoZ1`uh6i<>mMH`TP6)?(g&6-{S^x@cw2KVxk zWm&vZSbwH5bdam51}-noL9L>)3J`%^P%C$I0~^5|5E~t_JBzWowGCoEudTF1!6RX= z0-lp&`tbUi2KV8Sl#*8pV=P_75<==|3Gu~blE}lonhy!<_72eJ4f^oS74Y?aMjyTo z0iQpo^kE@TSN|KGQV=$48!$7igH|LQe)798>rS8J31><6^7;XwOb0dV?H$N3Cy(s~C@3f%9v=Pu{gacE3=;*e#wp&|U-QC?%Qc~2^)T5)LoSdBg{{D=NjQsrk&d$z{kB{o=>P}8ht*xzb zadAdQMqprIJ3Bj}p`kW5HX0flq@<)%Q&Xy{s##fC#Kgo`SASP@bac+o)7`ea-E3~% ziHxbKsh^*p+}+{T)zxHVWO{mfsHmtN9Ua}$+1-_y-Ewr#lbGGr+})jxhujl9IAt52n&CN|sO@xGm?&$37?Cf@ScDJ{;g@uKi zou!(ZnwgoIzrVkpo}S*`-feAd@9*!tyu5#ZfA#hCi+_uY`uh5%rKP5(rozO`h=_>A z#l_j#+2rKpu&}V*-R0)z?ep{Y`}_R&_xau5=kD+G7{ZqD0004FNklmj z3`RdF72HZG6nA%bcXxMfacQYjw|DP#}3)eL!Sn{sj@GlNQOKk#Bl zZ!tS_RqY*9)7iCkg24FZA~_HNzdyj+{MWcognuqFaY<>?qbU-)?7O|)3{)s^bsLG+ z4Xlq)EWw2i$V$|1tf^c2d+5XoIhQxcRu2y$Kacr*loji=sL%`yhQ)+attx}zRC;H{ z0}ADo;_{knoV^-9oGy2fjPV0ODdvAE4(F%De+zyY32!(MVhmz!OOeDc4w1x5%OZ(a zS2RQtJN85$E_!h)wzoFVMG(v3fzc8xtqw8%+Tw8j6_$dLU|?XRq@*1k9ZgM5&CSgrAtBwBnJFnLLPA2gxPQ1iJ3Hs+=T}!(udlD= z<>lew;YdhGjEs!ka&*?#*3i(<&y$$l)!f~ttK8k;+uPePFfeOtYt_}&WMpJ|dU~j+ zs53J&^z`)b@bIgvt2Z|{kdTm7RaMy7*gro%@$vD)!^2ouSd*2Vqou8yoTRR>xaQ{O zJv}|FtghJD+JC~t%*V;m-Ltsegopp;<^TQtQ&UrfgoM}E*ZKMRy}iB1$H(1?j9FP( z#KgqiY;N7QyVlm&baZr?nVE%!g}1l2pP!$qs;a4}se62Y;Nalw?Cf@ScAA=+Y;0^7 z7Z;qIoSvSZ)YQ~%ZEf%G@4URce}8}V_4QIxQr+F%TYp+9<(11MDh00Bx#L_t(|+U?ZE zQ$kS`$MK&^8X$@&wqSR6cXxMp15!#i@A5Z0Z^e$4-p4TavpcKroVjxk7=~f~p6LSn zd!RukmVXzhJ2E;w zX}Gy2B+AYTlQMi>lz^6^Liw8o;CWon-8BTXh=0*i4e$mPq=#ZS9j!V*Dbx24sQP+4 z=!G-d+Ztg|d25lMNBn-88|(BqPY diff --git a/tests/ref/issue-1433-footnote-in-list.png b/tests/ref/issue-1433-footnote-in-list.png index a012e2345fca796e807c0534e93196f8ccb201df..19934a709764a3ba1bba0f7ff8cd7fcacb0ecafe 100644 GIT binary patch delta 533 zcmV+w0_y#Y1g->-B!AUVOjJex|Nj600N2;osHmv(^Y-TF?OIw|xx2&o`1nXjNST?L z?CtUC>h9?1=-%Gmjg5`Y&dy$5UQA3($;;El$IqFYqh)7ri;b0SZ+Eh^ys4|Rq^7Q@ zs(lp{dr_+cY#Z=I86{?C`g@x8vjEIyyQ50Rfej zl}ATM?Ck8nzrXwY{P*|y-QVZ#@AH4#|64ntJpcd!6iGxuRCwC$*;kIjFc?MQI}8ld zd+)thrqdH+_J6-5-gx8zL9Phr+rW_=%aMNz047;C1B3|^CQO(xVZww76DCZUFk!-k z2@@tvm@r`g`IUx{${}_-amvE)6;t@*)3wyD@b}lUvea%hJ)0|O)IDKUYyB_>NWR0x zQmM$V5&!_?(u@f!55`Sg+D0jEmrdcF-9JybvKKw!w}1T~#!ZZy2s3U1000000001B zShE9Zdcyk!cXoa+9@5k5NB-K`^hcp%MrHkUEHFz*Q$786g{BE%@y zGwzffnM)6k(u%SdpPse0zM;(hzr1Qyo2GAke&z`$*B74f@71LzY+wwhA#lk6CP@#Q XDu-@(;|^~}00000NkvXXu0mjf>w*_e delta 498 zcmV)a>hA08@M2_aiHnns zkC&vTuBofDvb4OYsE-$9bH!y5RcgFG=7i&+{uRzJRTNY16aWBP@t(xa z18r6Bj14#PsWs(b{*%p(^+-`HuEp_D zMtEs8PEIqz%PSFQ8R4=_3~z6xeB(xKr(wKlzsZTwh@Omb8Xt?k-t(y3-26iH$3@Zw oa(|Z-Zrt4Fgx{{OOc}s8A3*+2QJ3M15&!@I07*qoM6N<$g4Qz)9{>OV diff --git a/tests/ref/issue-1597-cite-footnote.png b/tests/ref/issue-1597-cite-footnote.png index 6ec017c76814c55908cb99a44cbb0416eaf34389..e7c076b14e1d23dde9bbe9defd74fc430fdc0c80 100644 GIT binary patch delta 488 zcmVJ^p$f&BaNJ>(0a(Y5UOu)j*+S=Nbl$7=L^>A=- ze0+RU3#>gq^HNXyI1%FNWy(bFnbM&Vokl3l0F z%*@OT0k`|#Pk(1Re%`upDKKnIjIduLz&bcG%Wpw2dfsIotcgio{*I3^EGsMYO*P*ReuQ?PQ6ck eDdi^zy50|4lOUp9I;>Fu0000sj*gF$!o$a|u(+(Qw!y){)z#I;#>Qx9X#4y7pnssCHa0eJaBzHleD(G9 zl$4Y{K0cF^la7v#r>CcNb#?0M>YSXMmX?+Amh{)>x?^Yin<>SenC007fTL_t(|+U?X=ZbCr}M&Up94I#bv z-h1yM38X{V|9^gvc!#IU7{vKDa5Q7t0)QU(_N)-^<$HzrsN5++_3Zd^J>bs1cjHD7Zt+Y38!IZ zoD={60OYLRmOAIZEe3-eWcvr%_t&WX*vQOmrgZG+&`JtLpPrg@AGVa?yI2Y&}#6T$ua`-xmaqgz2& zm>$;zJqwzM6!hmWaadW;zIfRS?q6Hq&LuRu6?BE^aZS*(pa}&{K|5yQo&lz2_(_}l zK&OWg5J0zgL5~HZs~P)eeb8xu;qhaD4!uvideqMkJvjp%U6>dF^pj!e>IQExV}A&s z`yoab3+UqF;(v{e4L<0*dKesKlOCO9b(sRZa|F=mCCrA!6&M_W{vm)*>Bml*uYcF-!7%4jr}l$2l&e-D~nXg=c+5oj!Dx7!^Gg>*Wd zTrNK`(9sXjqQ*{*dUnu9tpgJ%EG#@R(DzO7!i|bpK=17A)YjG>>3e&7d3kxsWReUT z4`X>bAN1F6spujZbklY41RxNC2R&TS*d^j|mVXf*Cacwoip%A~?m-02bo|jNKInz{ z)ZrlkH1>3Z3EnPY{#?*_cEjQD{{gMlYGpDRW`MfKyS#N3WWmOw9RJ2fshOubHIw$w8PTJ4A8o^)anWW zw14?AhEA_e0~fU4@3&YiqP#~V=#dHX{a>L>gI-%t1$-HxtE$QSee+D~5E2&jl_s*Q z;c*7EV*0`(p8 zz=E%T(n;vimI0h)2m_0;7fr+qIv)Ry*+6GsypKeJ`?t1Zxr9cyg03(Gx)U2h|6VB!8SqL_t(|+U=KHPZL2D$NfY+^2h@T2`{`DO!R>_jf;^43=$KO z8dSVd&=ji`1*~LkMNMlef?O1^rC8b`PVK2F-CtEfE1{ds@UaUVZpA}J zI)K({wUJ09D=X`7pyhV<+yOuj1=zEx8R{GM0bN>JDvF}l>n$iKpg&p_G;39NiSNIH z9uBf^JQ4!oLK=ZySXdAQp{J)OGcz+33LO?`*8Jn_zYb0854x(Vs;Q|dFE5XB_lN$VeB^ zYin!8#l>-ZV`C#DBO?}zse)#k@zklkpqE!L6i^3!^ENm=;PFA@%S6z0N<^ayBS8?% zW-}RfyPZytRM5(a-`cqs^vHMI*+~V>&h!Q&jDP;1{1ZXb-u3(a#{;^quCBbioH8KW z>2%TsfOhfj+e!`3r9lt95^GkgbnlMT)-HE-4<&ynw!DltuO)5*>FO1Ez=K-p{#`c3 z-p{(zKc!nYqS0Rlg8=|`ZyjZ2Wg3k}6h*7my1l&}_s3$tC0{^#q?4DT9BQ=EvwCS^ ziGOunZmF?>Z6T*$f7c9eG6kn(pEHCO= zO_8cb1GZXI3l>&=IQk9{IugOgr>X@D`#Aig!TpV=z=?{f*q~(aM7LNb`002ovPDHLkV1lwo%0d7D diff --git a/tests/ref/issue-3481-cite-location.png b/tests/ref/issue-3481-cite-location.png index 01139e25f55ff67f2742e2b9a20076b9019059f4..110ee4a2493c67ad5bb2b0b8c9dc00c8c748886e 100644 GIT binary patch delta 482 zcmV<80UiGI1N;M!B!ALSOjJex|Nr;*_lSsyi;IhPc6R>${-vd*H#ax#?(Y2j{B3P* zm6et3?CfJ>V_REW#>U3vAYm*K6&tep`}m!Y23jhEB000000000wF%*QK2hbHy z4TZS5y;lfd2?{SQ-zkK<6VAiTI4J;t)_&Vk@BFpJU&bGL~p`)uxOjJ=*TT@kCl9ZgizsG57bGNy{LPSi($Iq~`x{i>T zNJ>&zT4JWCu#=UYKtf79K1O(Yf|Zw_DJwIHi<37wL9?~JjgOakdxN^X#5+AjD=jtU z<>l4Y)zj0{9e*Du`T6=}W^PGKQzj@dUSMdet+hHlM5U*%P*PenHa^PC)J;!UDl9Zd zNl~q>t?B9MV`F2*#l`#k`@zD<*xBErqM}(@SwTTTPft%nLqpKe(Ek4ZdwY9pYis)Y z`Y|yvmX?-radDKClyh@)sHmv$@bIjxtaWvDR#sNX$bZO~n3$iRpR22@nwpw_e}B)< z&*gwu(gp4vYJgTg;QdC^0sj+f&e0F$%aB_MvGdo2`Pq@0mnw+FR zK}o^G%P=xJq^7P;P*`(yeW=6zQD-N(Ad=0-j9))uCTb~=k53R`T0?5umAuA z8h=SdK~#9!?blUPoM#w^@#pHh%L*(k1Px9g2}FrO++FH!sibXEcXxMpZ{zN+5Q88= zgS)JI&&%u}GZ|*yJ=jh=9e!WnI=laKiva`v=l=4ytAuaxH_v}O{4sKdWYC{;yXarr zI^l!oKQjEZ2^d!mU`m~N3a=1)1&*YTLVrQGf(v*}7{3^%M?q&;Bv9w}to=^Ndhj_x z(`HCmS_eRU9A1tr`l#unokX{=K>!Si2~|spbM~v4rsxRZSV^nr!w-b4HO~rcCqhtO zJ%Q`>L7M2nQr!3;Ej1@yC{-yM^hHxp&(lr^aLrIQTuP1r&bC;4!jc)aVr+PsT7NSO zTZ`kUgh#yfrjYZ*DuGQksH`T>EoBiL5qt9Y1IFrIo@EaUd3Sw@zj>Z{TKM2A z{H^T^;gc);?e~F^=-)ErI408{djkDyTQ7XDV!2^#BoH?qz*L@d3NHn{+#~5TT%ud~ z1-vT4!eMd-w3gZdr^T~Y_+<{ys~w^?%0&{DRgu; zHsDU`^nCr8khT1Afo;dnD4Xnz*3Xu@Mxr1(@4R;;#;A%~nW2wDt*mzgB+fY2Jrao< zf_uWw0MwdWM>#K*Ey1WV{St~tJo<=`^TLY)Tj2&Nz0$2~#~Khln18}1=|HWyoxMM> zyF+tS7fh}_Fck<+nw0Au3Bs+;te!CJuVSf=xt5!VZ#LG?j%^dG*T|2=O9ETN#%M)~ z)pFpnkq9D0P1VhjJtI+2c>Fltu+FI5(Ho8|g82&C4LL*^x9w)?J>r3NLe9&thyW#< z2T^Qu{HaldMAcJeWPhl;b5CgC65YA-<9h>#>b@a63mBG)gQ1pCP#)3k<7_g4fMX)@ z0Wmi&Uc|4?g9)4RX}?$8FJ!&_j!*^R9um5}*_m;j(G=8ZIbY(aZ=q4onBhGf9qrpd}jj&{PX?>nq$vFq(+&000000NkvXXu0mjfZM(q; delta 2296 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 literal 5129 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 Qga7~l07*qoM6N<$g6Tx#a{vGU diff --git a/tests/ref/issue-5496-footnote-in-float-never-fits.png b/tests/ref/issue-5496-footnote-in-float-never-fits.png index 4ae5903d808d8fe616b9e3b7d484e59e508b0ca1..85851f5ad05c51eab1ee30bde1befbdfd46b90ed 100644 GIT binary patch delta 329 zcmV-P0k-~+1DOMmB!6H@L_t(|+GF@n0bm%_OjXU_zyE|Fax~w){~Uq_Li2$`r|#T) zMvmrFXRbK8_}sYlh)kOcimJ%eeE#B153isVt2R*-V56Gpp!wPJw-AI~GbLcO7#h_) zs(DoN5YaqZ0FG)V2*|IW-@X4#rsm@(FOsSG=<)NZ>G|Z_Tq9A{1k_As+q0pm>+zEp z6b0A_(Tr$^eg6Dyv;Z6}Nk%n~c#*p~e~y!UpFe*-gdG?d*w)qt(Y$u;TB4kN@7}%A z($c!RI%{j|f`S6*aKQQV`T6+-G(UX!P*PIz=FOWxc3N5*MDy|E$CZ_pf%X&7ykNls z1qB5lFGdrb#*n6P1xBlUcAuP*LQYy&dA89t*w=ikeD`Y+U3ia8-ao6 b=jR6iA!|pI4=Dx}00000NkvXXu0mjfn%%19 delta 319 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 b=jR6iA!|pI4=Dx}00000NkvXXu0mjfn%%19 delta 319 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 Y8*7pP0XFhPE&u=k07*qoM6N<$f`Gj<_W%F@ diff --git a/tests/ref/issue-5503-cite-in-align.png b/tests/ref/issue-5503-cite-in-align.png index aeb72aa0d169fee63a9e8db9bef41fbd4427cc4a..eabc4dc3d6f0e7d07a2d76aa0f45d2a56e1670a5 100644 GIT binary patch delta 369 zcmV-%0gnEO1B?TZB!8|@OjJex|NrLa?f3Wjz{1M;`TFeb@!HzjU0q!@H8p>Kf7H~} znVFf(&DCLJYfeyDs;spA{r$1AvFYjQ`1trsOH13^+kJg~si~>h*x2^=_M)Pqn3$OI z^73P2W6{yk#l^)oHa6<&>WPb!+}`GUeT6|oOGQUdWoK`Ce1C+zzQ(b$yOWije}Re8 z)ZB`Ul-k_nKS4>Gou$su*o20TOHEbS+TzK})6vu0Pf=Oj-{%Zkquu}j0GUZdK~#9! z?bSyP!T=0JQHKDb_uhN&Nl51Y7eiu`H3-=Y=3U^+8;=mt=hCT!XmXJFlknYl0U)~g z%HiMvgjL{rQ)&*!K&e~-5bKBWj2Tu6?>9E#aJN0^hl4ZTmTTqk@#F$Pv{>neGfsk& zNkrc$=EX+A6B}8t(XaDiT#nSBu?hD|p~* P00000NkvXXu0mjff7;Gb delta 366 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 NgGeh&RY@2H{09z(kemPj literal 9441 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 bZiL3@p4yiWe?Gqcu!x`}r!HG34GR4qADlT0 literal 10391 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 XU`Z&<$A(oL00000NkvXXu0mjf9{dqA literal 12210 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 Ae*gdg literal 6423 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 diff --git a/tests/ref/link-basic.png b/tests/ref/link-basic.png index 0d2bd75330140c7deb06e5ab9a4be67f6a339aea..f53223ffd87375d2f87802dd4c458b8f4636c21e 100644 GIT binary patch literal 5991 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 VtmX^~FIfNp002ovPDHLkV1o1PAzlCg literal 6240 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 KMNUMnLSTZ67n&~s diff --git a/tests/ref/link-bracket-balanced.png b/tests/ref/link-bracket-balanced.png index 8b7e02db23dd0f1a940c3f0c65d86d9158b2e397..01bfa87973c1cd1417cdb078784ce8ba3fddb5b6 100644 GIT binary patch literal 2506 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 omU1LM`}}U5%f2Vk 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 Y0MK@LJ)eXi0RR9107*qoM6N<$f`tGNd;kCd delta 951 zcmV;o14#V!2f_!C8Gix*001~<+;IQ^1BppQK~#9!?Uw6LQehm&`(v_t(WxJu{ zQmfFUvRYY*7rZbPMI9m_A}A@|1x+RL0D_l=rX!i+@B+hYq=I-UG_x7HIYY&*e(P*! zE|Zrn-1FUUXW!>{p66`OXW!@9IrJ0hpXd-mfIuVANC-3njekHRA8B?{=;u-!2nvhbdA*k1>LEWtNkAI_V#vQU?Ak0LZPVD>ebcNpr9a)Mq{_z;jKs{YH4X9R%x4bH*Zmh z6P=q^1c-I*25K}NI+ap+WK`nrMB`*rQnTJ*&0y|5h`BsQ-hZA{=w04n-lu_ zE>pnKlMw2I09q!KF&GRm9*-v=Ai(W*L$sHdmw&-XrBV)uqf)8htynC^Fl=LEgSew# zP!r(NgxtZl^F+nznLL1a;Sy=m{^?bnl99$Hpuk&Ueu-^j7H$K)|0E&?;!jL~AOl>u zSk9fl)ZWnzoxXt)_%e$vsI6B;$0k8Xtj09iIkTU;p7=U2nh|vpM|o3K`V$H#c-TI=gcC z!jjTTe2CZR*vD8}qOrFhd|A0Pzo1Cn*bJU1OsQd{X3Whm44Q{%arBpkC9`v`%Bq^` zntI3dTuf|&uC3Fx@D)sRi>}AmS6ip>dN6m;e>D1!Mna$wXe0z0fkvQ_5NHG%34#7! Z^a~*ZhhP0-rab@v002ovPDHLkV1h=6&wl^_ diff --git a/tests/ref/link-to-page.png b/tests/ref/link-to-page.png index 2dbf76778ac7a169abf2bef3b407ded9400b77a5..d618f066b91f8d8713610e51ed0d8aad06c439c2 100644 GIT binary patch delta 870 zcmV-s1DX8Q2mA(*BYy#{P)t-s|NsBy=k3hS*2c)tl$M^!%hTN6=8lkMm7TS>zi)AQK|@PnV{6&lfK zW@vC&T4H2oZnL$$z{1LSdV*6`T|h!gb9H?&Gds}I+RM$=#DB)lD=jslqpMO>T%@M1 zUtwvesDVnjg=Z4B#n=kjE8`Sa+<$2cleH-FHt0#7X~ZI_&%eejy!Rr_|5VilG45k*!DJ zf#C1Itb^uoWCq^U{rXw=+V=cKN)3op6(%6xzWVWfL;@UgiVYmM8D2&O!qoEaZAhZn zNMBo$vWk?pu(8BaSj7B-_mx^!9!xeiamx(+uWt})IywRR!cR&(4=UZRe#>28S9!!< we1ChZ&^q64q_i@jf3CB+o;!VMb-Igi2!BPFGKh_5>Hq)$07*qoM6N<$g2X+-g#Z8m delta 960 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 diff --git a/tests/ref/link-transformed.png b/tests/ref/link-transformed.png index 4efa32f3c3fa1d4b170f1d3e95c1fd853ff965ec..c391f080bea18465a3f7915397f091531e5818c5 100644 GIT binary patch delta 1220 zcmV;#1UviR3D*gbBYy$5P)t-s|NsB@_xbMc^ZWb!>g(|1izxw^YixT>F&$R%WZ9K;^N}g*4CVyob2rEpP!%m`}_0r^Y8EPXJ==ZmzRf!hvDJj z)YR1F<>f&^LBhhqk&%(9si}H;di?zSot>S4fPjdIh<|^7Q-4!arlzK3WMrVApkQEN zudlBuDJd>4E{2ANq@bK_2FE8!w?cUzrS65dgBqS^>EVHw-`T6dDE;Mn*=txw-cC_VxAkySuw)W@gpZ)lg7SIyyR=o166X z^q!ucqobqe=jU{EbXrU{s-{-WpzKo8RJU&Lk#LO=-IhmWIR#;%);ppb)?K%PQssI22 z+(|@1RCwC$*w<6rKoAD-PX^P|NTc`k0wf{5_uhN&Js}|p-9@&vw~}mQ|Hnax#e`zu z;f{xle}4}e`)PIG?%mu7MMXtL+C6-57n-cTa~r_U9hldn`}d&B&09d@g=Wle)k^5H z?)gEqzim2nxNl?K8UVHHzTiOH)oWP!bKtAi!Y_I*UFmPdlgI080qog}&!67o#`Vq? ztnJoW_|?nqi$9p%lV0$Yx>Dq#()!4jg0|o}+tL@m2H4XI}(b!YpeDp{gW~ebrpxO^CL6$k2 zt0*_1&aXi%u7m?sxlm;pmW{zPg-b&+d+kCzN07Z>&CiyjOJ9rt(^M4v@hdN3rmV$2k4Ng!>z5#QbEEW;@Zc5M1ET@Tmu3(nkP9^+* zC~-{9ov=;(lefQX2Q=jZ36 zqobalp2x?>^z`(bo0~d1I)sFTbaZsm($bWaluk}gv9Yn<-rg%KEB^leS65e{prBx2 zU@R;wM@L5_B!47kW@gpZ)lg7SySuw`a&lEwRYXKYzrVjjLqq1~=GWKPjEs!;_xH22 zv-$b?va+)E_4P(ZM!C7U_V)J4$;s;K>RMV_Pft(o?(Q!yFRZMrkdTlsFgHw1QtuC&M|k&lp<#>UBwkC)QZ*45S6g@%ja;o;=v=j!Y5S6N|l zbbR~!{G_F+c6WWWwYgbZVc+5CN=#H%SYTvkZhwJ^6c!#66&)cXD?2?!jEjyR@{FQlRceTk7ua?(Pm$s8Yk@XXmU${$jI}VSRSi0gaW^YGrylI^>8?&z#-#2q-`;p zN`J9>RXtjoVJh!^fwHQ)S`2j5H7;A)56jKLTI+{+Wf0%i<7GFvPB3 zZif>Yl+A)XENQ`yLxW!&$P71!D8xwtiYS^f^1+>LeeYeYkj6=dpP2;(RAzR48n(3e zIUFQTp`ctzk)4l_k^gS+y91VB4n#RglYbS3-|tg7P9=@CaU%V5u6ugAd+%VCBAgVH zX?s0N;*&+xToPS~`1GM~NTUK4vWldTU}le3^;4LcL*ufrm*aj%W5SIzDrB#$CIv~Z zg1uzcRFu?aRNZ`Zr1wyJD9lnSZ8P8ih2;*IBc!s3%Dw$X8jZ3+YzhfxnRYl}zJHO< z90671apkW*o92X%68|z qr?C~79C^#_-&32-X0zG;TmJyEYKah2B$0~%0000N;;*Vf!ZU z&up4d#a5v)!~iH{_#cp`3H%oz3>ucJ8hkr^85S01T5B!X{%bT_&cpiC>*}_)&>Bng z7WbT-9Cl)ame!X{sqKy7wI;UJrEXEP*4~c7${lMVSrkmAK=sht%mltPWwx3`nk#l z2hD|b4>O=x3Ohsm%2B=DkiZ=h5@e=iMDOAH5H*L&-NH*M&*Ps{)fO!yBXseg~@`+`c1Jo9=sWjp0?wx-XfbPza=h17u|TOJ11>Nyp9gwUgfW_LqO& zj4+B>iyN4j%x#Wlyxd>%|Gg+9CnuLRYSPowvvF5Z_4#Ud6g5X{lT9C~;=-q3RZ|_I zC@S`m@$t^rHy>MC1KRG&DB0iLn@Ud2Iq3H8N`el$0@nVq#)8N#5HNQ^d=p zq@-UdM#kKR64;km`j*?g$;5&WR(sw*syqnH$(_6-Q>v92DIp=TM6Q=VEs{r)In&}i z4bkmzU1(5wb!pJ(FwWAIX$yzLgMzNdGNnoy!o!KD1FlX*=a0Nc(?vrtp%7$NzrL}t zalu@rsaO~6<$RqD{?M&D{3I082|!ly>`Li+7|2;9%wwt5#@!vv$~yYoOC*Kb_h}X0 zShmTHj$cxKY(Bcq_gLw|rVO`#{Ft7aD(=79&-EXzL1xuRUELp@f!_+$qsq(6XJ$|m zogiV812<=9og~k1k&%%$Ha1Ur&}g(;A(uZj$(H&x#VaVTL6`L=B!s{{3inxE?ZuxT zow&ZJQS;5sO(a$vcXM?Xc)I85c(`{(IS$(XkLK6*Kde8TpG%B>avKgHxbch`p!`5!R-!n+2XE^#Yc01*|| zHJJ{6>cQlL_mRIY0RfkB%uh#$81D@Rsd|ZcTckudSZz2NXh~ z=I)A4L{CmnPcMPA8LKqR%+B`q1NxCa7n>{1WIo?-2U;VA{B{7GwBeZ9n7&xldm>zm6K_vOM|w=$>LL~3S9#Lv~gaAfg# ziev$24Xy6^-p`eVIz4V%XeXa&NgPpnR?8yvbnP+ox zq343Lhywh^85kH^1!01_^|h@oZf;2MS^|Qq%CLx(XQVwp*QfW3Ll$_FUU>d0oUviN_2Hhv0iF>0H5)y<>Yj}}6Vpd=Q9o>>bQQcpi zYqLS8*lL}b<3VmMHlP4AGxKG3r&YUe{Y!9u7phkIx zahV?8bDbmJCM#fhPx%fD=NcXL|BA;#W0~DU(G3$4Y*a`UGU_S}5-Td(L;N#T(CVX4 z47fN(duzi&w_kDO!JT)QWNezR;0>1sH!?CUX2hsBYp(u>z_v`)c=unu=+=o*==>Ek82|y?wSX$!9%ED|ncO1v&_)YZ_ zy~2~|xHn}^3*so6{BoKY6JN`_{z6LNNRsrryaC3)MDw`k4>w3%U&));Tn8E}Xf|RL( zkKk&;ZcgRtEu{&SSLpU-!7|+_5Z^UU70!AjKnp zGbGHQG>Sp=aU6>bAPR_js?bZ30e?t|Ng;8ceGk$-!^gb883ejHn(E6bX%@z_F~mL@($Tv+^-V_L)y_`hHH%84ONNv$>{dD-2~0v{jA%N3HZSZcY3y3E{D3Uq>EZXa-Ez`fkk%^2+A(eU?Ci z)vXeC=vrs6>fc^;3IstSW)zQ<={y&3X1w?E=X*jwhifcsIqv1yFMueanadiuN=Bo- zT}z)_->$z&@&d++-Mst8bp^^C!loj|@ENpN zI=PhSZL_v6uNM`sKxT6N+gJDzOm#^pqJI@Nu*Njg`FAqqf!0ZOXk7XSUws<~bs3!k zNjSJ{NuhXjcoCX>*G)oBRc^K8;*rp&UutMN(CTi~qn}A|K^COZ{EHk@IT34i0?CkV z&(?X%U-@5UT9}>1l#8D;MV<=yNdCG~5iH(VDGfigt6OHP?M*M(vA?5KJh|eL*t=(o zu#L%FT+wxix^P&P&MFmTbtWz?+|!0_px)9GDJ8^dwu>ib08P zmGlmK){o-Wu@~kXs=t@(ABvdLlO8~tOOhtw(X+&d9MObDr8GpgTwG7+fHcCatd!Lv z@CVsV6~<(vz_3T%y|Q<#-}7p4BHxTB-tQQFy0)+PW4|>pZ<}+}Z`NyS6i> z?T!F@w5_-)4UP|5wZM%XJ>qT&8j6|0h6QpH7K$JCbd;o(3(fJ-_6~D@OkU4j%6vSa zSS*5rDCv;@qWi96r0Pem(v%ker;?Z@?%&Y?>=-+PLXy& zt@)=vZO{mL|1~Y%&!;q{yR987aVXkFgZvC*>Q&cKfSqYFyenI*yqaCSSPkyg49k%jfepAu|+h{tC9Y94a?tBWLa7 zj-j6>BO^m5Jyw^o;Jq~oFpy6{^-nKx+GVB2X_;ks_1tRYRuaI?vm#D8ad*71j|??H z%t=sut^}y{sKMTXp3y=caw;?d}pPkw} zC5857_V9e7B6m$0lR~vzhQ(TvUq;I&EP}h;H}{p4q{1u*jHvXRnwWw|M@P@&yuwCx z_p(}AT5h=QK9VYRBbR$8UyMt(U*Wi2541f~B}$io7IBcKr2aN_`L@4Yo9a8M9Ire) zs5K_Uk{xs24}Xno6xL9@OcL4A($bnjmpnMrX1Sh|KW-hfCCJa3UR}LrA|vb#IUZb9 zywkLMZR1^ATU%sEDSI?~sTAv7no{xXxm#(nQJ7)~+SkdLx63J24Z4-5*Z`(Wxe$)u zWhT@%)jR2FYiy*;8Rz0NSYV|&OL;7Ov|7JrL61Zw;P1!bC;~2Grz~OAlhY|u2ny&u zRc*3eS0XCR*wEBO|3a#bDNd?fGk#TNE!jv=c6In+;c+T8#vOXl6r$>?iCsAi;~saY zt1rJYc4 zIKkLPiCm#sci*2ULW0Op1kc8jLor zA|(^nB%UT6Q)w&B6F4I2#J2T3=-3lT>n>iGxv<3^#q2$PVM}2KCkst?S9bG!jnoI! z>|RR3Z}$i}lgQ@5M-R+oG3?uXU-}+vp$CZ6N5x4&Ysx zfP^S)av#@O_jYv=0b$9*7E;xPI^J3foVelEKf{FRoP{|m8ZT##T|PB5gb77&o}QmG z4N&BAz>i2@SqPz&-C4S6KLaCn{epr-bY@(+`e%<6NPB-8-@|AZo6X~6)M+(Wy@=t< z^DU2C(bq>zL zfE|+sIjBAYKsjN}IG;3P0i4Up&JLc^fvB1Pd50n7RY^} zk1@zo>Yt%uiY?-!;U4bD@594oZ8LjCli@rnQjL!Kh9THqU$pemYk zVs;k9-(6-WTB4t(1hS5~!yLgePlv(&E7Gg*JyTW!ftiU;2YsHKT1Cx6>_t-gvFs}Nuy>xExU!ljCoojd#t zFa3Wr0zh6pMX#e9+cvK-&&}Sf^wWUAo6Wf2oMTp3j$sCfSSFZ58~u)GS^o!$^1hP% zGYZ@qTzmMb_Y?6x$&9S%^p3u0e~uGq9uNw};5aC*eOBFSr5`i{C(6qG6V&0YlR#Q$ z#+usNh#0x%X|K2zeP&<>4Fp4Zr(g-Ra(BSH#(Zp+WiQwi-N%l6=@8oQve4pD+`P<{ v9xTpt$X3$&pDLFEg#5SO{g)a!{!2|%25zs62?;?umZe2Nr36vByF*|>x*Mc< z_j$kP{q_BrnS1IwGiT1sedfgIXsZx`Xh9el7({BSihAfW4V}UOSm?a+>m<5D#;m3& zZ{YWPKX>y5#S5zLQ@%9?8QaD|=QuMKpRQSxJtAFF6Yg)mT_B$>RF}C9_#0&QtM51F zaHc29q!mol)cU9;k1sji7FFgzw=n<3fJN__Mu*|ST#1pno1DzKn>mZSN^AEk^(Hxp zfiB=VIV@5~>3?Vxn4Fw^GIl-l#iGUU79=P90)oPm_DF)e>+`ty_`6B-%@M@I#r&K7T@rdpVW;sTr8r8h41UP&i{_ArdyZ#$ zYmyje-SLmb1b^Lh#Zjg6m`vBZ&Z8$u;lkX?4J)%gn$$XCijvEbJJKH-HG0_H^hXgr zE{G(rGXIrz-mIaidAph@>p{@i9CRZSc$w5bQTnW=KtVd-!j?&LCdsheVEg{=hFtkQ zwSdo_5hz>KwcrN2hx#Q2h4xPK-=EJ|NTJ)C1N~#j*oxR$S#5WxD+#|1r1Md6>e&uu zi3A4+Z!u@j{@ofYY-?*f-59#r|Gre#=)FrBKawM%FxQ*P^+NOwYVZIP6Vvw)mD)c! zIf>p!Pfw4<4=<;YAYtB^5V2Twi91k?)$yJ6%vXJr*+eTJl~=CjH}n~ZvJ|y&Q;q{@tXEp zy{IzfWXbk4se3h5W!7}r1c&2TSS;a_FlgpVo-Z_dx`?#%3a_3)PY=;LUk5lwK7!;3 zIShy65}udi(#@S6R|=r%Vu9~&E6)~zrS2(}hVq?c0ese7}O zhj?CY@WI}+<;-L8hxhiKRYw%POdvIGssdVaY=o)?o_GWkJ9~-KL<#ylYn8q`&s3E@ zLu|A(-yAfJj*hPOB>p{IT5>_3iObc=`nI7qYJNWZ_4*nOPCga`dazx+z1RKy%^@xU zB@XL*WDaVrFXhU|(^H|ZVFztkVRX}u9Z^L20|~n`UrI4IBkt7#M8hykwF16wdR$8F zfo%xB?oNGH5fVE2nn@Urwxt9{*}N=Tkyl3(jW2uECM%2|4Hb@7nMqZ>JJtw_1X0|| z0)D-rMe922O$4QK#z5gvTLivVF=r_L3ymDE( zvt$&GA<;G=Wby{}b9;N6^W1vMd8+*UV&4SBz`*ccnkn$e?*zL2}t=VHsvSt*W6R)FUPev|%5RRyHD*kDjbD3s`7y zCvVu$D^^!i$@uhv)WO+%TMA?v^?v;khjxZ2FE8)7W-U!6?NdMgND3~)Q1H%dzC5;* zWx~}~#O>|r$QB!jHH1gdag>SpHL*qw`fAO?00!0-KfHI9+oq$stPkf(4KWJD3q)mP zZ*MUMUt*fX$6E4a&=bMYmkR&MN9@IdLmk2H*g>D6Y%zp@O_Zi8n?{=L34^3pnPt?0 zHCYrP4c2&$ZKj|dKgpy`Qs(PS0s)G?zP=m^o9LE~%Nkb9fD3CX_LhbQac)OkDTAa(RC_U(*l(y7;T(n0zzE+{vyJj=Qs9#xk&chhYLENkLe@L*ga+;F3Z%gyjAE+ z<8hb)5t{-ou7pf#`Q%E#(Wrp+ae3pk4OgW1oE|o-HN5VP>=kH zVO2~PF{6Z)XREeXtwJe7cxTvA3V9m0NgQQfT!Ue~>GNO@oZtG6i_;M{dTyVVUlAa) z17`N6{_0|QxNw6{4z$Ml3E`(y*I{!Xojgmm5$_5tuSH#cPnRC**z@W3EC#gek|X+> zjbOCT@+3=_LP;kac;z@VWyYRHF? z5(&!i2>?80mM}(ZW?j?#U6QXu=dxO+C2jaY(NEWT(G`=JK$cJ~$e$^#S6 zG+^%>k4*@3=>jGvtvh)k7M)>SYR-8}r>m=*Bl@7dES`gRVu>k=;j92%l!idC#0>=CXNfD~wD*6#CwfePUosDm6rjWyL1?YlK8XPQy<$$-5 zknQDH?ebhv)r2~`#)b9i)2F$gG~E+=f3VL-s>s61tw^l?9xU522Ua8OafVu6+$Mc0w3aQ&wmGiUw)$X;fF|kwrFX_=BX|Fx)tevPzd(8 znvT#h6wtAjFK~LW)ica^aKPfh{~2DLRD!XYRG~^5{gE4pOp*~Gl|1JIq=w%m&HMYO zr8QYIuoo4E!h`$E!Jz#-ecWzdqa}Enu49XbZa(y>+j6KYk-_Ids_Xul4{|H`+ek1I z8FTXPW=cYtdY%Rpj-Ub@7&Ux8eE&RhGYKr!{J;c4p2KI zvIvh+GDUV+CV}AXtu>qJvQLydIqEEAV2qg5bqmD;Q{)O9qq%B*Of2NS%IPQi-G*FnN?F!Dp>qB%%1S5=Rl2q67puHAu2;GLya z4!*N}S-RgA%9JKt2}p zB~P=p7|KP#Ia``KoR>*OU~;uT@4k@J9Q6KB8&*r+{e!Z`1RRgE>SZb>;CxEDB(rc5 zyy|0L=w<8d zHX}_dOSmm2n^dzCBCBSi5v6~NBcDZTTB=R&><9s=Z0HDbSu6Mr#UrZvFcThENyCk( zKdIp(sr6)zKFyTP9UYC;d<^SkfK@ZKE@?$PncA6EfbLzlcPHMr{UCw6MV>x#wT}*@ zgpCsvhJ=PuK;Fkcr%ts!tMd)$)cDXqya#HOXX_k=(;5R|?y8Yea_zM)-yqu@Z6tzJ z)^SFK$(|_`+`2oq0-_KY&^LwO-S#o&#tL7H!u180G2RXsv ziv_<`icOOafy6FQ&8qpGjPVPk-r*;7j(gNb#hV}#zMh?uHV0jZfURAO$DRhIMb+i^ zOcVn-;&^0w*7j0qlj8;DjGg9{QOm|1Y$>aw@G5~Xh-KvE+xq$vjm~NomVQd)E5~QW z#HiiAk8Yr*tmhLHd?I?m?n^AZO1#z@=Jp!aAU#{ncJ!yC=XOnea^n7MyCy75o|AKA zV%&T@W28{!*(@y?S-+tSU7GqUGO~je8wfwOm3dNeY+3$C2?usqp#uOry^lnRHJ8T| zG9X+<)f6kA%!wCqTfFq!#?O-p6IUZqa}PHy*@y-b$%i_*_G16VUrN^4k#pv2PI0na zLm7Dx9!(e2TJm2y``770Q{2DEKKc}{lB+E#&S!;wd?mfMmNz!K3)vz^Ioo$hQ3!Fz z(3$8<23w_k((rIY$RI8NiLrVPev?HG`wWBrM#T>R$q3Kbp_++ZZX*pZugZmbRCIU;d1C<{Q>XIDCLJ2{AKcArwuY1qB7w z(8FF^LDs0$jRRMi&N?=u-}F!Fj2Qkl!h^XVc6J%|FZ>vr$&BLPZ5C};?D&2ujOGTZ zc}Qo-)aB~355XfRNg2KR@osf6B~%CFP>Gt(#>R$CLVj4Gw*ex8Wl*LVlm7{h`6n|v zgp)TmNzslE?$>j=j{$SCp#iw#vFfHF5Qxv8xuOpA)9}A6%sp_K)?fqN8MXRDF;eLm zDb0h)>{_jkUt%lis?c3t83O2m%O48l%YZfR5(;jMzX<_IQtMwYzL*awMvHM))wFI; zt`njHe84X=vzySqzK?LV@f57*1f~2HvEtF&9~nt#pHl`5x6A<{OG`^_0LAelOz_w& zIX)5+bjjy=rRWQ;YiABc zYbrxD?I$0rXu0R+a5b?P865pRy+~!BHyd8~Eo2AoUCzfeak5bZ7;8RCgpuRhBsur` z0Q`}F&qBE22RcEoy9TjhvHGAMc(Bf;r3ZcPlgXN?@@EeGrBAbyq?{cbAuJt(!V7-L zv&Qq#com|Yqf%aY<#p&jOM7@%MYd5iS!}m!gbd5j1o)(AY;lpk#wGbIOA9AYUNMmm z_w5((h8aLtkdcv*u*Aqd<(r(VW^u#nbvdC1_d zCq(TmAC|Ods4V4A*Rjaq|D=J%0B{P1$J2^{E|~^B>-$Xo@dl(L?xf3_(LaFpiR#6N zX#Yhk)L;9%OM3<-_y5u9KOvg`Bi4UVYQ;bZ=CH@*IfF9e7j)1FLrqCru?l7x{vUC> B)F%J{ diff --git a/tests/ref/measure-citation-deeply-nested.png b/tests/ref/measure-citation-deeply-nested.png index 596c351ebef651b56d9a9d4e673de6476cd0db85..6711fc732eb40ad1c569f1f589486efed7039e5f 100644 GIT binary patch delta 687 zcmV;g0#NbB3w%F=Rld~9xZ?7Y+N!++P7n4o!kgUCOC(qx(Q z*5vTX+tzWRJAL}@^m~1U(`A{wzQ!XZEzQr@+}`Hhin8v)*X_a9&Q_1;uF31U(do0z znw+G^J%8YnxPRrN!pK2^(`TCR$lKIuoWm}4*?g$RID6riy8G|+%Swl4XmHBR)RL5( z?!?&1MTGa^>+|#W`T6?&{r&8{)BpYcet?L!xWM$-<(QhHsjIWbIehcf;{EpddVGYk zw7lKl=lA#dF%dOH0003aNklYH;gmvd)S%p^vqs%ArgsR@~meCW)GYY(ma5rJLhegB!Kv-|HFB3RYs&KMj8~QuBjyZL{J;pJ90Cz8-$5tlB(cEH(r^6iK2Sri59(uTuIs`ls0KQfV z@2$lFf1rlJ3yZ5;ChTN-6f6pN5VLNlVWajB+$h#=Js>?{7Yf*O3F}GxnIh4D=nF1k VQ!KH&%+~+_002ovPDHLkV1i^?n27)Y delta 687 zcmV;g0#N+=8p{`&3n z`t0)Z(&7F1`r(zi>b1`P{Qbo>dE%SB*m$Pbb)@#*>C$7E&Q_27^!Ud;f9>w_-;T8D zvCEpAr1RF~;g-7Hin6-9zrDZ5T3lq)WtsZ!^vzL>$wPzZtbfSrw$JRm)5t%7?ZMXW z!q?=Wz|v%y&{~r4%iV*7h-++cO;1;|wY|K)#v>&ywz$B}&)4SW=74~Jd3kxv%*=v= zhrhtYtE;P-nVHSa&4`GIkdc+KvbOf$>f3;;(O#9)W}5KH+r~J1$2)!0Xq)cC*Vb{N z$wGqZv(4zP$$#s)(f<4V=&#Dc!otD9!O_vtl9ZfgXmE~>j_K*?<)gyjleqWd?ECNZ z%SwmYe5lY{lgL4V(`TCR$lJp%b<}B``}_Rv#MsG2g!kd=_1);sRF3Su)BpYc^w{Ob zIeew3uZM||dVGYkw7lV$y6wQ#_xJgs<&BB}009(9M1Mh4c-rmM#d5-66o%m!{vg5H zQtCq8-QC^Ys8OSW)&h55CJfVDK+;X~EWYhKXLB%_Os2nc8L|Oz8ahKC$?mQsM4!k> zr_;6N*||YgD5J`JUkD+_#)UZlw4jSbU2v1sbA%j4SzmQ4O86L+uOJ`(5~5~wM2HW) z!oZdl-hatkTLJctV)yYcvKdQ*p00e0>G< zdXOIR_ZS(DzQvrf>@0G^;Q>%#1xF`Hk>I0%+6RW^@9VImgS`khLQqkO;3?CHz<#>tR-koOxgfASzIu`smb)e{QxAJ VGDAqH|Kk7v002ovPDHLkV1i#BqpJV_ diff --git a/tests/ref/measure-citation-in-flow.png b/tests/ref/measure-citation-in-flow.png index 18617beda04614c0e03cd01ce9fbfd20d2b0a127..83f92aac4e686fa740b4b7a0ff30760a6b4f5db8 100644 GIT binary patch delta 642 zcmV-|0)7421=aFMd{ z=;-F>?dRv`__ z(a_M)&(F`t$-kC2v$iHU}YkcWqdgociWhK7QKjDdrTgM)*Afr);8etdjQBh1zR!dD)lW+kW ze>potH#avjGBPnSF)Au5CnqN+CMF>#D2)IB0Ub$1K~#9!V;BXaU=)l3Du5USaHXro z2?*d%SBu3EK#;x`rv%D^Y}JYm!)r0Sj~taP&Ij6RWGyBpnFX>qfEh_n*_c5d&QCI> zvc(x<28ZeRxYVR$pNC3-ohFlQPK=S zwq-Q6m?4kXFpZywfq}JrtyI6)CRlXu)4r$#2fy_ASTdWcstOl`IP{!Lr z$q}4Q@9*#J z?d|OB?Cb06>FMj~>FMa`=;r6`=jZ3-SWoSd4PnwOcOnVFfGn3$KB zmzI{6m6es0l$Ml~l#-H?k&%&(j*yLxl8K3lhlhuThK7QKjD&=SgM)*Afr)^CfPa5~ zetv#@e0+I%d3bnuc6N4ead~lZac&xJaBgmHVPRoiU0qUCTvAh4Pf=M>QBh1zR+DW3 z8-F)9H!?CZF)=YJDk>)@CnhE){gx^I0003XNklv!OpUekW{9M%#d(4ZPUQ*=3>t+%jupsa)@mRt z(w#Oz9i9ZUxKu8X!_Az5fu%}ERm3@-Ay-?d*qmF*uuwERfw~s+W%BVU1_!I~@la)Q zF)*n5T5Gz2ldoie5u=w6zqSjbV4x!tgR#4|0RweGDL;f($Y!7~u(am|QL?tWqZQXE d7zINK000%rI%GYlM(qFq002ovPDHLkV1m*LUTpvX diff --git a/tests/ref/par-semantic-align.png b/tests/ref/par-semantic-align.png index eda496411e511b17f950f54a2d827e07af63cc4c..202236efe12c7fbf6612c8b8e879a9f1a62ac7db 100644 GIT binary patch delta 3099 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 delta 3076 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 SSXqSt0000AiPRQN)5^NxsR>+<7xN8h+4u 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 QMF0Q*07*qoM6N<$f=JsB_y7O^ diff --git a/tests/ref/quote-cite-format-label-or-numeric.png b/tests/ref/quote-cite-format-label-or-numeric.png index d1dadf0e298bb70091fcaf2e71012ac55d99d387..22654a0d71815c7a4a75eed58fdc80b187b4a8e7 100644 GIT binary patch delta 2131 zcmV-Z2(0(|5a1AyB!4GKL_t(|+U=R?PgO@0!2M^Mero*Gs7;HMMHB@Q1rbpeaba-* zMMN}M*~FbtD~ehLR44^m6cCVAc94AsaRo(jR|J>#m!4$uA_l_;&3oxg$mF~;bLZaq zoipd2bD8mvZr`NM&`ETH4#;~gCxpFMl_#hH&EKVG(M*=IqoSg~SwczE*ArAwDS zyVS_Y$is&ZpFVx+;NbA}K!=2cR8>_O3+Ue7-km#l;*b6P{cQf;y?bit{rmSF9UUJ& zd{7z>4-bIezkh#U__nvV%jk|BJ03iEfWQz}$>fQN3Awzpv-9Q4m)uCLvv1$N#Kgom zZ{DyV(C^&2Bl^2{@6xNrRegPZWo2bAUc49>7{JmfM@L6xK6e}%8sbN z&mN|oKYt!-bLPxJ#t-)H-CJ5(8W|bc)6?VQr;CYzg^XJlkJJ3FJ{n8MxP z-{0s!BY(KNyVLXNV`F17#JS7O&0W2Ewft0b)~s2BpSrrbBA-5eIxjCzK*K9PKcAmC zyPB_hHeXXygCJK`R8**;z`#JP0(34fEG(2cuV23g`h^P@WTreuQc@Cg5SA`o3Q$lQ z-bKt0VnUqU(TG4-R#vWEyOyJ8V`BrLl`B^&zkdTIrxJYfqQe34Py{C@Cr&Y&?CR>m z={Vr)*RMy0OG!xy?k1OG3{C(mLy#3>Vq&m?4B`6;2?-npZYq}J$P?)6>+8$Q%N6J& zM~<+pL?J6HD;C7%x$mP#kHW*lSsC1%q1m%%V`i2@LtF-~pPwH+GmjrXZY-d2+oGZ( z!ha||k4uk7$WUu*E60;3=J@jy7L0>*doxGg4ZK3Jhs+^@-@0{+Wf@`uE>c`vOtOK; z@Yt+BI5?>D=r1&4$iagLo0^)46}n*gx_^yVG^a#YhPpD;&1gY$NJ)>TbxBlI6r}`j zFy2U0Oe0lTw{D$eGLsuGD-z1ddS1USE-oY+l2DT!t0AhSckkYr9%w#OP=g>$7j#NW z3ONQWr1+kqg@%So!8y52abGVlFDWur60Nckk_rk-G9ps~9UB`P6cmKVxw*NWJ%4+4 z%a$!{gG{cVpn!6YWNXu=O=P1aRlNNvy;O~xnwkn|G$h5y2M!!y4plE{bxuwW)n`4>EaM(ti!aB-bn+9UaY?AiK}Z%;Xlx-B_0nN6-BpU`X8>1))2nZmh^?&vCWn*@BcIZi*QDO-w7=mtVYaAUx#C$CRx&$Sv8$jdN944w2F$@+3@%HW8306D|HI6BP=5@qp8XS$| zNqt2;C1P;Eal>=x&QV-Z8RI+bnJ*G-n@UeEq-=vMP}sRdoAmT_^ejTO!4m;T3_;kn zYnKes_!xBg@@0x+aPt$0+NK*hfVA6e+{~C;QnXjs+#7s^eeUX zZK-KF(^`oZl*;gN@awOrWNF`%{-8-JJZv)R0)^XY-x zb{TFSUO%l?Cc!%E6(4TqeTyQBZ^`l-^fynGR?=GlL;e`gmD_8)&T5uk?p z*7kn-g?3K#Ou&&Z=YJNfOQdgV>A|mWGe& z!^~ODt-aU@G=HV#bs?eQc=s=JZRpL+=V#><60eztj0X8)YF6@v4A!x*TEybaBm^nY z%IdR!vLMQvHt3O&$7jx_NYG^SvbUn*DjbdA(A$5H2%z-%GI|VyOgv?RVE_REqm|W7 zVv=hm)d++IluV{a$;u*E*S2sbu%Zg++=U+KvYM~|HcYY?wJf-iBccwQD(H-iO#Igf zljZ1`*vRN3pQLvjBQhUd8S2VVHw{{Y)}VFMpfzaSH0W>D{soAyv-~P>!!G~;002ov JPDHLkV1hvd3ZDP~ delta 2157 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 delta 2883 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 diff --git a/tests/ref/quote-inline.png b/tests/ref/quote-inline.png index c09faa3a86db6c3accef91c0b6bd06318a1675b3..9205d68392d88d7715fa6e517d0e6f31c07d7b1e 100644 GIT binary patch delta 1458 zcmV;j1x@;$3&abMB!5g$OjJex|Nr9R;$L51=I8C_=jZV7@JB~S`uh6l=;*`4!}b;82J?(XjP_V)Pr_}ACh(9qEG^77u^-ue0YNl8gcN=nw&*7Ni8($dnm zx3}Tp;b353d3kxLsHl;Vk%)+hqN1X$t*v-?cz=I?oSdA1fPa9Uot>+ztEHu-o12?f zR#wr`(W9fI%*@Pzfq|r?q`|?#zP`STi;HS(bXi$hMMXuHmX@-zvS(*!)YQ~bQBh-K zV~L50etv#^eSJbgLi_vsTwGjEPEJ@@Sgfq9%F4<(I5>QKd`L)0)z{xwS66#`d!eDB zRaI50th9=Zlz-UT;*5@#tF5)HuC_@_Q>Ur1Q&nA?ou#t0yg)!euCA_xgoK8MhR@O2 zRaakmdxLIpc-hZAnmzS4{ii$ZoIYdN6US3{nYimU24T3Vo>pr4Cdy?|<)baByyJZsg?TY;0_pn3%D#vADRn+}zw~XlUBn+T-KnP*70m>FM3w-SP49 znVFf#$H&;%*yiTu>gwv%)z$R$^!)t%<>lr5{r&#_{_O1Ryu7@Ridu3200T2gL_t(| z+U?d=ZyQ+vhT(S%v7JmE+u=CmFeini+@{OC+kY-IGc%WYi)oX#VRRg*A!dg8V{2Jf zs@)5#k}Fr0{77>(pY-b793ArV^76>u$_)emQCvbwd{oc>c&5mxq!>VXhP!ccL0bDk z$sLK2ba=ahls_OP=vs1;QT-gag2j8e8{tUUaYS!(tpJfw45(8?iSLQgq9z&vRVY?f z?SB9on{;(S0oDPkr?-~66HExG10Hw8uPg&as}J7k($%@vK|oiQU?xZgjw8XJZ#x06 z<1(;g*IpWX?aCJQme5TouFyTyc`E>y&E=KJnb&5A(spx~;);3YyHn#A&V?T>ee3O> z@S1G=YM8kZgv=uu`f1HM@@^`X|0Ka3>j<3p+kd= zi^Y}JNf@*Lu3?xyE7!xTsYRoIgf$-|KN-uV6;_F1ei^6{GK7!)6)A|H4ZytHsi(Yx z7ZR(6_UhdR`{`2<+YzbM@2`O^-O2*?m2E$vg|U+cR99oR)t?SWu7{pf7ewlP*MEGg zmtbMhQ-Dl(c1o+d8b59iWeU$pRgzlrgYkk-N12q696X^cmyG%v0Tfb5s@3*GxK~L2 zstV7lVSRu1Ew>b9=>$yQ=#NT)Z6$G^@I6!;Ym+NO^{mS+1vsPO-kSXMGb_1PSd{ef zCq+etpO;U@#wRBfQ!ghLO6nxum1CK0N z;{L@&-O%EA*kT?pe=2m~@B{$zO``bnOQN_b+wjCidd4QdX*5YUQzteUAAbXS;=kD} zG(SI7JG*FEGB=y1`u=7k{7Uv=ZTEx)Q)}ew25`pC&%x%}I^b6ZjPoi@!;o$~xTEKKbp;QX8IS@4DuEqZdp^uL&s5MxX2S?|i(xY2i=2UCq?@^$vI% zNQXc8@S{{Xr{rIKDJH%5ek;zypMN1H9eh)s^D8s0F#mAO%e#;M1-wcAMj#&85dZ)H M07*qoM6N<$f>`GzX#fBK delta 1419 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≪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%0D39lxc;L146rmSK^d?(F81; z=LPg5uQr>_D%|)_cO5)HAypxG0bN!=7tjTCSpi)@mu>7P8m>=cExjdq00000NkvXX Hu0mjfnwp}d diff --git a/tests/ref/ref-form-page-unambiguous.png b/tests/ref/ref-form-page-unambiguous.png index e7baa2f2ca7c99f8b6edfbe87486662b1942eda0..3b37f115b88c380a360f39b7d06417118501ade4 100644 GIT binary patch delta 2923 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 V(Ds{Z-01)S002ovPDHLkV1mQ$p!Wa( delta 2853 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 D37&G( diff --git a/tests/ref/ref-form-page.png b/tests/ref/ref-form-page.png index 52fde86d742c523fcf60dc5b6f6932236dac92b6..0cc29a4f1ebf1f6266cc0212249a0c8597dad4a8 100644 GIT binary patch delta 3559 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 delta 3590 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 M07*qoM6N<$f;ONju>b%7 diff --git a/tests/ref/ref-supplements.png b/tests/ref/ref-supplements.png index fd715339f7467d29b3e6275cf2c63ec74f9eec06..e400c1feb523adfd89e1f09f9e7b35712f631658 100644 GIT binary patch literal 8167 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 N002ovPDHLkV1fo(kr@C0 literal 8266 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 diff --git a/tests/ref/show-text-in-citation.png b/tests/ref/show-text-in-citation.png index 392487bc8a53191a74ffe07174bfa3f61f27e2ae..6533a4f7c914d47cdbb5fa20e70400260418ba3c 100644 GIT binary patch delta 788 zcmV+v1MB>o2CD{;B!8PwOjJex|Nq_J=ilJx-Neh^+~YYrL)zTr+S}v4z{n;jFz@g4 z-rwcj)7#zD+uhF9-NMMh!^?Vngr%pi-G+&ckC)%xZ-Kws&w|~F8y~W+Iw%x$Sz{1LA zXmCG4Ny*F8tgg0NTx4!=c&@OxPf=NEYjeoT(w?BGxx2%Ff{NW`X5H1@-K($N(AaEj zY#|{b@$vDHkdW@-FMc-iHVMmj!sTa ze0+Q&A|j@yrhk!Yv$)-Se%;#Q z-Dhdtw!Gb@tKF8Fv$M0^pQEv{vE6}$-Obb~DJeNQIVUG4=H})kBqa0m^B^D~_xJZ$ zSXhmXjg*v>#l^+?`ue1#q}`5^r>Cd5xVYW2xZT6a-G9u}+}`H*_xSt!`}_O+-FJE2 zsjl7A+AS?Dxw*Ob_xbMc^BM<^K>z>&OG!jQRCwC$*Hv~xQ4mDYs!QD6-QC^Y-QC?? zNFW4waEF}_m_j-$$=ee+f7PpDGW}&Bp$90A>*i;?Hceb zF3itChJU?*L9wA!Ev*{Bnd4~|GD;dr22yck7=UqkJOddyss8Zj5CFq^KRp2vStgfvHJ|NUbA-LaA7#0d)6Z?HM96YsrM9WK~hA0Z&)&>my{arkW&GO1Aibl#*m{ z2!+GSCtRs8^GJMmIEo|#+~VPP3vC^ps*D=&oPR$&Sf#>FS`OOVRC%EWaN)SUfecGL zBO{ob$lQDY#?#{xWE2#Ubmb`y3<5BoSC>~HBEFd1+oxiGpYKR)u0zDp3vDmVNawBw zaCME%4T#v;=9|-*rcO^Z;JLehJ%kKyMUbR&xy1)0nj`}(KRB7@TV*o+CSO93Kupr0 StqrXJ0000DJCW+larH? zk&&jRrkg(_5>Fp~mH6bA(Y;0`t@$rz5 zklx?p$;!?X$!5)ljOr?qUo_a<1V9hS$bpQSO8?=mP5^pfXm1Zh)YkbAcXt5L zZRqQVh{}4>?V`bX4M0m9VuB%}u|d}--;T{k!}4L}c$%*}&{lMV7{jRq&Dex5{DAw+DS({awx5zcA=!b6Z%1Q9Dk!^amF z<6|>(uYe^YG$<271_xle_CVUR8Q+UqQbkdI+2L06!!M^?CtTuz`#*aQD9(TKtMo{kdPuG zBBG+A+uPgL*4EhA*s-y(%F4=ld3nUd#QXdEs;a84uC6XFE-fuBVq#*6iHVh!l|@BG zw6wG|G&FmAdv$en3JMCOq@?%v_gGk1($dn`*Vh*p7v|>Xlc20uSP zY;0`E$jB%tD6+D$b8~ZQYHB+>J5*FudVGXILrdJ==7))qwz$B|&elXmPPe(iZg6B%GaDOI;4`~Te_*> z(9$Zt6$=viioHvuuGT25v3!+;@3Ws8`MAC^4nI7r9)BOH-n|)zbE>B&ud14}59?4W;8^&TV!@7K)5rNkj43=eYm4NAq#VDyj&F- z-roIj7*OmJ-?nzX9R~XOD**tYa@7RF$pc{2WYlETWYlETWYlETWYlET+9 zsi_JI3UzgLdwY9DMMbf(vDnzy*4Ea<#Kd`ddE49DqN1WAA|hg9VvvxKKtMoXU|>;E zQI(aIz`(%v_J8)>-QDr=@mE(@sHmt!L`1v0y8r+HUH_Wr0003NNkl!aet9j(@zDpDE0W>KnUclz zmAE!FT}rgLyx?02007Zd4TOIl0HY@F$Bdecnv9x^nv9y Date: Tue, 20 May 2025 14:57:19 +0200 Subject: [PATCH 101/558] Underline file path of failed test (#6281) --- tests/src/collect.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/src/collect.rs b/tests/src/collect.rs index 33f4f73668..84af04d2dc 100644 --- a/tests/src/collect.rs +++ b/tests/src/collect.rs @@ -30,7 +30,8 @@ pub struct Test { impl Display for Test { fn fmt(&self, f: &mut Formatter) -> fmt::Result { - write!(f, "{} ({})", self.name, self.pos) + // underline path + write!(f, "{} (\x1B[4m{}\x1B[0m)", self.name, self.pos) } } From 300a782451082e5d7bdf894f0cc756261076008b Mon Sep 17 00:00:00 2001 From: frozolotl <44589151+frozolotl@users.noreply.github.com> Date: Tue, 20 May 2025 15:54:49 +0200 Subject: [PATCH 102/558] Always run tests from workspace directory (#6307) --- tests/src/tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/src/tests.rs b/tests/src/tests.rs index 26eb63beb5..0ed2fa4693 100644 --- a/tests/src/tests.rs +++ b/tests/src/tests.rs @@ -59,7 +59,9 @@ fn main() { fn setup() { // Make all paths relative to the workspace. That's nicer for IDEs when // clicking on paths printed to the terminal. - std::env::set_current_dir("..").unwrap(); + let workspace_dir = + Path::new(env!("CARGO_MANIFEST_DIR")).join(std::path::Component::ParentDir); + std::env::set_current_dir(workspace_dir).unwrap(); // Create the storage. for ext in ["render", "html", "pdf", "svg"] { From e90c2f74ef63d92fc160ba5ba04b780c1a64fe75 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 20 May 2025 16:20:40 +0000 Subject: [PATCH 103/558] Fix text overhang example in docs (#6223) --- crates/typst-library/src/text/mod.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/typst-library/src/text/mod.rs b/crates/typst-library/src/text/mod.rs index 462d16060c..23edc9e98a 100644 --- a/crates/typst-library/src/text/mod.rs +++ b/crates/typst-library/src/text/mod.rs @@ -348,15 +348,17 @@ pub struct TextElem { /// This can make justification visually more pleasing. /// /// ```example + /// #set page(width: 220pt) + /// /// #set par(justify: true) /// This justified text has a hyphen in - /// the paragraph's first line. Hanging + /// the paragraph's second line. Hanging /// the hyphen slightly into the margin /// results in a clearer paragraph edge. /// /// #set text(overhang: false) /// This justified text has a hyphen in - /// the paragraph's first line. Hanging + /// the paragraph's second line. Hanging /// the hyphen slightly into the margin /// results in a clearer paragraph edge. /// ``` From d42d2ed200c8f3f167ee09be69fcf86f4b645971 Mon Sep 17 00:00:00 2001 From: frozolotl <44589151+frozolotl@users.noreply.github.com> Date: Tue, 20 May 2025 18:24:46 +0200 Subject: [PATCH 104/558] Error if an unexpected named argument was received (#6192) --- crates/typst-eval/src/call.rs | 14 ++++++++------ tests/ref/math-call-symbol.png | Bin 0 -> 703 bytes tests/suite/math/call.typ | 9 +++++++++ 3 files changed, 17 insertions(+), 6 deletions(-) create mode 100644 tests/ref/math-call-symbol.png diff --git a/crates/typst-eval/src/call.rs b/crates/typst-eval/src/call.rs index 1ca7b4b8fb..6a57c85e80 100644 --- a/crates/typst-eval/src/call.rs +++ b/crates/typst-eval/src/call.rs @@ -404,12 +404,14 @@ fn wrap_args_in_math( if trailing_comma { body += SymbolElem::packed(','); } - Ok(Value::Content( - callee.display().spanned(callee_span) - + LrElem::new(SymbolElem::packed('(') + body + SymbolElem::packed(')')) - .pack() - .spanned(args.span), - )) + + let formatted = callee.display().spanned(callee_span) + + LrElem::new(SymbolElem::packed('(') + body + SymbolElem::packed(')')) + .pack() + .spanned(args.span); + + args.finish()?; + Ok(Value::Content(formatted)) } /// Provide a hint if the callee is a shadowed standard library function. diff --git a/tests/ref/math-call-symbol.png b/tests/ref/math-call-symbol.png new file mode 100644 index 0000000000000000000000000000000000000000..8308bece1fba2d652a82e225c94b1212a90f1025 GIT binary patch literal 703 zcmV;w0zmzVP)71w8wCUzNE$5E!E}jP(cDv_^;`hG!e(qlIT>PFB{H0C^Sik}nFzUlUjndufRT-{{ zA@$j%D!l&!xp<*Ua5t-|8Vp7lZh2Crg&XR?W1HxL=5_5nj7H8JIpMB4a4V}zG@zXW zMhl38Mjd#*SnIADprajEb3xmrL$yzh<5bB41j;#Rnr<9~Xt7H0N)Z5WqeykGs|3$} z10dK109%9NtroC=1uS3z|1RFhvFu+}fm?hWuQsR#{FFahey1pGWH0TS*I5d)jgM>Y z17OPfvHcQmDhcmcsNdT(^|iqSXG2Z^#;^MgI?yNy4|&2P^sNsz1G_0i00>>=fgSEB z35T*Orrx*VdQP~krC_5Uy$`(Eq0V~SkIeGm}^lKj${B} zT0?5;5VBVvZh$_jD7@1vT?0}cVDVD`>~rF+7O;Q?EZ~0yjw=K=g~5wd&>8^b7JSmR z+7yA!P8^k2q`hBZIX8n`?!u!QY3X80!1=(M41dX>CI`7^hjqEq8tG0dY#@|=)gHK) z0CPaT9^{5H-O||2P9_{P(dv&iIJAi{&h$9Q?c_BgrAa2dG(kU8vy0`4u)i#}j8veK z$&*OkP9{vwYh!I4Aq`-=5NEOjj#4YN)s%+)TJT+>DFwX?@Z=>G4qq;_0!6S=|DhGP z4!-O4`gwHjqY}XO@l}9fGte`hRG2gXX2t*@B44n81v#4^s3xg!v|GC7FaUcI Date: Tue, 20 May 2025 18:25:26 +0200 Subject: [PATCH 105/558] Removing unused warnings in nightly (#6169) --- crates/typst-library/src/foundations/content.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/typst-library/src/foundations/content.rs b/crates/typst-library/src/foundations/content.rs index daf6c2dd9a..1855bb70b4 100644 --- a/crates/typst-library/src/foundations/content.rs +++ b/crates/typst-library/src/foundations/content.rs @@ -414,7 +414,7 @@ impl Content { /// Elements produced in `show` rules will not be included in the results. pub fn query(&self, selector: Selector) -> Vec { let mut results = Vec::new(); - self.traverse(&mut |element| -> ControlFlow<()> { + let _ = self.traverse(&mut |element| -> ControlFlow<()> { if selector.matches(&element, None) { results.push(element); } @@ -441,7 +441,7 @@ impl Content { /// Extracts the plain text of this content. pub fn plain_text(&self) -> EcoString { let mut text = EcoString::new(); - self.traverse(&mut |element| -> ControlFlow<()> { + let _ = self.traverse(&mut |element| -> ControlFlow<()> { if let Some(textable) = element.with::() { textable.plain_text(&mut text); } From df89a0e85b80844ef56a6fa98af01eaaf7553da8 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Tue, 20 May 2025 18:27:14 +0200 Subject: [PATCH 106/558] Use the right multiplication symbol in expression tooltip (#6163) --- crates/typst-ide/src/tooltip.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/typst-ide/src/tooltip.rs b/crates/typst-ide/src/tooltip.rs index cbfffe5305..2638ce51b8 100644 --- a/crates/typst-ide/src/tooltip.rs +++ b/crates/typst-ide/src/tooltip.rs @@ -86,7 +86,7 @@ fn expr_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option { *count += 1; continue; } else if *count > 1 { - write!(pieces.last_mut().unwrap(), " (x{count})").unwrap(); + write!(pieces.last_mut().unwrap(), " (×{count})").unwrap(); } } pieces.push(value.repr()); @@ -95,7 +95,7 @@ fn expr_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option { if let Some((_, count)) = last { if count > 1 { - write!(pieces.last_mut().unwrap(), " (x{count})").unwrap(); + write!(pieces.last_mut().unwrap(), " (×{count})").unwrap(); } } From 2a258a0c3849073c56a0559dbd67ee2effcd1031 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Fri, 23 May 2025 09:31:26 +0200 Subject: [PATCH 107/558] Remove unused Marginal type (#6321) --- crates/typst-library/src/layout/page.rs | 43 ++----------------------- 1 file changed, 2 insertions(+), 41 deletions(-) diff --git a/crates/typst-library/src/layout/page.rs b/crates/typst-library/src/layout/page.rs index 62e25278a6..98afbd06f0 100644 --- a/crates/typst-library/src/layout/page.rs +++ b/crates/typst-library/src/layout/page.rs @@ -1,16 +1,14 @@ -use std::borrow::Cow; use std::num::NonZeroUsize; use std::ops::RangeInclusive; use std::str::FromStr; -use comemo::Track; use typst_utils::{singleton, NonZeroExt, Scalar}; use crate::diag::{bail, SourceResult}; use crate::engine::Engine; use crate::foundations::{ - cast, elem, Args, AutoValue, Cast, Construct, Content, Context, Dict, Fold, Func, - NativeElement, Set, Smart, StyleChain, Value, + cast, elem, Args, AutoValue, Cast, Construct, Content, Dict, Fold, NativeElement, + Set, Smart, Value, }; use crate::introspection::Introspector; use crate::layout::{ @@ -649,43 +647,6 @@ cast! { }, } -/// A header, footer, foreground or background definition. -#[derive(Debug, Clone, Hash)] -pub enum Marginal { - /// Bare content. - Content(Content), - /// A closure mapping from a page number to content. - Func(Func), -} - -impl Marginal { - /// Resolve the marginal based on the page number. - pub fn resolve( - &self, - engine: &mut Engine, - styles: StyleChain, - page: usize, - ) -> SourceResult> { - Ok(match self { - Self::Content(content) => Cow::Borrowed(content), - Self::Func(func) => Cow::Owned( - func.call(engine, Context::new(None, Some(styles)).track(), [page])? - .display(), - ), - }) - } -} - -cast! { - Marginal, - self => match self { - Self::Content(v) => v.into_value(), - Self::Func(v) => v.into_value(), - }, - v: Content => Self::Content(v), - v: Func => Self::Func(v), -} - /// A list of page ranges to be exported. #[derive(Debug, Clone)] pub struct PageRanges(Vec); From 6e0f48e192ddbd934d3aadd056810c86bcc3defd Mon Sep 17 00:00:00 2001 From: Igor Khanin Date: Wed, 28 May 2025 16:05:10 +0300 Subject: [PATCH 108/558] More precise math font autocomplete suggestions (#6316) --- crates/typst-ide/src/complete.rs | 45 ++++++++++++++++++++-- crates/typst-ide/src/tooltip.rs | 2 +- crates/typst-ide/src/utils.rs | 11 ++---- crates/typst-library/src/text/font/book.rs | 3 ++ 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index 91fa53f9a5..15b4296eb7 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -15,7 +15,7 @@ use typst::syntax::{ ast, is_id_continue, is_id_start, is_ident, FileId, LinkedNode, Side, Source, SyntaxKind, }; -use typst::text::RawElem; +use typst::text::{FontFlags, RawElem}; use typst::visualize::Color; use unscanny::Scanner; @@ -1081,6 +1081,24 @@ fn code_completions(ctx: &mut CompletionContext, hash: bool) { } } +/// See if the AST node is somewhere within a show rule applying to equations +fn is_in_equation_show_rule(leaf: &LinkedNode<'_>) -> bool { + let mut node = leaf; + while let Some(parent) = node.parent() { + if_chain! { + if let Some(expr) = parent.get().cast::(); + if let ast::Expr::ShowRule(show) = expr; + if let Some(ast::Expr::FieldAccess(field)) = show.selector(); + if field.field().as_str() == "equation"; + then { + return true; + } + } + node = parent; + } + false +} + /// Context for autocompletion. struct CompletionContext<'a> { world: &'a (dyn IdeWorld + 'a), @@ -1152,10 +1170,12 @@ impl<'a> CompletionContext<'a> { /// Add completions for all font families. fn font_completions(&mut self) { - let equation = self.before_window(25).contains("equation"); + let equation = is_in_equation_show_rule(self.leaf); for (family, iter) in self.world.book().families() { - let detail = summarize_font_family(iter); - if !equation || family.contains("Math") { + let variants: Vec<_> = iter.collect(); + let is_math = variants.iter().any(|f| f.flags.contains(FontFlags::MATH)); + let detail = summarize_font_family(variants); + if !equation || is_math { self.str_completion( family, Some(CompletionKind::Font), @@ -1790,4 +1810,21 @@ mod tests { .must_include(["r", "dashed"]) .must_exclude(["cases"]); } + + #[test] + fn test_autocomplete_fonts() { + test("#text(font:)", -1) + .must_include(["\"Libertinus Serif\"", "\"New Computer Modern Math\""]); + + test("#show link: set text(font: )", -1) + .must_include(["\"Libertinus Serif\"", "\"New Computer Modern Math\""]); + + test("#show math.equation: set text(font: )", -1) + .must_include(["\"New Computer Modern Math\""]) + .must_exclude(["\"Libertinus Serif\""]); + + test("#show math.equation: it => { set text(font: )\nit }", -6) + .must_include(["\"New Computer Modern Math\""]) + .must_exclude(["\"Libertinus Serif\""]); + } } diff --git a/crates/typst-ide/src/tooltip.rs b/crates/typst-ide/src/tooltip.rs index 2638ce51b8..e5e4cc19a6 100644 --- a/crates/typst-ide/src/tooltip.rs +++ b/crates/typst-ide/src/tooltip.rs @@ -269,7 +269,7 @@ fn font_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option { .find(|&(family, _)| family.to_lowercase().as_str() == lower.as_str()); then { - let detail = summarize_font_family(iter); + let detail = summarize_font_family(iter.collect()); return Some(Tooltip::Text(detail)); } }; diff --git a/crates/typst-ide/src/utils.rs b/crates/typst-ide/src/utils.rs index d5d584e2ba..887e851f91 100644 --- a/crates/typst-ide/src/utils.rs +++ b/crates/typst-ide/src/utils.rs @@ -77,23 +77,20 @@ pub fn plain_docs_sentence(docs: &str) -> EcoString { } /// Create a short description of a font family. -pub fn summarize_font_family<'a>( - variants: impl Iterator, -) -> EcoString { - let mut infos: Vec<_> = variants.collect(); - infos.sort_by_key(|info| info.variant); +pub fn summarize_font_family(mut variants: Vec<&FontInfo>) -> EcoString { + variants.sort_by_key(|info| info.variant); let mut has_italic = false; let mut min_weight = u16::MAX; let mut max_weight = 0; - for info in &infos { + for info in &variants { let weight = info.variant.weight.to_number(); has_italic |= info.variant.style == FontStyle::Italic; min_weight = min_weight.min(weight); max_weight = min_weight.max(weight); } - let count = infos.len(); + let count = variants.len(); let mut detail = eco_format!("{count} variant{}.", if count == 1 { "" } else { "s" }); if min_weight == max_weight { diff --git a/crates/typst-library/src/text/font/book.rs b/crates/typst-library/src/text/font/book.rs index 9f8acce878..cd90a08fe6 100644 --- a/crates/typst-library/src/text/font/book.rs +++ b/crates/typst-library/src/text/font/book.rs @@ -194,6 +194,8 @@ bitflags::bitflags! { const MONOSPACE = 1 << 0; /// Glyphs have short strokes at their stems. const SERIF = 1 << 1; + /// Font face has a MATH table + const MATH = 1 << 2; } } @@ -272,6 +274,7 @@ impl FontInfo { let mut flags = FontFlags::empty(); flags.set(FontFlags::MONOSPACE, ttf.is_monospaced()); + flags.set(FontFlags::MATH, ttf.tables().math.is_some()); // Determine whether this is a serif or sans-serif font. if let Some(panose) = ttf From 9bbfa5ae0593333b1f0afffd71fec198d61742a6 Mon Sep 17 00:00:00 2001 From: Shunsuke KIMURA Date: Wed, 28 May 2025 22:29:45 +0900 Subject: [PATCH 109/558] Clarify localization of reference labels based on lang setting (#6249) Signed-off-by: Shunsuke Kimura --- crates/typst-library/src/model/reference.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/typst-library/src/model/reference.rs b/crates/typst-library/src/model/reference.rs index 316617688f..7d44cccc09 100644 --- a/crates/typst-library/src/model/reference.rs +++ b/crates/typst-library/src/model/reference.rs @@ -21,9 +21,10 @@ use crate::text::TextElem; /// /// The default, a `{"normal"}` reference, produces a textual reference to a /// label. For example, a reference to a heading will yield an appropriate -/// string such as "Section 1" for a reference to the first heading. The -/// references are also links to the respective element. Reference syntax can -/// also be used to [cite] from a bibliography. +/// string such as "Section 1" for a reference to the first heading. The word +/// "Section" depends on the [`lang`]($text.lang) setting and is localized +/// accordingly. The references are also links to the respective element. +/// Reference syntax can also be used to [cite] from a bibliography. /// /// As the default form requires a supplement and numbering, the label must be /// attached to a _referenceable element_. Referenceable elements include From 9ac21b8524632c70ab9e090488a70085eabe4189 Mon Sep 17 00:00:00 2001 From: Igor Khanin Date: Wed, 28 May 2025 16:41:35 +0300 Subject: [PATCH 110/558] Fix tracing of most field call expressions (#6234) --- crates/typst-eval/src/call.rs | 7 ++++++- crates/typst-ide/src/tooltip.rs | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/typst-eval/src/call.rs b/crates/typst-eval/src/call.rs index 6a57c85e80..fa96834164 100644 --- a/crates/typst-eval/src/call.rs +++ b/crates/typst-eval/src/call.rs @@ -37,7 +37,12 @@ impl Eval for ast::FuncCall<'_> { let target = access.target(); let field = access.field(); match eval_field_call(target, field, args, span, vm)? { - FieldCall::Normal(callee, args) => (callee, args), + FieldCall::Normal(callee, args) => { + if vm.inspected == Some(callee_span) { + vm.trace(callee.clone()); + } + (callee, args) + } FieldCall::Resolved(value) => return Ok(value), } } else { diff --git a/crates/typst-ide/src/tooltip.rs b/crates/typst-ide/src/tooltip.rs index e5e4cc19a6..528f679cf1 100644 --- a/crates/typst-ide/src/tooltip.rs +++ b/crates/typst-ide/src/tooltip.rs @@ -371,4 +371,11 @@ mod tests { test(&world, -2, Side::Before).must_be_none(); test(&world, -2, Side::After).must_be_text("This star imports `a`, `b`, and `c`"); } + + #[test] + fn test_tooltip_field_call() { + let world = TestWorld::new("#import \"other.typ\"\n#other.f()") + .with_source("other.typ", "#let f = (x) => 1"); + test(&world, -4, Side::After).must_be_code("(..) => .."); + } } From 9a95966302bae4d795cd2fba4b3beb6f41629221 Mon Sep 17 00:00:00 2001 From: Ian Wrzesinski <133046678+wrzian@users.noreply.github.com> Date: Wed, 28 May 2025 09:44:44 -0400 Subject: [PATCH 111/558] Remove line break opportunity when math operator precededes a closing paren (#6216) --- crates/typst-layout/src/math/run.rs | 31 ++++++++++-------- ...ebreaking-after-relation-without-space.png | Bin 439 -> 2630 bytes tests/suite/math/multiline.typ | 3 ++ 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/crates/typst-layout/src/math/run.rs b/crates/typst-layout/src/math/run.rs index ae64368d6b..4ec76c2534 100644 --- a/crates/typst-layout/src/math/run.rs +++ b/crates/typst-layout/src/math/run.rs @@ -278,6 +278,9 @@ impl MathRun { frame } + /// Convert this run of math fragments into a vector of inline items for + /// paragraph layout. Creates multiple fragments when relation or binary + /// operators are present to allow for line-breaking opportunities later. pub fn into_par_items(self) -> Vec { let mut items = vec![]; @@ -295,21 +298,24 @@ impl MathRun { let mut space_is_visible = false; - let is_relation = |f: &MathFragment| matches!(f.class(), MathClass::Relation); let is_space = |f: &MathFragment| { matches!(f, MathFragment::Space(_) | MathFragment::Spacing(_, _)) }; + let is_line_break_opportunity = |class, next_fragment| match class { + // Don't split when two relations are in a row or when preceding a + // closing parenthesis. + MathClass::Binary => next_fragment != Some(MathClass::Closing), + MathClass::Relation => { + !matches!(next_fragment, Some(MathClass::Relation | MathClass::Closing)) + } + _ => false, + }; let mut iter = self.0.into_iter().peekable(); while let Some(fragment) = iter.next() { - if space_is_visible { - match fragment { - MathFragment::Space(width) | MathFragment::Spacing(width, _) => { - items.push(InlineItem::Space(width, true)); - continue; - } - _ => {} - } + if space_is_visible && is_space(&fragment) { + items.push(InlineItem::Space(fragment.width(), true)); + continue; } let class = fragment.class(); @@ -323,10 +329,9 @@ impl MathRun { frame.push_frame(pos, fragment.into_frame()); empty = false; - if class == MathClass::Binary - || (class == MathClass::Relation - && !iter.peek().map(is_relation).unwrap_or_default()) - { + // Split our current frame when we encounter a binary operator or + // relation so that there is a line-breaking opportunity. + if is_line_break_opportunity(class, iter.peek().map(|f| f.class())) { let mut frame_prev = std::mem::replace(&mut frame, Frame::soft(Size::zero())); diff --git a/tests/ref/math-linebreaking-after-relation-without-space.png b/tests/ref/math-linebreaking-after-relation-without-space.png index 7c569ad1fd2166800e30b88c2f5ca689be659341..fb14137680a55a42a8717f11fe555378489031c0 100644 GIT binary patch literal 2630 zcmb7G4^UJ09S>FfgJq7Q(iS0|=-7reb#CN3kVplsaT0hc{*c1LB%sLZ0GU zN)f2VmKN?{U5|2=K}xM4%bzEI0|i6`it8FYoR5OK^AAdfV;@xqI*X ze))bs|Gux}jpW0D0nZ087>vLpuO_?+zD3~k(S~*4f4Tf8(F{gF;E{y*-;4Z=H`n}q z=N~@l#c83btZ%U4Oj1U3j;3|>@6@H*PhOMN{(9dIlk=Unx6gf99FsMk(mIq%bAP6p zze+cLn3F$Fr}nkBFVep{sY$mYTffO^cA)|HqPZUayV*63liouQ2?w1QJJ25K{>$!V(8~qym0ptp}j2hGksV=yEF}z!@)W}Ed z`{_g8o0m#MSeDb6?X+Y*Q~Glm`JHSK4Tili4F;*n;Hcy{gL#I(@`@+K$_c%0!@X!$ zkx*1DynmIJaBLZz-ZdVZfR09OS!i#RY9`8ZRA4P<#LH#h_DCdK7#eL zy`h3xDPrX!Jp!^41Zy%+9j4I7s3Ix~mouE;nLnxwVpTEbD96Cq zdq2z}1Y>FX38`^Hq-RSr7}Dtw=>@j<8e8wX%RDX+RH!)=uDBDWdeL!)v zBO^hfp`l*wT%}u1%Z<@0yBMtsj!ZV6VA@Ws-pC859cm(iV~Lm)>B|(Lt;lQlE+K26 zrZ~MO)1Er~xw?)<)ncQi9Q1qTO^5IRbkY~(Sc+#0;;cgYLlqsN=4zRr0~D-fdG4^lK~BmgqpJCJb}&glrn`kX zfIjo65FeYCkeMeC&qO-nv8IcS*Fm%4kXN_cO_IZOETA6iOr@h>Ln~YP_*O|@v19|l z18DpyScrMF*pLvh3jv!xQQurIVd!i*y55JPj%TjKO9t8`n^r|VB37uyX2_?{D7=I^ z%P;FXLkdSid}1#M+K-o{%(iMy>jSm-18*!M{wFnL6uCT1?UqrCvRnO?ksv@~;l;D1hJc@@-FsA-$KN=Ns# zI#bLrH55Pq?CVFHU4o0iEEmV$?y-Y$@}@ie;~rGTm8?%|Ix3mtN_W+{zN!NnZUVxT zeT|~S!omq*3;2crCb>XBMKd@x2X3-XwVKmM- zdSej;ZU_SFmlNo)aAt#^P2H~p(wzl(3#1r+f# zxE8bB8_XMTLI{b^nXbnH@ zqgWHvK@h-v9TI|#?DPF>$jC4`IBYf`ltE!yQtgouzms+4XEou|Oln#($}S9EZLePE zYSPWHrFypLU9QMqyV#`tz`Y+vdM0AabatnZ31I*E0iX|ovyfZRdiY)@v6n&K-t76Q zm-4P~+e@oOe%S=nss#Gc7j-lPSvib!Cn?90T3TAb(t+Xn*w$BU(9XQ5Eo!91^8FiS@oF=oT`IZ3{26RJ} zSBXlh824*b4{Agp4ETS(_S{3F_|zPK-Y&)LqOu85pw>~Qd-lAJ_UH_?V$(F73Xr|_ zq(AFb5e37tUa#^7}^o0NhHpOeC7`8Rvs&^m3Jp&m?N`Fx_PqHN%>pa6Bz-*L?2?dcZqPE)DS$d#f0VGHijBy=Syq68$IcU}B1?`b`pr`|&TRJ@Yy=mPJD Oj3bH33C*t@FZws;1~!fW delta 426 zcmV;b0agCS6t@GA7k_F900000QW46Y0004gNkl2#n5REgAXKhDI)yq{y_C3#65@6e+Qmv7MUKF1EJWOj|y3?vLF3zMt6l{RCch zKIeRo2c$d-E3B}>3cL0ip}^16;Ti$Hv$j4MkGj-%$=W#KJAZYD2aE*(=M#r+@Sd^Y z^rQzwVTH}ZZzQN{Y`U?+9S67K6u7bG&!C=_ zhRch=0)U&4FkI?MlQyod1oF<#@*6idta~I!=ZZ<4Q^|m%a^qPJRMh5r=%1qx^C UMq-$2r~m)}07*qoM6N<$f)`iH{Qv*} diff --git a/tests/suite/math/multiline.typ b/tests/suite/math/multiline.typ index 34e66b99c3..70838dd8c7 100644 --- a/tests/suite/math/multiline.typ +++ b/tests/suite/math/multiline.typ @@ -99,6 +99,9 @@ Multiple trailing line breaks. #let hrule(x) = box(line(length: x)) #hrule(90pt)$<;$\ #hrule(95pt)$<;$\ +// We don't linebreak before a closing paren, but do before an opening paren. +#hrule(90pt)$<($\ +#hrule(95pt)$<($ #hrule(90pt)$<)$\ #hrule(95pt)$<)$ From 82e869023c7a7f31d716e7706a9a176b3d909279 Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Wed, 28 May 2025 17:14:29 +0300 Subject: [PATCH 112/558] Add remaining height example for layout (#6266) Co-authored-by: Laurenz --- crates/typst-library/src/layout/layout.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/typst-library/src/layout/layout.rs b/crates/typst-library/src/layout/layout.rs index 88252e5e3c..04aaee944c 100644 --- a/crates/typst-library/src/layout/layout.rs +++ b/crates/typst-library/src/layout/layout.rs @@ -41,8 +41,23 @@ use crate::layout::{BlockElem, Size}; /// receives the page's dimensions minus its margins. This is mostly useful in /// combination with [measurement]($measure). /// -/// You can also use this function to resolve [`ratio`] to fixed lengths. This -/// might come in handy if you're building your own layout abstractions. +/// To retrieve the _remaining_ size of the page rather than its full size, you +/// you can wrap your `layout` call in a `{block(height: 1fr)}`. This works +/// because the block automatically grows to fill the remaining space (see the +/// [fraction] documentation for more details). +/// +/// ```example +/// #set page(height: 150pt) +/// +/// #lorem(20) +/// +/// #block(height: 1fr, layout(size => [ +/// Remaining height: #size.height +/// ])) +/// ``` +/// +/// You can also use this function to resolve a [`ratio`] to a fixed length. +/// This might come in handy if you're building your own layout abstractions. /// /// ```example /// #layout(size => { From 3e7a39e968644ee925598f792fdc597b55a2529f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj?= <171210953+mgazeel@users.noreply.github.com> Date: Wed, 28 May 2025 19:29:40 +0200 Subject: [PATCH 113/558] Fix stroking of glyphs in math mode (#6243) --- crates/typst-layout/src/math/fragment.rs | 6 ++++-- tests/ref/issue-6170-equation-stroke.png | Bin 0 -> 1381 bytes tests/suite/math/equation.typ | 7 +++++++ 3 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 tests/ref/issue-6170-equation-stroke.png diff --git a/crates/typst-layout/src/math/fragment.rs b/crates/typst-layout/src/math/fragment.rs index 1b508a3494..59858a9cb6 100644 --- a/crates/typst-layout/src/math/fragment.rs +++ b/crates/typst-layout/src/math/fragment.rs @@ -11,7 +11,7 @@ use typst_library::layout::{ }; use typst_library::math::{EquationElem, MathSize}; use typst_library::text::{Font, Glyph, Lang, Region, TextElem, TextItem}; -use typst_library::visualize::Paint; +use typst_library::visualize::{FixedStroke, Paint}; use typst_syntax::Span; use typst_utils::default_math_class; use unicode_math_class::MathClass; @@ -235,6 +235,7 @@ pub struct GlyphFragment { pub lang: Lang, pub region: Option, pub fill: Paint, + pub stroke: Option, pub shift: Abs, pub width: Abs, pub ascent: Abs, @@ -286,6 +287,7 @@ impl GlyphFragment { lang: TextElem::lang_in(styles), region: TextElem::region_in(styles), fill: TextElem::fill_in(styles).as_decoration(), + stroke: TextElem::stroke_in(styles).map(|s| s.unwrap_or_default()), shift: TextElem::baseline_in(styles), font_size: TextElem::size_in(styles), math_size: EquationElem::size_in(styles), @@ -368,10 +370,10 @@ impl GlyphFragment { font: self.font.clone(), size: self.font_size, fill: self.fill, + stroke: self.stroke, lang: self.lang, region: self.region, text: self.c.into(), - stroke: None, glyphs: vec![Glyph { id: self.id.0, x_advance: Em::from_length(self.width, self.font_size), diff --git a/tests/ref/issue-6170-equation-stroke.png b/tests/ref/issue-6170-equation-stroke.png new file mode 100644 index 0000000000000000000000000000000000000000..a375931b50514adcfc0800fc1d4ac51b08e1bf06 GIT binary patch literal 1381 zcmb7^ZA?>F7{`muxiJ}#Wunqiw>dj?SQRF?%B_l(p_A%-Y0R9}RmLWkRvFsDrPqnC zj2AZ}5J;)cOcsfB11&P}URcGVQtE=0nL;n6wFPV6q4e$ctYrJ(2fr+N&hvaZ=j6%% z|I7c}*6hsasQFPO5-B?C(+!`8r!ah0y!L9i$0hTQl1MW@%G$7QhcLq06?tjl?&`Zv zaLiWG+t+wxGq;(imH$`YmN!&yk~OSMe$VDR+IFz$>KS^Th9y_+1nhUUrfZ<-Jg-0l zcJ|8KpMfO@w0kCzw_AA4ek|(ADmKA6b*R26BwH_6A(z#GQkq#_O81_u2?!EJe*Ka7;dy=-RP1I+4-}ej)E()1+m_C{ljP~g$07A_Qv?3vQ zut0;%KcEdRC7a^x0R!c(8SBY)o$bjG=4ZK*kjXD$r3|K0o6{Bjo>j$}fuXKKc)UZr ztg6^=xOF4Vn1EQdz9&=u{iYR$Yapdp?qho!sG(TgtHk@8Bt;*fE){m(Y+n4J(Ot=z z$cNojI8t_tSQ&iUjk~Ve!JMG04a^wO_p*bYLDyxO@B2*txjl|eaxQ4OrT<|d7G1Kl|O_pilkQ#nHX0>MlG^>HWdror{H;H-8epczB`uM*%^nuL9l`at;i-B!`50@;naLu!>$@|u8MSR9V|`e>uu+|7p^11#bwOtQ z+>cvO!JUkJ;auGDHK#u^so-7*Y!A5RR|yLtn+7UhC{k0rYvq9?bo?E(t|_!RY&_F3 zq2a+^RtXvR9OWpZiNc^uf^|2FwYiP~X^=a#68kYg5PE}1Mgt_&#zo6&v_*@>z{W5s zu#qd?+8FPLkRwMI=41Ll&ToSr-xwpfW7!%5nd11wu1dhYe)vA8BoUb`K)RD;L@esj zV702hM^HUk>Q_pY!w5nh#GY4tvpknie^9S;orXv9&=@vRlOb3AF!FfPdgIX0@{USo zUps$|avS~UUj^>x8%d|hw|FJg%OsLw*RZk9mcl05*8XpvU%=lDx}><9-Pw1OmbFa} z;=*83Qj3hOi2DuEUA+|sUx?|TDvSj<$MRRGjhcK>j&3xLeLNNh7=0A*D)Z*i8 z@$YP74qr4+JYpB0bOQkm_r&47nS_z*d|V|e(AjfzrQphWo*Q|@f_!Fg8HHE^nM?P8<|FwxE*ii~qiPC{d($4w%uU{9qmb&qhp76<}0y z`YF74l(IGcBAf0jTjQ$BbRcu5*YYcFGYm<%<+`uOPORR0IFQqhucLpJn0ze?#7v8D ziGx~Fy^mzXZ1HF-QhTP4AF)poDfKi;l~nAxQt$`k9*CS4WNh4`6?V{y;vqM|sAxmS zx9m2RsewhdkDG27*!$GrLuQ2YBa_f?2WM$uSjM%O4Bxfh-+bqaZ;Tz-8ltR|tHA!L y*;9wbon7Z_X{i%~PB8mr>GXn|FAcLY@5Db-_9~SC literal 0 HcmV?d00001 diff --git a/tests/suite/math/equation.typ b/tests/suite/math/equation.typ index 148a49d02e..189f6e6db3 100644 --- a/tests/suite/math/equation.typ +++ b/tests/suite/math/equation.typ @@ -297,3 +297,10 @@ Looks at the @quadratic formula. #set page(width: 150pt) #set text(lang: "he") תהא סדרה $a_n$: $[a_n: 1, 1/2, 1/3, dots]$ + +--- issue-6170-equation-stroke --- +// In this bug stroke settings did not apply to math content. +// We expect all of these to have a green stroke. +#set text(stroke: green + 0.5pt) + +A $B^2$ $ grave(C)' $ From 61dee554ba9f8d30b983776ecdfefa4b12a985ea Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Mon, 2 Jun 2025 14:02:01 +0100 Subject: [PATCH 114/558] Add an example of show-set `place.clearance` for figures in the doc (#6208) --- crates/typst-library/src/model/figure.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/typst-library/src/model/figure.rs b/crates/typst-library/src/model/figure.rs index 5a137edbd8..bec667d6e3 100644 --- a/crates/typst-library/src/model/figure.rs +++ b/crates/typst-library/src/model/figure.rs @@ -125,6 +125,9 @@ pub struct FigureElem { /// /// ```example /// #set page(height: 200pt) + /// #show figure: set place( + /// clearance: 1em, + /// ) /// /// = Introduction /// #figure( From 83e249dd334442b09bbeebcc70cae83950c31311 Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Mon, 2 Jun 2025 16:03:03 +0300 Subject: [PATCH 115/558] Fix Greek numbering docs (#6360) --- crates/typst-library/src/model/numbering.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/typst-library/src/model/numbering.rs b/crates/typst-library/src/model/numbering.rs index d82c3e4cdb..320ed7d176 100644 --- a/crates/typst-library/src/model/numbering.rs +++ b/crates/typst-library/src/model/numbering.rs @@ -261,9 +261,9 @@ pub enum NumberingKind { LowerRoman, /// Uppercase Roman numerals (I, II, III, etc.). UpperRoman, - /// Lowercase Greek numerals (Α, Β, Γ, etc.). + /// Lowercase Greek letters (α, β, γ, etc.). LowerGreek, - /// Uppercase Greek numerals (α, β, γ, etc.). + /// Uppercase Greek letters (Α, Β, Γ, etc.). UpperGreek, /// Paragraph/note-like symbols: *, †, ‡, §, ¶, and ‖. Further items use /// repeated symbols. From 4329a15a1cb44a849c9b6a8cd932867b4aa53ed0 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Mon, 2 Jun 2025 14:04:49 +0100 Subject: [PATCH 116/558] Improve `calc.round` documentation (#6345) --- crates/typst-library/src/foundations/calc.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/typst-library/src/foundations/calc.rs b/crates/typst-library/src/foundations/calc.rs index a8e0eaeb39..7f481a23bb 100644 --- a/crates/typst-library/src/foundations/calc.rs +++ b/crates/typst-library/src/foundations/calc.rs @@ -708,12 +708,13 @@ pub fn fract( } } -/// Rounds a number to the nearest integer away from zero. +/// Rounds a number to the nearest integer. /// -/// Optionally, a number of decimal places can be specified. +/// Half-integers are rounded away from zero. /// -/// If the number of digits is negative, its absolute value will indicate the -/// amount of significant integer digits to remove before the decimal point. +/// Optionally, a number of decimal places can be specified. If negative, its +/// absolute value will indicate the amount of significant integer digits to +/// remove before the decimal point. /// /// Note that this function will return the same type as the operand. That is, /// applying `round` to a [`float`] will return a `float`, and to a [`decimal`], From fd08c4bb3f55400e0fb9f461f463da19169a04a0 Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Mon, 2 Jun 2025 16:12:42 +0300 Subject: [PATCH 117/558] Fix typo in layout docs, change "size" to "height" (#6344) --- crates/typst-library/src/layout/layout.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-library/src/layout/layout.rs b/crates/typst-library/src/layout/layout.rs index 04aaee944c..46271ff22f 100644 --- a/crates/typst-library/src/layout/layout.rs +++ b/crates/typst-library/src/layout/layout.rs @@ -41,7 +41,7 @@ use crate::layout::{BlockElem, Size}; /// receives the page's dimensions minus its margins. This is mostly useful in /// combination with [measurement]($measure). /// -/// To retrieve the _remaining_ size of the page rather than its full size, you +/// To retrieve the _remaining_ height of the page rather than its full size, /// you can wrap your `layout` call in a `{block(height: 1fr)}`. This works /// because the block automatically grows to fill the remaining space (see the /// [fraction] documentation for more details). From 6164ade9cecf1f7bf475d24e0123c3664b8490a8 Mon Sep 17 00:00:00 2001 From: Lachlan Kermode Date: Mon, 2 Jun 2025 16:15:04 +0200 Subject: [PATCH 118/558] Add `typst-html` to architecture crates list (#6364) --- docs/dev/architecture.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index bbae067923..3620d4fdad 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -12,6 +12,7 @@ Let's start with a broad overview of the directories in this repository: - `crates/typst-cli`: Typst's command line interface. This is a relatively small layer on top of the compiler and the exporters. - `crates/typst-eval`: The interpreter for the Typst language. +- `crates/typst-html`: The HTML exporter. - `crates/typst-ide`: Exposes IDE functionality. - `crates/typst-kit`: Contains various default implementation of functionality used in `typst-cli`. From e023db5f1dea8b0273eec0f528d6ae0fed118a65 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 2 Jun 2025 18:44:43 +0200 Subject: [PATCH 119/558] Bump Rust to 1.87 in CI (#6367) --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- crates/typst-kit/src/download.rs | 3 +-- crates/typst-library/src/text/deco.rs | 1 + crates/typst-macros/src/cast.rs | 1 + crates/typst/src/lib.rs | 1 - flake.lock | 6 +++--- flake.nix | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41f17d1371..c5c81537b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: sudo dpkg --add-architecture i386 sudo apt update sudo apt install -y gcc-multilib libssl-dev:i386 pkg-config:i386 - - uses: dtolnay/rust-toolchain@1.85.0 + - uses: dtolnay/rust-toolchain@1.87.0 with: targets: ${{ matrix.bits == 32 && 'i686-unknown-linux-gnu' || '' }} - uses: Swatinem/rust-cache@v2 @@ -73,7 +73,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.85.0 + - uses: dtolnay/rust-toolchain@1.87.0 with: components: clippy, rustfmt - uses: Swatinem/rust-cache@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d235aec5d..ca317abd00 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.85.0 + - uses: dtolnay/rust-toolchain@1.87.0 with: target: ${{ matrix.target }} diff --git a/crates/typst-kit/src/download.rs b/crates/typst-kit/src/download.rs index 40084e51b8..a4d49b4f3c 100644 --- a/crates/typst-kit/src/download.rs +++ b/crates/typst-kit/src/download.rs @@ -128,8 +128,7 @@ impl Downloader { } // Configure native TLS. - let connector = - tls.build().map_err(|err| io::Error::new(io::ErrorKind::Other, err))?; + let connector = tls.build().map_err(io::Error::other)?; builder = builder.tls_connector(Arc::new(connector)); builder.build().get(url).call() diff --git a/crates/typst-library/src/text/deco.rs b/crates/typst-library/src/text/deco.rs index 485d0edcf3..7aa06e815b 100644 --- a/crates/typst-library/src/text/deco.rs +++ b/crates/typst-library/src/text/deco.rs @@ -373,6 +373,7 @@ pub struct Decoration { /// A kind of decorative line. #[derive(Debug, Clone, Eq, PartialEq, Hash)] +#[allow(clippy::large_enum_variant)] pub enum DecoLine { Underline { stroke: Stroke, diff --git a/crates/typst-macros/src/cast.rs b/crates/typst-macros/src/cast.rs index b90b788862..6f4b2b95c2 100644 --- a/crates/typst-macros/src/cast.rs +++ b/crates/typst-macros/src/cast.rs @@ -185,6 +185,7 @@ struct Cast { } /// A pattern in a cast, e.g.`"ascender"` or `v: i64`. +#[allow(clippy::large_enum_variant)] enum Pattern { Str(syn::LitStr), Ty(syn::Pat, syn::Type), diff --git a/crates/typst/src/lib.rs b/crates/typst/src/lib.rs index 580ba9e803..a6bb4fe381 100644 --- a/crates/typst/src/lib.rs +++ b/crates/typst/src/lib.rs @@ -27,7 +27,6 @@ //! [module]: crate::foundations::Module //! [content]: crate::foundations::Content //! [laid out]: typst_layout::layout_document -//! [document]: crate::model::Document //! [frame]: crate::layout::Frame pub extern crate comemo; diff --git a/flake.lock b/flake.lock index ad47d29cde..dedfbb4e04 100644 --- a/flake.lock +++ b/flake.lock @@ -112,13 +112,13 @@ "rust-manifest": { "flake": false, "locked": { - "narHash": "sha256-irgHsBXecwlFSdmP9MfGP06Cbpca2QALJdbN4cymcko=", + "narHash": "sha256-BwfxWd/E8gpnXoKsucFXhMbevMlVgw3l0becLkIcWCU=", "type": "file", - "url": "https://static.rust-lang.org/dist/channel-rust-1.85.0.toml" + "url": "https://static.rust-lang.org/dist/channel-rust-1.87.0.toml" }, "original": { "type": "file", - "url": "https://static.rust-lang.org/dist/channel-rust-1.85.0.toml" + "url": "https://static.rust-lang.org/dist/channel-rust-1.87.0.toml" } }, "systems": { diff --git a/flake.nix b/flake.nix index 6938f6e571..1b2b3abc8e 100644 --- a/flake.nix +++ b/flake.nix @@ -10,7 +10,7 @@ inputs.nixpkgs.follows = "nixpkgs"; }; rust-manifest = { - url = "https://static.rust-lang.org/dist/channel-rust-1.85.0.toml"; + url = "https://static.rust-lang.org/dist/channel-rust-1.87.0.toml"; flake = false; }; }; From 664d33a68178239a9b9799d5c1b9e08958dd8d5c Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 2 Jun 2025 18:53:35 +0200 Subject: [PATCH 120/558] Be a bit lazier in function call evaluation (#6368) --- crates/typst-eval/src/call.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/typst-eval/src/call.rs b/crates/typst-eval/src/call.rs index fa96834164..eaeabbab35 100644 --- a/crates/typst-eval/src/call.rs +++ b/crates/typst-eval/src/call.rs @@ -25,15 +25,13 @@ impl Eval for ast::FuncCall<'_> { fn eval(self, vm: &mut Vm) -> SourceResult { let span = self.span(); let callee = self.callee(); - let in_math = in_math(callee); let callee_span = callee.span(); let args = self.args(); - let trailing_comma = args.trailing_comma(); vm.engine.route.check_call_depth().at(span)?; // Try to evaluate as a call to an associated function or field. - let (callee, args) = if let ast::Expr::FieldAccess(access) = callee { + let (callee_value, args_value) = if let ast::Expr::FieldAccess(access) = callee { let target = access.target(); let field = access.field(); match eval_field_call(target, field, args, span, vm)? { @@ -50,9 +48,15 @@ impl Eval for ast::FuncCall<'_> { (callee.eval(vm)?, args.eval(vm)?.spanned(span)) }; - let func_result = callee.clone().cast::(); - if in_math && func_result.is_err() { - return wrap_args_in_math(callee, callee_span, args, trailing_comma); + let func_result = callee_value.clone().cast::(); + + if func_result.is_err() && in_math(callee) { + return wrap_args_in_math( + callee_value, + callee_span, + args_value, + args.trailing_comma(), + ); } let func = func_result @@ -61,8 +65,11 @@ impl Eval for ast::FuncCall<'_> { let point = || Tracepoint::Call(func.name().map(Into::into)); let f = || { - func.call(&mut vm.engine, vm.context, args) - .trace(vm.world(), point, span) + func.call(&mut vm.engine, vm.context, args_value).trace( + vm.world(), + point, + span, + ) }; // Stacker is broken on WASM. From ff0dc5ab6608504c802d6965587151caf2c757f6 Mon Sep 17 00:00:00 2001 From: Kristofers Solo Date: Tue, 3 Jun 2025 15:38:21 +0300 Subject: [PATCH 121/558] Add Latvian translations (#6348) --- crates/typst-library/src/text/lang.rs | 4 +++- crates/typst-library/translations/lv.txt | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 crates/typst-library/translations/lv.txt diff --git a/crates/typst-library/src/text/lang.rs b/crates/typst-library/src/text/lang.rs index 2cc66a2615..f9f13c7836 100644 --- a/crates/typst-library/src/text/lang.rs +++ b/crates/typst-library/src/text/lang.rs @@ -14,7 +14,7 @@ macro_rules! translation { }; } -const TRANSLATIONS: [(&str, &str); 39] = [ +const TRANSLATIONS: [(&str, &str); 40] = [ translation!("ar"), translation!("bg"), translation!("ca"), @@ -36,6 +36,7 @@ const TRANSLATIONS: [(&str, &str); 39] = [ translation!("it"), translation!("ja"), translation!("la"), + translation!("lv"), translation!("nb"), translation!("nl"), translation!("nn"), @@ -87,6 +88,7 @@ impl Lang { pub const ITALIAN: Self = Self(*b"it ", 2); pub const JAPANESE: Self = Self(*b"ja ", 2); pub const LATIN: Self = Self(*b"la ", 2); + pub const LATVIAN: Self = Self(*b"lv ", 2); pub const LOWER_SORBIAN: Self = Self(*b"dsb", 3); pub const NYNORSK: Self = Self(*b"nn ", 2); pub const POLISH: Self = Self(*b"pl ", 2); diff --git a/crates/typst-library/translations/lv.txt b/crates/typst-library/translations/lv.txt new file mode 100644 index 0000000000..4c6b86841b --- /dev/null +++ b/crates/typst-library/translations/lv.txt @@ -0,0 +1,8 @@ +figure = Attēls +table = Tabula +equation = Vienādojums +bibliography = Literatūra +heading = Sadaļa +outline = Saturs +raw = Saraksts +page = lpp. From 1b399646c270d518af250db3afb7ba35992e8751 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Jun 2025 14:53:13 +0200 Subject: [PATCH 122/558] Bump crossbeam-channel from 0.5.14 to 0.5.15 (#6369) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4b70e06bc2..30a4db7a7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -508,9 +508,9 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06ba6d68e24814cb8de6bb986db8222d3a027d15872cabc0d18817bc3c0e4471" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ "crossbeam-utils", ] From dd95f7d59474800a83a4d397dd13e34de35d56be Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 3 Jun 2025 14:08:18 +0000 Subject: [PATCH 123/558] Fix bottom accent positioning in math (#6187) --- crates/typst-layout/src/math/accent.rs | 59 +++++++++++++-------- crates/typst-layout/src/math/attach.rs | 6 ++- crates/typst-layout/src/math/fragment.rs | 33 ++++++++---- crates/typst-layout/src/math/stretch.rs | 2 +- crates/typst-library/src/math/accent.rs | 13 +++++ tests/ref/math-accent-bottom-high-base.png | Bin 0 -> 572 bytes tests/ref/math-accent-bottom-sized.png | Bin 0 -> 382 bytes tests/ref/math-accent-bottom-subscript.png | Bin 0 -> 417 bytes tests/ref/math-accent-bottom-wide-base.png | Bin 0 -> 359 bytes tests/ref/math-accent-bottom.png | Bin 0 -> 622 bytes tests/ref/math-accent-nested.png | Bin 0 -> 537 bytes tests/suite/math/accent.typ | 28 ++++++++++ 12 files changed, 108 insertions(+), 33 deletions(-) create mode 100644 tests/ref/math-accent-bottom-high-base.png create mode 100644 tests/ref/math-accent-bottom-sized.png create mode 100644 tests/ref/math-accent-bottom-subscript.png create mode 100644 tests/ref/math-accent-bottom-wide-base.png create mode 100644 tests/ref/math-accent-bottom.png create mode 100644 tests/ref/math-accent-nested.png diff --git a/crates/typst-layout/src/math/accent.rs b/crates/typst-layout/src/math/accent.rs index 73d8210194..53dfdf055a 100644 --- a/crates/typst-layout/src/math/accent.rs +++ b/crates/typst-layout/src/math/accent.rs @@ -1,7 +1,7 @@ use typst_library::diag::SourceResult; use typst_library::foundations::{Packed, StyleChain}; use typst_library::layout::{Em, Frame, Point, Size}; -use typst_library::math::{Accent, AccentElem}; +use typst_library::math::AccentElem; use super::{style_cramped, FrameFragment, GlyphFragment, MathContext, MathFragment}; @@ -18,8 +18,11 @@ pub fn layout_accent( let cramped = style_cramped(); let mut base = ctx.layout_into_fragment(&elem.base, styles.chain(&cramped))?; - // Try to replace a glyph with its dotless variant. - if elem.dotless(styles) { + let accent = elem.accent; + let top_accent = !accent.is_bottom(); + + // Try to replace base glyph with its dotless variant. + if top_accent && elem.dotless(styles) { if let MathFragment::Glyph(glyph) = &mut base { glyph.make_dotless_form(ctx); } @@ -29,41 +32,54 @@ pub fn layout_accent( let base_class = base.class(); let base_attach = base.accent_attach(); - let width = elem.size(styles).relative_to(base.width()); - - let Accent(c) = elem.accent; - let mut glyph = GlyphFragment::new(ctx, styles, c, elem.span()); + let mut glyph = GlyphFragment::new(ctx, styles, accent.0, elem.span()); - // Try to replace accent glyph with flattened variant. - let flattened_base_height = scaled!(ctx, styles, flattened_accent_base_height); - if base.ascent() > flattened_base_height { - glyph.make_flattened_accent_form(ctx); + // Try to replace accent glyph with its flattened variant. + if top_accent { + let flattened_base_height = scaled!(ctx, styles, flattened_accent_base_height); + if base.ascent() > flattened_base_height { + glyph.make_flattened_accent_form(ctx); + } } // Forcing the accent to be at least as large as the base makes it too // wide in many case. + let width = elem.size(styles).relative_to(base.width()); let short_fall = ACCENT_SHORT_FALL.at(glyph.font_size); let variant = glyph.stretch_horizontal(ctx, width, short_fall); let accent = variant.frame; - let accent_attach = variant.accent_attach; + let accent_attach = variant.accent_attach.0; + + let (gap, accent_pos, base_pos) = if top_accent { + // Descent is negative because the accent's ink bottom is above the + // baseline. Therefore, the default gap is the accent's negated descent + // minus the accent base height. Only if the base is very small, we + // need a larger gap so that the accent doesn't move too low. + let accent_base_height = scaled!(ctx, styles, accent_base_height); + let gap = -accent.descent() - base.ascent().min(accent_base_height); + let accent_pos = Point::with_x(base_attach.0 - accent_attach); + let base_pos = Point::with_y(accent.height() + gap); + (gap, accent_pos, base_pos) + } else { + let gap = -accent.ascent(); + let accent_pos = Point::new(base_attach.1 - accent_attach, base.height() + gap); + let base_pos = Point::zero(); + (gap, accent_pos, base_pos) + }; - // Descent is negative because the accent's ink bottom is above the - // baseline. Therefore, the default gap is the accent's negated descent - // minus the accent base height. Only if the base is very small, we need - // a larger gap so that the accent doesn't move too low. - let accent_base_height = scaled!(ctx, styles, accent_base_height); - let gap = -accent.descent() - base.ascent().min(accent_base_height); let size = Size::new(base.width(), accent.height() + gap + base.height()); - let accent_pos = Point::with_x(base_attach - accent_attach); - let base_pos = Point::with_y(accent.height() + gap); let baseline = base_pos.y + base.ascent(); + let base_italics_correction = base.italics_correction(); let base_text_like = base.is_text_like(); - let base_ascent = match &base { MathFragment::Frame(frame) => frame.base_ascent, _ => base.ascent(), }; + let base_descent = match &base { + MathFragment::Frame(frame) => frame.base_descent, + _ => base.descent(), + }; let mut frame = Frame::soft(size); frame.set_baseline(baseline); @@ -73,6 +89,7 @@ pub fn layout_accent( FrameFragment::new(styles, frame) .with_class(base_class) .with_base_ascent(base_ascent) + .with_base_descent(base_descent) .with_italics_correction(base_italics_correction) .with_accent_attach(base_attach) .with_text_like(base_text_like), diff --git a/crates/typst-layout/src/math/attach.rs b/crates/typst-layout/src/math/attach.rs index e1d7d7c9de..90aad941ed 100644 --- a/crates/typst-layout/src/math/attach.rs +++ b/crates/typst-layout/src/math/attach.rs @@ -434,9 +434,13 @@ fn compute_script_shifts( } if bl.is_some() || br.is_some() { + let descent = match &base { + MathFragment::Frame(frame) => frame.base_descent, + _ => base.descent(), + }; shift_down = shift_down .max(sub_shift_down) - .max(if is_text_like { Abs::zero() } else { base.descent() + sub_drop_min }) + .max(if is_text_like { Abs::zero() } else { descent + sub_drop_min }) .max(measure!(bl, ascent) - sub_top_max) .max(measure!(br, ascent) - sub_top_max); } diff --git a/crates/typst-layout/src/math/fragment.rs b/crates/typst-layout/src/math/fragment.rs index 59858a9cb6..85101c486d 100644 --- a/crates/typst-layout/src/math/fragment.rs +++ b/crates/typst-layout/src/math/fragment.rs @@ -164,12 +164,12 @@ impl MathFragment { } } - pub fn accent_attach(&self) -> Abs { + pub fn accent_attach(&self) -> (Abs, Abs) { match self { Self::Glyph(glyph) => glyph.accent_attach, Self::Variant(variant) => variant.accent_attach, Self::Frame(fragment) => fragment.accent_attach, - _ => self.width() / 2.0, + _ => (self.width() / 2.0, self.width() / 2.0), } } @@ -241,7 +241,7 @@ pub struct GlyphFragment { pub ascent: Abs, pub descent: Abs, pub italics_correction: Abs, - pub accent_attach: Abs, + pub accent_attach: (Abs, Abs), pub font_size: Abs, pub class: MathClass, pub math_size: MathSize, @@ -296,7 +296,7 @@ impl GlyphFragment { descent: Abs::zero(), limits: Limits::for_char(c), italics_correction: Abs::zero(), - accent_attach: Abs::zero(), + accent_attach: (Abs::zero(), Abs::zero()), class, span, modifiers: FrameModifiers::get_in(styles), @@ -328,8 +328,14 @@ impl GlyphFragment { }); let mut width = advance.scaled(ctx, self.font_size); - let accent_attach = + + // The fallback for accents is half the width plus or minus the italics + // correction. This is similar to how top and bottom attachments are + // shifted. For bottom accents we do not use the accent attach of the + // base as it is meant for top acccents. + let top_accent_attach = accent_attach(ctx, id, self.font_size).unwrap_or((width + italics) / 2.0); + let bottom_accent_attach = (width - italics) / 2.0; let extended_shape = is_extended_shape(ctx, id); if !extended_shape { @@ -341,7 +347,7 @@ impl GlyphFragment { self.ascent = bbox.y_max.scaled(ctx, self.font_size); self.descent = -bbox.y_min.scaled(ctx, self.font_size); self.italics_correction = italics; - self.accent_attach = accent_attach; + self.accent_attach = (top_accent_attach, bottom_accent_attach); self.extended_shape = extended_shape; } @@ -459,7 +465,7 @@ impl Debug for GlyphFragment { pub struct VariantFragment { pub c: char, pub italics_correction: Abs, - pub accent_attach: Abs, + pub accent_attach: (Abs, Abs), pub frame: Frame, pub font_size: Abs, pub class: MathClass, @@ -501,8 +507,9 @@ pub struct FrameFragment { pub limits: Limits, pub spaced: bool, pub base_ascent: Abs, + pub base_descent: Abs, pub italics_correction: Abs, - pub accent_attach: Abs, + pub accent_attach: (Abs, Abs), pub text_like: bool, pub ignorant: bool, } @@ -510,6 +517,7 @@ pub struct FrameFragment { impl FrameFragment { pub fn new(styles: StyleChain, frame: Frame) -> Self { let base_ascent = frame.ascent(); + let base_descent = frame.descent(); let accent_attach = frame.width() / 2.0; Self { frame: frame.modified(&FrameModifiers::get_in(styles)), @@ -519,8 +527,9 @@ impl FrameFragment { limits: Limits::Never, spaced: false, base_ascent, + base_descent, italics_correction: Abs::zero(), - accent_attach, + accent_attach: (accent_attach, accent_attach), text_like: false, ignorant: false, } @@ -542,11 +551,15 @@ impl FrameFragment { Self { base_ascent, ..self } } + pub fn with_base_descent(self, base_descent: Abs) -> Self { + Self { base_descent, ..self } + } + pub fn with_italics_correction(self, italics_correction: Abs) -> Self { Self { italics_correction, ..self } } - pub fn with_accent_attach(self, accent_attach: Abs) -> Self { + pub fn with_accent_attach(self, accent_attach: (Abs, Abs)) -> Self { Self { accent_attach, ..self } } diff --git a/crates/typst-layout/src/math/stretch.rs b/crates/typst-layout/src/math/stretch.rs index f45035e272..6157d0c503 100644 --- a/crates/typst-layout/src/math/stretch.rs +++ b/crates/typst-layout/src/math/stretch.rs @@ -278,7 +278,7 @@ fn assemble( } let accent_attach = match axis { - Axis::X => frame.width() / 2.0, + Axis::X => (frame.width() / 2.0, frame.width() / 2.0), Axis::Y => base.accent_attach, }; diff --git a/crates/typst-library/src/math/accent.rs b/crates/typst-library/src/math/accent.rs index e62b63872c..f2c9168c2f 100644 --- a/crates/typst-library/src/math/accent.rs +++ b/crates/typst-library/src/math/accent.rs @@ -80,6 +80,19 @@ impl Accent { pub fn new(c: char) -> Self { Self(Self::combine(c).unwrap_or(c)) } + + /// List of bottom accents. Currently just a list of ones included in the + /// Unicode math class document. + const BOTTOM: &[char] = &[ + '\u{0323}', '\u{032C}', '\u{032D}', '\u{032E}', '\u{032F}', '\u{0330}', + '\u{0331}', '\u{0332}', '\u{0333}', '\u{033A}', '\u{20E8}', '\u{20EC}', + '\u{20ED}', '\u{20EE}', '\u{20EF}', + ]; + + /// Whether this accent is a bottom accent or not. + pub fn is_bottom(&self) -> bool { + Self::BOTTOM.contains(&self.0) + } } /// This macro generates accent-related functions. diff --git a/tests/ref/math-accent-bottom-high-base.png b/tests/ref/math-accent-bottom-high-base.png new file mode 100644 index 0000000000000000000000000000000000000000..23b14467280d93fba150194ded116d6b15fea4da GIT binary patch literal 572 zcmV-C0>k}@P)v*+FZC^6au}I+YU#J7le%9ciTqo1 z?0@j>I;vZ|cvTD%?|V$|$+DltUy+2SZAku9`=WvB7TbSE=B?=e|9|(g#mGWdz)+sq z^orURzqUr<#-2o1aNnIasd`-@3Rm?Px`G9Dw6VBw5i;+A5r%@~^|Z0r_yS5wOTi-UTRc?ab% z6u8W$jm1iPk$Dq+F%*cNqKU;HnBRb?KTvLBErdo0Jz@P$6N?Y3fT;>-8ELcyp4zn_ z^cEx9)W;K{Y3nqY{LKOKKeEXGmTcNsoID*&o&5r)E~z7A4nU~TCA6{F45i#!idk-H z+@*=d|D;eF78P^Q6?~VaO`Geg9}3s)9=d`PF|@IG-eeU28)GKE1+=j^@(D8USQ&-_ z|97;oIPh@^a>`m!{1Vt8*>M~p6eS`jE?aOks3{>``;4F15zPWt)@};Hll(iDbEx)$y`0Dql%5ELnaGLr){`eY& zdwXzn+U{;yvvTF~m21~5U$Js&+nE9CEnXV!ACFo*YVoMWv;_b(vVqN&POabo0000< KMNUMnLSTaLKoLv; literal 0 HcmV?d00001 diff --git a/tests/ref/math-accent-bottom-sized.png b/tests/ref/math-accent-bottom-sized.png new file mode 100644 index 0000000000000000000000000000000000000000..5455b2f5b260a860704717196db8b988e54b12f6 GIT binary patch literal 382 zcmV-^0fGLBP)^*)K^trgXzr>?sZh||E-Dt|M$1iB#vkLpvcYkLgAJ!`u{)R7;O?)@B|cY zXdg1qw14hwq2IKz_^lNR_njpY=cCI1|L5Ill%4u-K|Nmz}sk#rsE&2cd3?zfmC?CJ~ z+PZ!F)`j&_)BfN1@c;h@2)99M>i?S`{@+?ulo%VG-n{!G^(}t71`0l+a)FN6I&J-} zzu**heD3TAG)P>J9)f}xS>)k&dQ=h%dJ3bb7e0aWccrDI(WQ0*)($_9BYE-OXnj0t c@ldoF0CeBVg-HF}4gdfE07*qoM6N<$f{N|D(*OVf literal 0 HcmV?d00001 diff --git a/tests/ref/math-accent-bottom-subscript.png b/tests/ref/math-accent-bottom-subscript.png new file mode 100644 index 0000000000000000000000000000000000000000..818544445587a485c7152e543f2ab8a8536a89eb GIT binary patch literal 417 zcmV;S0bc%zP)_^QKU;L<91?f&9SD7A;fbqA z!u=m1^o4~dA5h2QFTuYnw?ocO(txH$&){ga6&A6x7v+e!2Ze_}Hx(;^TySZ-W0LeC*N+ z@v-~&%Q;lC_}ZM#lK)3mY~2Cj)EFQAPIJ$!K0Gkh;;a*+4S`XM2dBjVM)yn!G36@500000 LNkvXXu0mjfK$6xw literal 0 HcmV?d00001 diff --git a/tests/ref/math-accent-bottom-wide-base.png b/tests/ref/math-accent-bottom-wide-base.png new file mode 100644 index 0000000000000000000000000000000000000000..0475b4856bd49b9a4f7663564747ca9635654028 GIT binary patch literal 359 zcmV-t0hs=YP)YZomn-m~=V zl5b$v%y(ew?VOkY4?^UAnQW(79ACM#xNh-NWQ&*1U;8l#DtlA;1+6Up_rLfoh`!X; z7Es&P0gTkYq3^f$!=$_B(#qnSKdt{B`;YA7|33dq&i#k@^nE4Gg1U2N#fqb-7I$t_ z{rBJc4UqM>E7q-Fxq8i-)hk!7Uo+d~H&puT z)UEU^tN(0-&~K-;pWV{3{2$$GCrE1hHd-H#T08_S1^~|x+^P)&N!S1Y002ovPDHLk FV1n_Aw{-vj literal 0 HcmV?d00001 diff --git a/tests/ref/math-accent-bottom.png b/tests/ref/math-accent-bottom.png new file mode 100644 index 0000000000000000000000000000000000000000..bd1b921460ca17d139552bd63f73144da916bc91 GIT binary patch literal 622 zcmV-!0+IcRP)zM(@7Ur)P)swAt_M<6Ut1fP)c+<8ciD}Qd5Z3L@6vA ziem*X(`{^sdt+OBld~7U<#W!t5GI`3MM3*;E`IQE_})EF8GlJxDO?Jd!v8}!59;!8 zu*7=tVPmV|(BlV9O08oS#pSwfgwA8b1N9^9Goe$L{Y-uM&%#4QkAPY1RIP0VS+G$; z_N)zj)9f?fjH>B9zZKrL%y{Jjlb0YQwy6t6x~*;#+4(n;HIo9!kJ$OJ3-Cpc>aYYr zwBIqlt|6Q_mC?UO)?!AnR^1Xrc7F~<`P15u2-c;WREy>BMX^zZFgARLdJW;eJ{ls( zD!eEjrBLa@$UK#N&LZUREd%b`B^5r~<+{ zz;pwEogY9=er!;jr#(a^a5r+=0r50&lBwGo!pC!Vs8xn$ehJ{x>MkEpy$wUxEP;`N| zRA|MsNYUxc-?Z8?t?g+&=43MSpZmYb@1B#Jdu{^a6bTAg0RwiHWgq`l#@1!t@JtK7 zS6jZD!y^2MdpL$w3mpmcV!;o<8BYVc-uSUwGy!djb64Q^#Z$_@ADa^_O0 z!KzS}wz{GPO;C$HLJ~1pNEU!Azp~7>L*@Dy?Q8B&^wNU2bAYwIfUMf%Q^bI}n*`Dv zq~K>wwQWX)gGNcGTRRNUZ3B*i8$4u8lY;F_QrO@b1nvrrhrr1qVCGyn;yB{X^@1xW zd>qhG;s4#e^}l(Z7l}95R#|b$E4%`*yYsOQDa>kv!9)zc_@XP%LrRQocXEXV!!R51 zXp1hrUT=y^KNdw%x$G(6Uki4)aeFDZ_W?6KSk!6SBJ2Zg&D!5B^<~!yyC`?L`IS*8 zIe5H5hdk+PWdiX8c;?g2)|<(}LjI#SOYT~W(LF{F&cdozmNAA#pr$XiqXkVyx!ZJs z%$OV?xt>V>xI8A{$|!%%NDmgGT|s*2!RFS9`iN5Z4EIjPpw#XSU!-k0RobEgR=^7Q b$ARAgn*4* Date: Tue, 3 Jun 2025 17:42:22 +0300 Subject: [PATCH 124/558] Change Russian secondary smart quotes & remove alternatives (#6331) --- crates/typst-library/src/text/smartquote.rs | 5 +++-- tests/ref/smartquote-ru.png | Bin 1877 -> 1886 bytes 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/typst-library/src/text/smartquote.rs b/crates/typst-library/src/text/smartquote.rs index 4dda689dfd..270d8f0f34 100644 --- a/crates/typst-library/src/text/smartquote.rs +++ b/crates/typst-library/src/text/smartquote.rs @@ -237,7 +237,7 @@ impl<'s> SmartQuotes<'s> { "cs" | "da" | "de" | "sk" | "sl" if alternative => ("›", "‹", "»", "«"), "cs" | "de" | "et" | "is" | "lt" | "lv" | "sk" | "sl" => low_high, "da" => ("‘", "’", "“", "”"), - "fr" | "ru" if alternative => default, + "fr" if alternative => default, "fr" => ("“", "”", "«\u{202F}", "\u{202F}»"), "fi" | "sv" if alternative => ("’", "’", "»", "»"), "bs" | "fi" | "sv" => ("’", "’", "”", "”"), @@ -247,7 +247,8 @@ impl<'s> SmartQuotes<'s> { "es" if matches!(region, Some("ES") | None) => ("“", "”", "«", "»"), "hu" | "pl" | "ro" => ("’", "’", "„", "”"), "no" | "nb" | "nn" if alternative => low_high, - "ru" | "no" | "nb" | "nn" | "uk" => ("’", "’", "«", "»"), + "no" | "nb" | "nn" | "uk" => ("’", "’", "«", "»"), + "ru" => ("„", "“", "«", "»"), "el" => ("‘", "’", "«", "»"), "he" => ("’", "’", "”", "”"), "hr" => ("‘", "’", "„", "”"), diff --git a/tests/ref/smartquote-ru.png b/tests/ref/smartquote-ru.png index 05c79263f6797e3072ee9cb867431b4fb334c28b..867121d1e32d470b9b4c7f4daf8bb37b9a521a4f 100644 GIT binary patch delta 1871 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 A{{R30 From 4a8367e90add92a0a316bcc258e39d522b484c25 Mon Sep 17 00:00:00 2001 From: Nazar Serhiichuk <43041209+G1gg1L3s@users.noreply.github.com> Date: Wed, 4 Jun 2025 11:13:39 +0300 Subject: [PATCH 125/558] Fix Ukrainian secondary smart quotes (#6372) --- crates/typst-library/src/text/smartquote.rs | 3 ++- tests/ref/smartquote-uk.png | Bin 0 -> 1971 bytes tests/suite/text/smartquote.typ | 4 ++++ 3 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 tests/ref/smartquote-uk.png diff --git a/crates/typst-library/src/text/smartquote.rs b/crates/typst-library/src/text/smartquote.rs index 270d8f0f34..09cefd013d 100644 --- a/crates/typst-library/src/text/smartquote.rs +++ b/crates/typst-library/src/text/smartquote.rs @@ -247,8 +247,9 @@ impl<'s> SmartQuotes<'s> { "es" if matches!(region, Some("ES") | None) => ("“", "”", "«", "»"), "hu" | "pl" | "ro" => ("’", "’", "„", "”"), "no" | "nb" | "nn" if alternative => low_high, - "no" | "nb" | "nn" | "uk" => ("’", "’", "«", "»"), + "no" | "nb" | "nn" => ("’", "’", "«", "»"), "ru" => ("„", "“", "«", "»"), + "uk" => ("“", "”", "«", "»"), "el" => ("‘", "’", "«", "»"), "he" => ("’", "’", "”", "”"), "hr" => ("‘", "’", "„", "”"), diff --git a/tests/ref/smartquote-uk.png b/tests/ref/smartquote-uk.png new file mode 100644 index 0000000000000000000000000000000000000000..7ac1c032e583b0c3a691097b9b6fadaeaaaa7e68 GIT binary patch literal 1971 zcmV;k2Tb^hP)4J*jj{4nWRBDBRDmr&odJm6K)-=ECtQfMYTp8?isUFAc89S>MeP4f6ru+b))l-!sjh*$XV? zOzmJ!W`oJ|CbuX)9`4*!bQbL6J7=qLl4B&5%>jeB!)?H(2-$3$z1vy=1Q_%K4JP1y z&dcwkVM=1d9&$bmp$9eztnOsdC=0k639M7HSe?HWr3RRbX&}#dyc3*by9FN)(?Lh$ zMTcIsTd|FEt);B`OlryL$beC(vrKqwk&M4xHjs7t{(zlx7s0}&0b7yyv3@}GupXWS z_R{hGs+F132CJE*4xf~%hQlPcDKd3GS#L7tE_L@*mzx!4`;r(W(N-S`^HZlCD$YMz zBRNtV^K%Q38SYDQqCaXI(P%n&VHbrL%0f`%s6|vzNDO&SNVx#6lk_cG$ zY8}v#^=o6l2aYz!C!Bu(hUyG!!o@5YUD#H#O_$=p@-d$v7KJu$q-||<)+wNPou>WG zHulu1?r@~+p(O@7Vy{7TzDwhW-dN%ls4Frq;L(A^jBf))I>o-i;!7mD`l_o;#BM#Q zyIX<})dcSwxptxj2)0b&LZqvoO>)mk_o36V7gM#imVGqonm^_p-*xy*17fzORX>?} zSP%dIaG6l-XH~2VOX*@|0iBxFYmGXU_n?eVVH1WBrCw(%8al?c=RExb1w%sk#MB`WyY5@tMZUB-1W zd#ac}s<3YVyA}%84_Az65|rD~|W`RWDWv$Pj?w>3som`{4J(3sDQW9Eb`-v|r;J z&Y`C)(@M`V0w~PMwtcV}&FFA)?*d|eW(4mD>{2iJ~9F1SpQm_hNFF{k57D z48TA#ws_JT=Y6M;Z>dz8xaIL>@cHUd^RuD*or1T>RmU9e0E*=yRwGyor5Y$RlB-W# zkqwhUzEY|qa7YW@jZxddfm|W z#;1_xR+Hpbk36;;h!chSc=V1%M08FD>t-@Ck0fcwifp5S$TMT}hgAXehrKZ3{vH6j zSFw7maQEO=M-o@QXj-2-zExFx0Q3A*C(6@30y#M76h5$+S8EALum1t4+fIfBhoASY zP)`CgUnUc<`$kMk{z(QFaMM3_nC61lJE-|CG8h|}V0y_VRX*wF9X$M)C zSlY^lH#t#7Gp)@`U zERmfPRdsB)am=~qr7=DBHe_$m%h+#fBG%Q{ZuQIu+`n(P+u2cjOnm!;1|%5b??H9Q zr(Ld5vnMbL@b=TfsPvz=(*sS=K^13FlcTlP!mp-62dh%uOlxb^@efh+_cs8~p+&1c^L}@Wc=dA>8;r z0J-bhspo|002ovPDHLk FV1f-SyG;N9 literal 0 HcmV?d00001 diff --git a/tests/suite/text/smartquote.typ b/tests/suite/text/smartquote.typ index f2af93ceba..6eab35076e 100644 --- a/tests/suite/text/smartquote.typ +++ b/tests/suite/text/smartquote.typ @@ -46,6 +46,10 @@ #set text(lang: "ru") "Лошадь не ест салат из огурцов" - это была первая фраза, сказанная по 'телефону'. +--- smartquote-uk --- +#set text(lang: "uk") +"Кінь не їсть огірковий салат" — перше речення, коли-небудь вимовлене по 'телефону'. + --- smartquote-it --- #set text(lang: "it") "Il cavallo non mangia insalata di cetrioli" è stata la prima frase pronunciata al 'telefono'. From 128c40d839398374c69725a5b19c24e07fb23c3d Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 4 Jun 2025 08:20:54 +0000 Subject: [PATCH 126/558] Apply script-style to numbers consistently in math (#6320) --- crates/typst-layout/src/math/text.rs | 13 ++++--------- .../ref/issue-4828-math-number-multi-char.png | Bin 465 -> 461 bytes .../ref/issue-5489-matrix-stray-linebreak.png | Bin 644 -> 716 bytes tests/ref/math-attach-kerning-mixed.png | Bin 2418 -> 2419 bytes tests/ref/math-attach-limit-long.png | Bin 1941 -> 2060 bytes tests/ref/math-attach-prescripts.png | Bin 670 -> 687 bytes tests/ref/math-frac-precedence.png | Bin 3586 -> 3592 bytes tests/ref/math-root-frame-size-index.png | Bin 902 -> 897 bytes tests/ref/math-root-large-index.png | Bin 648 -> 640 bytes 9 files changed, 4 insertions(+), 9 deletions(-) diff --git a/crates/typst-layout/src/math/text.rs b/crates/typst-layout/src/math/text.rs index 59ac5b0895..7ecbcfbafa 100644 --- a/crates/typst-layout/src/math/text.rs +++ b/crates/typst-layout/src/math/text.rs @@ -65,18 +65,13 @@ fn layout_inline_text( // Small optimization for numbers. Note that this lays out slightly // differently to normal text and is worth re-evaluating in the future. let mut fragments = vec![]; - let is_single = text.chars().count() == 1; for unstyled_c in text.chars() { let c = styled_char(styles, unstyled_c, false); let mut glyph = GlyphFragment::new(ctx, styles, c, span); - if is_single { - // Duplicate what `layout_glyph` does exactly even if it's - // probably incorrect here. - match EquationElem::size_in(styles) { - MathSize::Script => glyph.make_script_size(ctx), - MathSize::ScriptScript => glyph.make_script_script_size(ctx), - _ => {} - } + match EquationElem::size_in(styles) { + MathSize::Script => glyph.make_script_size(ctx), + MathSize::ScriptScript => glyph.make_script_script_size(ctx), + _ => {} } fragments.push(glyph.into()); } diff --git a/tests/ref/issue-4828-math-number-multi-char.png b/tests/ref/issue-4828-math-number-multi-char.png index ff0a9bab97de1957f3ea99de261346c14f8ccd9d..b365645d33edd081c77563ba8b3cf23809a3d923 100644 GIT binary patch delta 435 zcmV;k0ZjhU1I+`FB!84iL_t(|+U?a(OOpWr$8ldyFQP-Iplf#MB%}#NflydP6hRd8 z;$gLD3!R(UA8>~P4-zh*(bdd^&g;H1zNcyzG1Z9X`iji({&# z2{+*;Oyl8=^thv&e$a4OZV>Y8z?AxRQo9%P4T394z}m>6@PEh}V4%_%I9>*b1c$&u z1fU!b3vWekuG4|HuP0xS8ppHVAd{|Uo~{B+w~j(C#pWKDz*RiU)0rO*7;~K23XAIFU&7bBVyYUs&mZGlf2dQ@3})r^G=Y^(+%C zb;9Mau&Ft;F@Ika=@R_j=7bn+*%gN2^Hf z0lCHc@b4?S%Pi)Ay_B%<%7xSr_BVl&I#teZ>vFed-z?k@4SyRQ{ppAnvhVGTm72HM dgq!gH{R0B4Q+)?#OgsPp002ovPDHLkV1jDm(rf?# delta 439 zcmV;o0Z9JM1JMJJB!8GmL_t(|+U?a%OVa@W$8q0JZ=pk{plfstp)9aNN(FU@APB?= z1gEA>=GttT;DQ1XJ1D4#k|-_F9x#V#)0sucYf`lp^vQWqM|=kr@!WGr_t z9x0mZz~X6-bI-fy7r^iQUO)9tl5%1-#^oR^9F0bMsV8vm3s&yKnOscn)a^gub#$+3 zkwt>F^Kh*g7JoLAK`n1eCrvRHhxFNuso=(7sSLYI%&jsor1Un?Sy-S}SUCN;t&c`- z#_pstM3!Oa5*_V#3l9fju2C}E`OEqPpBlQFVr3db!&^bJpRsv>78Sbgs?@CgacMX- zEE_2!u>(xJs|;W9XoioYKshcnymBFt!tOUPZDt)$8rC^)}K(W^&8G|wvij;GHxoR*=|j& zkr}%Am6P%dDTJgcXe6;CG<6O)tW(@6>Rjf4&C% zCk=X759{Ge6plD%G~i(C4m;ZA*AsMoHF{EB+lT88&&&Ws)_+Lc`JJ=!A zdf(P|x-^HPOna{U6%}DB2>=qi=#HQjUJ9U4Df1piMe@9 z!qsR|4#UK05H{|kC~r2ijL8nJ=Xj?#fm*MDH9Af9eWM29D`doGm2w~?7sD_+iCLv2 zd89VX8pzE0#(z@VuCOo?6T%-75|r{ET>@u26IPc~K{`|bU|>~;2W@0ZLEqdO?jYbn zA9_(qq9D=fY=7Lju$TdWfkD+_ixb4zvlF~o#<~ahK%oi+(CNbg&K|w60D#k?I^66d zGv8!ho4EA^FKyC%U}I(_kEjkGG@>NM^E~&3k2{xsEh&JIcs@9_#Rr+Ay#t>AL5__Fs>8Sn;48y0E(AOZ z>j07SD1YtBk5q@dLvrHb9zstMS@FR*B%^JYxx!dD0JdJ zhTM6`C}4Z4cs`o&lS}Kl24M`uD#KQ(=Yn-_REN9J6U|se0Ej(@7GZR8WEPzBU>(P& z!7#iFkR=@vEKKHhH{$2+bpYV3P!YBpY$g4att~E8zq}e=)E(vmVGRU1{ZB#<>tQ|o aul)p!nYUG3woT>$0000qHPo<(8zW_vLsD&q5(v17OUl+h!`3+F>=D@UUhHwf1S`*a5 z4t8)8e~1;o4s)E{ImWEi2U=^!qEi)ZkA=^HIL#WWNnY) z10Y(6QVZiD6#KzzJwmSZY8i?Q+twlM78Y~LF}<*fhwS1#7#MbAu{@w)#MQyECkqj8 zrWo!$>SU7PZS$AjOLchL7jCWtK=&~yhH?2Ek`&)ytNCp8paR7YTgOtoJZ%Op0ARUz z&+zN%4bsy3#+|G$sh-nY1BXkY1O>+>?I)pywXhZ*w%?lP&H~-n|Lp((002ovPDHLk FV1nTEEmQyi diff --git a/tests/ref/math-attach-kerning-mixed.png b/tests/ref/math-attach-kerning-mixed.png index 9d0bea27af9c7a50b79a0d8aed64dc01e3d45ae5..64a5468696799f0c560878e62a10d767948120cb 100644 GIT binary patch delta 2408 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 delta 1926 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 delta 645 zcmV;00($+g1)c?vB!6W|L_t(|+U?cPOOtT`$8j&6`bWCerGk>gOF`Hr42yVFeQN61czio(%9vkF2gw3aav6F}?Bw1#>~fOuNS(9t+c`j=O|ae*a5)?fKcX3< z>#4VMID8kB3V+CX!+DUD6Xni!?`PspiNbS5M%1m|DjBeJG@`hgEwD?TU%lM%mQ|$~ z_MMvIWP3wD5iNcZ+84x@okdQJ<^=Sjn!kGa0%x0O{Y$e)7 zw~z&li_Tvmp9DJThGqxlU~%04IdC^o{*j9jvrc z+)WSBQ$y!cKm1J?{&~D0OhoyvW&#AVHxXH1A`&tmM1-}1AI~ez0R10c%(FjCt(9=I zYI4u?yMDn4rFvzeaJ{Qg1Cl12fN0Bt$Y@gxP#+PweaB^Lee00000NkvXXu0mjfCBiTg diff --git a/tests/ref/math-frac-precedence.png b/tests/ref/math-frac-precedence.png index fd16f2e6bf3e043a81e4e6e3146a6b15c05fc559..bddcb43c33dbe87cd906241291093c7edb68abb7 100644 GIT binary patch delta 3590 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 A2LJ#7 delta 879 zcmV-#1CacI2ZjfbB!5jwL_t(|+U?fQPm^~Xz;XWpcbV5EJ8ZjC(|!pp7-thxxA8Zo+qF07G6+WG+2W*Sc5keK7Vh`HAX?Hw)`k07?zI* zaz!bqeL6o%3WghEY$$bPGp<@b%DAi90BSJ|^K?6ZniC95GF9%-YoO+Ta*)gpemg1z zzH#>4HSYGnS{;D#1a#Z13Z08!n*r88zas*c%d^rT<0UE1JOsQ4dode$m!(q&;Aa3^ z<$?&f*+4}cYJbhCdf<8{y)CwVZTSmO-i$jZAbE?d0nb=1)6Ci7kpyJh^kB1@(!^VULjcoX zApnk)Be|F?Numz{zQcBmCfbyPQq`1KWO2bPXbZr$B&>DO1{#&|nRIZrEq9RlHyE|P0i zJ=%O1FI{vuLv?;E3xw-eDQwEeh3g?bSN;)v)qmmRc$~Tus7*}XKYL?T;y`w#qi1hI z9Qy({-ZS8ZI`&n|DVVXxIZ#cU_6HUNsg4Xn1-Gyn++s8)cR6%-i` z??Jl2bjdn@gn^kO=VV3^sPiheeB%& zfN1(Bun^!|OOfFwPqb_ZmP-wOU}qrtl3b4QjQZx-GhhH}EPQ4vGA!F&E#pAtj{xJ2 zDkmQ2;7N5mdtOZdQTyrIjG(VN8048vYD+?cHCTf;t*3U+FG98!$i@Hw002ovPDHLk FV1fz?qpkn| diff --git a/tests/ref/math-root-large-index.png b/tests/ref/math-root-large-index.png index 85689823da386744b879c83bb6d72ccd8706d8db..29dd478fe568b02e112791b7bf41689967e0c209 100644 GIT binary patch delta 615 zcmV-t0+{`X1%L&RB!5RqL_t(|+U?feOH%S!K@7bKMTw*- zg-*!A9He4k=1ZeYA#FNSN2Qe8NHNPWmrae$y|HX=*><*bzUi)Zww(=jAiX%B+uy|( zo~x&beNv=_wXhb}!Us0|>smrxIBM(=DMWKzT^L6t`x4&twSTTtfNy=HVKrgFGh_@V z3JA~j42IN%BTK!;r6}1Y#{Zz?qg0KWFs_vrFACyat0;OqsxFMLT|mizVmNFnt?>eW z_bwq{Sc>gQ$h#M-+(05ZNyx!^D=?IfM<$CBh1mpHDK0iA0y4msd^wWvNkeL2JeyEA~5POTS+6GhyaWY4LWm$ z`>vp*deDVhAN$|&KRp$IRyX!002ovPDHLkV1gV2 BCT{=$ delta 623 zcmV-#0+9WH1&9TZB!5pyL_t(|+U?fOOB7)oz;XW|>C~Y^5FG;R5*{R~i-#hEZp!*@ zWW|D7SxYIBs2Sz1u5UzQCff!Pt0uT>3%=z`y0*2>y13)+G&}Q6y*=!XgAP=O`5d1g z4`2B49G*P>kvuzWhwZQ({$Im#S;T5sa<_5}FTPt1>$Bw?Lx0zr)$l^HdWR!h>Gme9 zg_Y(0zAc@j3U|JgtcABj?Y*-Za>|Uz7zn1ybZcRPziJe6Q=czlWTMY%n3Xmi18$?R z>@O~f=n&i3lQIu$%05jv`*yiR0aRrl0_C2^;8r5s?OD)SzT3HH-8}rTabHMWbs(?` z$-0A4N_g*qJ%3x!a4?z^Zv9wv0CkG_s#{$l<~qo>QyEm@@g`Ze>(c(-3|~BCORZO_ zthgYSG6|=9CaC;P4ufAozD*8oNM3O0WSe~Z1gd0)FZfKt107TI&p?B$$t*lt1r04% zc)ANTbVM4k(Fiz9!us$~v?9gKtD7xF2-Hy@>2erwUw>_C5|{Mh=lo|%Gfef|Xt^Sc zV#Qu?Cd|X5rLfryGn|v7Vz+t(+UFq{EmY$O25Moc4Q4o_MiDHXCeeU-c>81o=MIoA zASq0CLub)z0lY3`1k#B(^$HL#B#Ef#Q*#XSFkO}L2Y}3!aF#Y=4`ymOD z%NcnJYG_qY#gZRU;Jgzceci1c8dme`CBQfB9|O_HOSM39aIHTKB02w#42*@weSoMS z0x;g!8$L8lLperHO<<(vzU8lvvWpQ3L%WLo*|WoT*be_Se*k<$&z8G_6=VPa002ov JPDHLkV1nH`CQASS From 5f776c7372ffecbbe959fbfa968c8c91efaf0061 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 4 Jun 2025 09:41:08 +0000 Subject: [PATCH 127/558] Bump New CM fonts to version 7.0.2 (#6376) --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 30a4db7a7b..347704b332 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2863,7 +2863,7 @@ dependencies = [ [[package]] name = "typst-assets" version = "0.13.1" -source = "git+https://github.com/typst/typst-assets?rev=ab1295f#ab1295ff896444e51902e03c2669955e1d73604a" +source = "git+https://github.com/typst/typst-assets?rev=c74e539#c74e539b090070a0c66fd007c550f5b6d3b724bd" [[package]] name = "typst-cli" diff --git a/Cargo.toml b/Cargo.toml index bc563b980c..0f871e211c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ typst-svg = { path = "crates/typst-svg", version = "0.13.1" } typst-syntax = { path = "crates/typst-syntax", version = "0.13.1" } typst-timing = { path = "crates/typst-timing", version = "0.13.1" } typst-utils = { path = "crates/typst-utils", version = "0.13.1" } -typst-assets = { git = "https://github.com/typst/typst-assets", rev = "ab1295f" } +typst-assets = { git = "https://github.com/typst/typst-assets", rev = "c74e539" } typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "fddbf8b" } arrayvec = "0.7.4" az = "1.2" From 1de2095f67c9719a973868618c3548dd6083f534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Linus=20Unneb=C3=A4ck?= Date: Wed, 4 Jun 2025 11:54:03 +0200 Subject: [PATCH 128/558] Add support for WebP images (#6311) --- Cargo.lock | 1 + Cargo.toml | 2 +- crates/typst-ide/src/complete.rs | 4 +++- crates/typst-layout/src/image.rs | 1 + crates/typst-library/src/visualize/image/mod.rs | 4 ++-- crates/typst-library/src/visualize/image/raster.rs | 6 ++++++ crates/typst-svg/src/image.rs | 1 + docs/tutorial/1-writing.md | 2 +- tests/suite/visualize/image.typ | 2 +- 9 files changed, 17 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 347704b332..a9b3756a6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1215,6 +1215,7 @@ dependencies = [ "byteorder-lite", "color_quant", "gif", + "image-webp", "num-traits", "png", "zune-core", diff --git a/Cargo.toml b/Cargo.toml index 0f871e211c..b4890e3c16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,7 +69,7 @@ icu_provider_adapters = "1.4" icu_provider_blob = "1.4" icu_segmenter = { version = "1.4", features = ["serde"] } if_chain = "1" -image = { version = "0.25.5", default-features = false, features = ["png", "jpeg", "gif"] } +image = { version = "0.25.5", default-features = false, features = ["png", "jpeg", "gif", "webp"] } indexmap = { version = "2", features = ["serde"] } infer = { version = "0.19.0", default-features = false } kamadak-exif = "0.6" diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index 15b4296eb7..4a36045ae7 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -841,7 +841,9 @@ fn param_value_completions<'a>( /// Returns which file extensions to complete for the given parameter if any. fn path_completion(func: &Func, param: &ParamInfo) -> Option<&'static [&'static str]> { Some(match (func.name(), param.name) { - (Some("image"), "source") => &["png", "jpg", "jpeg", "gif", "svg", "svgz"], + (Some("image"), "source") => { + &["png", "jpg", "jpeg", "gif", "svg", "svgz", "webp"] + } (Some("csv"), "source") => &["csv"], (Some("plugin"), "source") => &["wasm"], (Some("cbor"), "source") => &["cbor"], diff --git a/crates/typst-layout/src/image.rs b/crates/typst-layout/src/image.rs index 3e5b7d8bd0..8136a25a3d 100644 --- a/crates/typst-layout/src/image.rs +++ b/crates/typst-layout/src/image.rs @@ -147,6 +147,7 @@ fn determine_format(source: &DataSource, data: &Bytes) -> StrResult "jpg" | "jpeg" => return Ok(ExchangeFormat::Jpg.into()), "gif" => return Ok(ExchangeFormat::Gif.into()), "svg" | "svgz" => return Ok(VectorFormat::Svg.into()), + "webp" => return Ok(ExchangeFormat::Webp.into()), _ => {} } } diff --git a/crates/typst-library/src/visualize/image/mod.rs b/crates/typst-library/src/visualize/image/mod.rs index 258eb96f34..f9e345e70c 100644 --- a/crates/typst-library/src/visualize/image/mod.rs +++ b/crates/typst-library/src/visualize/image/mod.rs @@ -77,8 +77,8 @@ pub struct ImageElem { /// [`source`]($image.source) (even then, Typst will try to figure out the /// format automatically, but that's not always possible). /// - /// Supported formats are `{"png"}`, `{"jpg"}`, `{"gif"}`, `{"svg"}` as well - /// as raw pixel data. Embedding PDFs as images is + /// Supported formats are `{"png"}`, `{"jpg"}`, `{"gif"}`, `{"svg"}`, + /// `{"webp"}` as well as raw pixel data. Embedding PDFs as images is /// [not currently supported](https://github.com/typst/typst/issues/145). /// /// When providing raw pixel data as the `source`, you must specify a diff --git a/crates/typst-library/src/visualize/image/raster.rs b/crates/typst-library/src/visualize/image/raster.rs index 21d5b18fc6..54f832baee 100644 --- a/crates/typst-library/src/visualize/image/raster.rs +++ b/crates/typst-library/src/visualize/image/raster.rs @@ -9,6 +9,7 @@ use ecow::{eco_format, EcoString}; use image::codecs::gif::GifDecoder; use image::codecs::jpeg::JpegDecoder; use image::codecs::png::PngDecoder; +use image::codecs::webp::WebPDecoder; use image::{ guess_format, DynamicImage, ImageBuffer, ImageDecoder, ImageResult, Limits, Pixel, }; @@ -77,6 +78,7 @@ impl RasterImage { ExchangeFormat::Jpg => decode(JpegDecoder::new(cursor), icc), ExchangeFormat::Png => decode(PngDecoder::new(cursor), icc), ExchangeFormat::Gif => decode(GifDecoder::new(cursor), icc), + ExchangeFormat::Webp => decode(WebPDecoder::new(cursor), icc), } .map_err(format_image_error)?; @@ -242,6 +244,8 @@ pub enum ExchangeFormat { /// Raster format that is typically used for short animated clips. Typst can /// load GIFs, but they will become static. Gif, + /// Raster format that supports both lossy and lossless compression. + Webp, } impl ExchangeFormat { @@ -257,6 +261,7 @@ impl From for image::ImageFormat { ExchangeFormat::Png => image::ImageFormat::Png, ExchangeFormat::Jpg => image::ImageFormat::Jpeg, ExchangeFormat::Gif => image::ImageFormat::Gif, + ExchangeFormat::Webp => image::ImageFormat::WebP, } } } @@ -269,6 +274,7 @@ impl TryFrom for ExchangeFormat { image::ImageFormat::Png => ExchangeFormat::Png, image::ImageFormat::Jpeg => ExchangeFormat::Jpg, image::ImageFormat::Gif => ExchangeFormat::Gif, + image::ImageFormat::WebP => ExchangeFormat::Webp, _ => bail!("format not yet supported"), }) } diff --git a/crates/typst-svg/src/image.rs b/crates/typst-svg/src/image.rs index d74432026c..1868ca39b6 100644 --- a/crates/typst-svg/src/image.rs +++ b/crates/typst-svg/src/image.rs @@ -45,6 +45,7 @@ pub fn convert_image_to_base64_url(image: &Image) -> EcoString { ExchangeFormat::Png => "png", ExchangeFormat::Jpg => "jpeg", ExchangeFormat::Gif => "gif", + ExchangeFormat::Webp => "webp", }, raster.data(), ), diff --git a/docs/tutorial/1-writing.md b/docs/tutorial/1-writing.md index acc257830f..d505d2d038 100644 --- a/docs/tutorial/1-writing.md +++ b/docs/tutorial/1-writing.md @@ -69,7 +69,7 @@ the first item of the list above by indenting it. ## Adding a figure { #figure } You think that your report would benefit from a figure. Let's add one. Typst -supports images in the formats PNG, JPEG, GIF, and SVG. To add an image file to +supports images in the formats PNG, JPEG, GIF, SVG, and WebP. To add an image file to your project, first open the _file panel_ by clicking the box icon in the left sidebar. Here, you can see a list of all files in your project. Currently, there is only one: The main Typst file you are writing in. To upload another file, diff --git a/tests/suite/visualize/image.typ b/tests/suite/visualize/image.typ index 9a77870af0..73c4feff8f 100644 --- a/tests/suite/visualize/image.typ +++ b/tests/suite/visualize/image.typ @@ -243,7 +243,7 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B --- image-png-but-pixmap-format --- #image( read("/assets/images/tiger.jpg", encoding: none), - // Error: 11-18 expected "png", "jpg", "gif", dictionary, "svg", or auto + // Error: 11-18 expected "png", "jpg", "gif", "webp", dictionary, "svg", or auto format: "rgba8", ) From aee99408e1cb6e825992a43399597f5d1a937230 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 4 Jun 2025 10:14:24 +0000 Subject: [PATCH 129/558] Apply short fall consistently in math when stretching (#6377) --- crates/typst-layout/src/math/accent.rs | 2 +- crates/typst-layout/src/math/frac.rs | 4 ++-- crates/typst-layout/src/math/fragment.rs | 12 +++--------- crates/typst-layout/src/math/mat.rs | 4 ++-- crates/typst-layout/src/math/root.rs | 2 +- crates/typst-layout/src/math/stretch.rs | 11 ++++------- crates/typst-layout/src/math/text.rs | 2 +- crates/typst-layout/src/math/underover.rs | 2 +- tests/ref/gradient-math-conic.png | Bin 1721 -> 1642 bytes tests/ref/gradient-math-dir.png | Bin 2615 -> 2575 bytes tests/ref/gradient-math-mat.png | Bin 1560 -> 1557 bytes tests/ref/gradient-math-misc.png | Bin 2993 -> 3138 bytes tests/ref/gradient-math-radial.png | Bin 1641 -> 1606 bytes tests/ref/issue-1617-mat-align.png | Bin 3354 -> 3335 bytes .../issue-3774-math-call-empty-2d-args.png | Bin 1315 -> 1334 bytes tests/ref/math-accent-bottom-high-base.png | Bin 572 -> 567 bytes tests/ref/math-accent-bottom-wide-base.png | Bin 359 -> 351 bytes tests/ref/math-accent-wide-base.png | Bin 510 -> 506 bytes tests/ref/math-cases-gap.png | Bin 340 -> 354 bytes tests/ref/math-cases-linebreaks.png | Bin 506 -> 492 bytes tests/ref/math-cases.png | Bin 1281 -> 1228 bytes .../math-mat-align-explicit-alternating.png | Bin 1035 -> 927 bytes tests/ref/math-mat-align-explicit-left.png | Bin 989 -> 903 bytes tests/ref/math-mat-align-explicit-mixed.png | Bin 2523 -> 2454 bytes tests/ref/math-mat-align-explicit-right.png | Bin 976 -> 875 bytes tests/ref/math-mat-align-implicit.png | Bin 1046 -> 954 bytes tests/ref/math-mat-align-signed-numbers.png | Bin 2036 -> 2024 bytes tests/ref/math-mat-align.png | Bin 1564 -> 1531 bytes tests/ref/math-mat-augment-set.png | Bin 1810 -> 1714 bytes tests/ref/math-mat-augment.png | Bin 3631 -> 3563 bytes tests/ref/math-mat-baseline.png | Bin 818 -> 816 bytes tests/ref/math-mat-gap.png | Bin 496 -> 526 bytes tests/ref/math-mat-gaps.png | Bin 1309 -> 1311 bytes tests/ref/math-mat-linebreaks.png | Bin 651 -> 648 bytes tests/ref/math-mat-sparse.png | Bin 882 -> 956 bytes tests/ref/math-mat-spread.png | Bin 1814 -> 1796 bytes tests/ref/math-shorthands.png | Bin 1231 -> 1173 bytes .../math-vec-align-explicit-alternating.png | Bin 1035 -> 927 bytes tests/ref/math-vec-align.png | Bin 1098 -> 1126 bytes tests/ref/math-vec-gap.png | Bin 420 -> 436 bytes tests/ref/math-vec-linebreaks.png | Bin 651 -> 648 bytes tests/ref/math-vec-wide.png | Bin 620 -> 630 bytes 42 files changed, 15 insertions(+), 24 deletions(-) diff --git a/crates/typst-layout/src/math/accent.rs b/crates/typst-layout/src/math/accent.rs index 53dfdf055a..3016064662 100644 --- a/crates/typst-layout/src/math/accent.rs +++ b/crates/typst-layout/src/math/accent.rs @@ -46,7 +46,7 @@ pub fn layout_accent( // wide in many case. let width = elem.size(styles).relative_to(base.width()); let short_fall = ACCENT_SHORT_FALL.at(glyph.font_size); - let variant = glyph.stretch_horizontal(ctx, width, short_fall); + let variant = glyph.stretch_horizontal(ctx, width - short_fall); let accent = variant.frame; let accent_attach = variant.accent_attach.0; diff --git a/crates/typst-layout/src/math/frac.rs b/crates/typst-layout/src/math/frac.rs index 6d3caac45d..2567349d06 100644 --- a/crates/typst-layout/src/math/frac.rs +++ b/crates/typst-layout/src/math/frac.rs @@ -110,12 +110,12 @@ fn layout_frac_like( if binom { let mut left = GlyphFragment::new(ctx, styles, '(', span) - .stretch_vertical(ctx, height, short_fall); + .stretch_vertical(ctx, height - short_fall); left.center_on_axis(ctx); ctx.push(left); ctx.push(FrameFragment::new(styles, frame)); let mut right = GlyphFragment::new(ctx, styles, ')', span) - .stretch_vertical(ctx, height, short_fall); + .stretch_vertical(ctx, height - short_fall); right.center_on_axis(ctx); ctx.push(right); } else { diff --git a/crates/typst-layout/src/math/fragment.rs b/crates/typst-layout/src/math/fragment.rs index 85101c486d..01fa6be4be 100644 --- a/crates/typst-layout/src/math/fragment.rs +++ b/crates/typst-layout/src/math/fragment.rs @@ -435,13 +435,8 @@ impl GlyphFragment { } /// Try to stretch a glyph to a desired height. - pub fn stretch_vertical( - self, - ctx: &mut MathContext, - height: Abs, - short_fall: Abs, - ) -> VariantFragment { - stretch_glyph(ctx, self, height, short_fall, Axis::Y) + pub fn stretch_vertical(self, ctx: &mut MathContext, height: Abs) -> VariantFragment { + stretch_glyph(ctx, self, height, Axis::Y) } /// Try to stretch a glyph to a desired width. @@ -449,9 +444,8 @@ impl GlyphFragment { self, ctx: &mut MathContext, width: Abs, - short_fall: Abs, ) -> VariantFragment { - stretch_glyph(ctx, self, width, short_fall, Axis::X) + stretch_glyph(ctx, self, width, Axis::X) } } diff --git a/crates/typst-layout/src/math/mat.rs b/crates/typst-layout/src/math/mat.rs index d678f86585..e509cecc7e 100644 --- a/crates/typst-layout/src/math/mat.rs +++ b/crates/typst-layout/src/math/mat.rs @@ -314,7 +314,7 @@ fn layout_delimiters( if let Some(left) = left { let mut left = GlyphFragment::new(ctx, styles, left, span) - .stretch_vertical(ctx, target, short_fall); + .stretch_vertical(ctx, target - short_fall); left.align_on_axis(ctx, delimiter_alignment(left.c)); ctx.push(left); } @@ -323,7 +323,7 @@ fn layout_delimiters( if let Some(right) = right { let mut right = GlyphFragment::new(ctx, styles, right, span) - .stretch_vertical(ctx, target, short_fall); + .stretch_vertical(ctx, target - short_fall); right.align_on_axis(ctx, delimiter_alignment(right.c)); ctx.push(right); } diff --git a/crates/typst-layout/src/math/root.rs b/crates/typst-layout/src/math/root.rs index c7f41488ef..32f5271986 100644 --- a/crates/typst-layout/src/math/root.rs +++ b/crates/typst-layout/src/math/root.rs @@ -50,7 +50,7 @@ pub fn layout_root( // Layout root symbol. let target = radicand.height() + thickness + gap; let sqrt = GlyphFragment::new(ctx, styles, '√', span) - .stretch_vertical(ctx, target, Abs::zero()) + .stretch_vertical(ctx, target) .frame; // Layout the index. diff --git a/crates/typst-layout/src/math/stretch.rs b/crates/typst-layout/src/math/stretch.rs index 6157d0c503..40f76da59d 100644 --- a/crates/typst-layout/src/math/stretch.rs +++ b/crates/typst-layout/src/math/stretch.rs @@ -67,8 +67,7 @@ pub fn stretch_fragment( let mut variant = stretch_glyph( ctx, glyph, - stretch.relative_to(relative_to_size), - short_fall, + stretch.relative_to(relative_to_size) - short_fall, axis, ); @@ -120,7 +119,6 @@ pub fn stretch_glyph( ctx: &mut MathContext, mut base: GlyphFragment, target: Abs, - short_fall: Abs, axis: Axis, ) -> VariantFragment { // If the base glyph is good enough, use it. @@ -128,8 +126,7 @@ pub fn stretch_glyph( Axis::X => base.width, Axis::Y => base.height(), }; - let short_target = target - short_fall; - if short_target <= advance { + if target <= advance { return base.into_variant(); } @@ -153,13 +150,13 @@ pub fn stretch_glyph( for variant in construction.variants { best_id = variant.variant_glyph; best_advance = base.font.to_em(variant.advance_measurement).at(base.font_size); - if short_target <= best_advance { + if target <= best_advance { break; } } // This is either good or the best we've got. - if short_target <= best_advance || construction.assembly.is_none() { + if target <= best_advance || construction.assembly.is_none() { base.set_id(ctx, best_id); return base.into_variant(); } diff --git a/crates/typst-layout/src/math/text.rs b/crates/typst-layout/src/math/text.rs index 7ecbcfbafa..e191ec1708 100644 --- a/crates/typst-layout/src/math/text.rs +++ b/crates/typst-layout/src/math/text.rs @@ -159,7 +159,7 @@ fn layout_glyph( let mut variant = if math_size == MathSize::Display { let height = scaled!(ctx, styles, display_operator_min_height) .max(SQRT_2 * glyph.height()); - glyph.stretch_vertical(ctx, height, Abs::zero()) + glyph.stretch_vertical(ctx, height) } else { glyph.into_variant() }; diff --git a/crates/typst-layout/src/math/underover.rs b/crates/typst-layout/src/math/underover.rs index 5b6bd40eb6..a24113c81b 100644 --- a/crates/typst-layout/src/math/underover.rs +++ b/crates/typst-layout/src/math/underover.rs @@ -286,7 +286,7 @@ fn layout_underoverspreader( let body_class = body.class(); let body = body.into_fragment(styles); let glyph = GlyphFragment::new(ctx, styles, c, span); - let stretched = glyph.stretch_horizontal(ctx, body.width(), Abs::zero()); + let stretched = glyph.stretch_horizontal(ctx, body.width()); let mut rows = vec![]; let baseline = match position { diff --git a/tests/ref/gradient-math-conic.png b/tests/ref/gradient-math-conic.png index ffd3e8068f65a6c50e76af647605aa5b21005338..9bac6c3d4ced5297eabd91af61bcd7addfb2d4a8 100644 GIT binary patch delta 1638 zcmV-s2ATP}4eAV#7k@Yi0ssI2xn8U3000ItNkl&$@C`$=+QAxUFYZ{WKY11r?vo@~dI9}p?x8r>q$74HQ#vaf1 zBaw2L(U__gNu+3=j~-c;Y+t_f*W(#!<|PoP;w6%RgeIX05`UV6CZP!unuI1uXcGE= znqJRRSSz_o@@!tQj*lHw9pL z(&z9{P)ek#(|F_!9NI%NYRA6S`Dx<%>-Kccy$UZxP*; zE3LsHt0Wuu(P2xi*4BFH?*~JLGW+b%>f8gPPEnMg!Z4h{5ZCE~JTC%A0=~l1qF>XY z8!h3JJ}-dh56yOjxus}_6F5$U(!to)8!UjyNr!zW1AiP51gYA*>O3u+gownW1n(k!Rn&VY~0mu=ufW&t>GF@ zo_i-XGkP^@+PP@PTp>a8XM4SG&Ca9GWUJSIThwT1YM*<(*Mn10 zt1^Fl`{2xnmb`AfxLk#M^*bNXoghd!KK1=pU+?`h9=cXz-+%Am6)(u1Y`xOsM>iDR z9-IzCMUrswf^M+i@My{|Nl*)VGImpRecst(I)9KUq|3jbpE%p0`Najc=L;zr#|uhU z_?N#8qnn58>4Awr*YjxeX0%yn|7(Aky}Jb<>`5QIS57o@xp(pmhZds|F%o7k{VF)@ z%KSV!9mNtw)!Y`uciw=CB~VS|nl~qWP?1E*ub9yqyU*xzH;TOv9lVQH*InH4eqJF^;ci#!L%Y`sU+sI z@+DQPP!j$A+BUe=An}G zrKn^PfPQK#AEQ)$iIbH5!^P(UDI+$Ej#mnryXms5M6@laJGlsM)>YO)0DQxr^?yP7xbh3ZA=I}y> zwz+{r)V5P-vrwW#iHz9`An%IQvU^YpZ#bfxZUDX&Yax_|jALQjrL$-^G=Hr(TP%fty#|%o z)MIodNwF1^wSUTVd0gmlP^zRU_l#cPc;l@(?_-nfSD5{2R1!rEg;opxd_`bdiu!al zFffj8GDb(USOyS(VojUeP=BfA>8va2oi`?}0pJLoC~S@B0BAZiZQ7w$4UnmL!_o0s zbQ51PHu5C^_1s?CXSU7!|m$rku~eS?0=GNv`mBZcW^sHv4Td}{M4rkbNN5t8 kAfZWU5}F{PNoWGVzrc)+4>46(^b07*qoM6N<$f=GfJZvX%Q delta 1718 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 Z`7hW!#nL9z|C0a!002ovPDHLkV1jBn2Ydhk diff --git a/tests/ref/gradient-math-mat.png b/tests/ref/gradient-math-mat.png index ecf9530389ebe41ea2ccc67c00a20bce7c8c416d..d003d6d03a21e22a04c21308f168a31c09f9551e 100644 GIT binary patch delta 1553 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 literal 2993 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 diff --git a/tests/ref/gradient-math-radial.png b/tests/ref/gradient-math-radial.png index 8d0047bbe491b86029ba81088f498b5361008a15..97fb17e6fbb1778200ae2fc10226d5d4c74f190a 100644 GIT binary patch delta 1602 zcmV-I2EF;|48{zQ7k@Yi0ssI2xn8U3000IJNkl5RxiT#iA)y)?hFmuq!0CsYvZup#(Prp!&w^ix||+;rPh^a5rk1~?2c3mc0`1m z147-9P~BeAGHbXXt$C#AZU9vYg-*GF){R zy&(ncAX<5VM2h2*W%zqUWGCaL9<9DTDgNE?c!(L|x zJ>i3v8nBp(9tAr(;QH11h$uuj$&(both+Wc)|Dhf{iVQ2M*grutTf+u~jKLb{H@fK&F{ma(@|ip8r+4@#>6ogI+t-1o2had7*Lk-U**mgGM;FzaB&tc3yg^RC&bf(4e)P z$Q^tHyJ9UXo&UIZ&j-CGPs7$>hJ(*;!zM5w*gnOaeD>h0y&bfSOD^UR(u%NEp(K2M z0_-SCGRRiirH{Xfx1I@4+**w5U}MJ1kb~WeyMG~}sDc66$WQO@+1o(}kn1&njzdw{ zDwc)gN5M|4Q0p&Uw>?L979s_tf}KKECMRez%nmT5E8eLmI)S4}9PW~DWKaN%Fak{m zO#!s=qwAK&Vc^;UuwcdEq88~FZ`ua^l!;E{OVg|b7M`YP+wFyEPiAf~`%YB{25=(} z0)Mq#eK?Ey!JwG$EhQU-XuY!3fEF6`*+xi=S`uC0>)y9)*lZ5XmT>l&atrK)c@Y4v z4CPk^9n3naf}PdD@_Mr5Uj>6qnEUy|JzE_H|0GH07n`BsDI}F-3~0 zVhjl}U17mYDozFm-vcwBTsMvS`ceB$<$vO`z6@p|P6!nxGCcJ0T~iX6^*srfH}B#0 zgsXN543fd(=?}X1WY8>yJtG3x8GVuTy-azVV|$>ZcTEaM zPD)2$$LxWQ-W?3i9^=o#uE?)QXAZa=8Z@hOUuSA|Z3TC3bx6AQNx-ecZdeI(m%s2h zG-%?}?r>h#%tsN>F}xkt;VpCxgMY=sNUrLKQRfDIw0?CthrpoUFMV6Fa_W4l;+L4K zg9S6B=)UfM@5FeUqF|xQI@wufJdE{KEID73?cBLRS3W9?%}8J{K3rTMdNZMsB z6RtEFW(OJ424F_sf0I?Eytyn+tb#%JgGI8!I5+5l-h69a+O|?jrnb-QwSU82PgB&> z7}YHmLtYh-$8Oi#@cY`_@Ebpz7f&JCKlb8JN95Ev8~>G6fB%@)60QYg0s*#N$ZGB*@^2Mef42_5&g|wL*x)_pKO9BhdU6A=~pW({VR(a zY#-9j^vgo@TUithng&hLplQ%FXo?0+gQfuB9}DrB4(bZ{wEzGB07*qoM6N<$g8JnB AsQ>@~ delta 1637 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 RGY$X%002ovPDHLkV1glBKE(h4 literal 3354 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 diff --git a/tests/ref/issue-3774-math-call-empty-2d-args.png b/tests/ref/issue-3774-math-call-empty-2d-args.png index c1bf52d0040aee18b7b00eefb7ba55ab48dafe9d..52472d8dbeed2803c9dd5d075f7244d213abef66 100644 GIT binary patch delta 1328 zcmV-01<(4U3bqQ67k}&s00000n)p3i000F2Nkl z2j+U1%h~FpW*szh9dn`(P7F-cU|N{sW9mdvvj>NDqvnFrMQZqf&POoMkWMHuNfa5R z2!bFF;o-onHh;vz7Phd3(T8(9-k&1@ zU(A5R=k(#O_yDl1Qyt!S0pRRjb$D6`AT&)IzO-DLenlBxbyj*NT^V*)NpH?nh9`7M zedBcD?&m=^tXmbXj|N$Ey@id!4gbLns&HT^$c6`0VWK;wzKQ6fF zmfn6x86NjU>K~&E`=dZM+;2}2gA;C;0I~@Us&Hs1$bW`~RADBzOFLgc7oJ@%{VP@( zUY{?`-=GXvd?fv_Oc~}qKY-tjHvCf_z_Fw1a3CcF9dm=~Fy-Gs(hc-se)D=R+b4{L zEo@;64{+G$iwwNw+KsOx0q5+6%Q^b+qeTFpHmSqAyZ|Mc+Hmk~sVk%mAKf6`a#9&y zQ6#;Ps(%bmua(}3QHI9^q)$fc!oCq8i}Kl1#NdPTe^(1)wGLqawBFlFB2 z>-Gs_VGCQ>!UG&`X^9M+ezQ6=67b$!xb~es+<*8nz^XcRcuP4z*?Mib+a-+&D8q-g zN_YIC45wX@7A{qWU3aB5Zz;p0gVMlAUAR*`U__@qMGQ{3X&lJLH~mj;Ad{Mpimtq)dzL+(TDGSPGD|5`Y?C? zD1WK4PZ$eZ*uoYb*l@6}E*J?olu}#2A&5S_{RC9*)`#z{1Xy3K4zIZdaC4Rfd-pOD`{0hNsp^|9(Rm9vPAbqjcepQ6L-DVNVf*6Mi@rWMdyj=ms(z zU3hYnv}KAioZyvSU!V+skt6+Kmoofdj(_z1`^qr$n*mx9(S{4N0kTWfVLoYxs$}%x zhiijfu`TGs)L$>XZ=Wz0wy=dQJn-Ohho4Nr$F~!9-<~oD-(Cc?*U;O+{ z7JaWH&J>)#6Cf`f%$IqF;F!n#!!2)`fjzPG5A#8>5qRDAPVj*YBk<%JfYz*Kjemq$ zu+`QlV=W>MJU0fmw})V6xBC&kFWn3MTD7VJTNRA9L`N mEW&Q9-=3Q+Y+(xza`<2R*CZT0x74Zt0000*X)3Zn{;7k}*t00000QKatv000E)NklY`(#l!N@ahxN z+%?K@ak6w#p?@;W^j-k}bhP2U#{qJGQipwufbO}y>M(_SAh`&Am{ab8WA-FrVGCQ> z!u=c$2EzmIx^iuAIN+QuaPp`={C7OS(sp$?^D;pGPIcIYL>tasC0&=N45yrzo?WgC zzf&W<^|mrRrbp@LSUeSi4gM*yqp)ZtBK0Hy1-;a-;34QT=N;f&)@xlf3lm7L(GCVve4Mga|T_Zp?qRU=I3{1FXG{{D`gy{}s zD7x^ZW@+1GWjM|)Es0l#zsQpQxI-Ckntv^QuU;8uUMoP`e6-<M$!hp(+u5 zxMgjiJGu>hn1+(#dV7Siu!Svb;r<7gIs9Y_K9)hqeS6Xzd}{%HZ1qiJ@Qg0FzBP+S zuPeqBoUt!3o}%Y6PBC>jYO$GJgSg zJP`o6nv#@9s6EwT04|NdaW51bfRDY3<6OTPfHUXfxVTIMaQZ?Vw=mrRoHifF#itp7 z6I?tz_vU^4XRZVTaC#CCujl`7bW*woj1s(TrZYTqnE^OzqBA@(O6RZ62+uRVx;i;e z1KyB`;}SO*fa9m&xGC`}&lpsm@qe~N9G9@&0Gt(tfhTJXzzptif;*nZ1RQhP3C@o-0`L9I2~OQ_1n!y~8qNUVwfjb3KE1%BcXjQG zJm5+yX5hMb`rOQWhF~)D=wrFNFa@);o{+1YhdH=sDJOBUy07*qoM6N<$f@^djP5=M^ delta 71 zcmV-N0J#6R1iS>0BLV^gu_fLCIdF8^?rvGLa^>=sYu7Aav2tnKnE~o8UK;Hmk6Jux d@u6Fv002ovPDHLkV1j5o9?bv% diff --git a/tests/ref/math-accent-bottom-wide-base.png b/tests/ref/math-accent-bottom-wide-base.png index 0475b4856bd49b9a4f7663564747ca9635654028..fb4a1169b745905df726e50d90e56d03d9fe11bf 100644 GIT binary patch delta 102 zcmV-s0Ga>i0^b6VBmpLoCE!}00^~D{abOJu9HdRj^>EAT6)RUPTe)V%AgS%!Xnj0t@er^W0BVrlFPS4}qW}N^07*qo IM6N<$f`ErEX#fBK delta 110 zcmV-!0FnRS0_OsdBmpjwCE#MO0^}bggb%ENfP=J%xGu!1iRQl`Gt@JFb|7?WNZ>P1N-O{rBAKhywNNW2wS|5*EJOnHT0M8KIstp54 Q*Z=?k07*qoM6N<$g8n2lN&o-= diff --git a/tests/ref/math-accent-wide-base.png b/tests/ref/math-accent-wide-base.png index af716bf45f7e1f270278e5ecba92c4a1adf0bb4e..793ab30bd3b2318829cc63dc193a6f6b6399ba4a 100644 GIT binary patch delta 480 zcmV<60U!SU1NsAyB!9t4L_t(|+U=FyD??!%$Ndj36*nT~#w8`BCPnVZ+>w_eNm7y& zbCi;lw|RfLaAUccSs|2{=7ycaHnUC4OJQov#&+8I+Ih}ar(Hib7w2>FJl~$4`t4xBY%sS9D8Gkp)oAiwzY@o zh`rYwXEtA1SeA_h&Uz^Zmc6ucHy#_0JLiClnKC)BsSb3 zMkxp zOmKzYz;ZzT;=u#H^bBudsn$=l#QL&h>=*Sv4wlT1by-`0qgL&t339P>X1&dTp;a-j zoIu3$CoMA^3*5W>)C}NsiDYkY0(X0ehlFb*%8xRS6|o{ty7(6$ WXFuwI9Dtbs0000Q3X&G8t1#Sh&ok!r5uIL^?b zFFj)e*4coE&ZNoVIwvq*w>ee5`y(Gnxl!?{hY<_*^)mrq>Y7OPX8}e%RtprMmx3+6 zuS`C*o%AQw&3{b5^QVc53J#Smp=1QWDJ7a*G;pyn(g>>VN`}|T2;Bw&r>zWCdy!$K z%_#6w(W}7(s&MWiGqAs9s-~8X+Lj6ZVC0B?z{7DB%^Y_RjP|1<_?bQ_WCos)ahD*g z;JsuB+0VdiDEh=b8*=_0uETWJ=d8fyf^Dqlbe;9&OGQ*z>;U>C!NXqUEz11Cm_*U=Bz0nP(taZ%d`Ak#ovs=F!>WNeN(!Cr(^RXN&JBe;=UU;@pHk1Jq63 mojp1tFlzCr#iJIF1d9PQ^aSoH$y^8k00000 zpzKtY2V;$_vik0{%7=8e*wF+;rvAHZN@t7f3P5D}w;y73wm8xoLNBfvn8bCbYG4xA ztJ(jz(%ItVP(KjquchTmXNxB#3{XD4qx^kbP{gRkqZW@^JQ6Gh Y0KklU)%RWnK>z>%07*qoM6N<$g3u(OEdT%j diff --git a/tests/ref/math-cases-linebreaks.png b/tests/ref/math-cases-linebreaks.png index eb4971c46fb2d2a36a8b95324d3d1e08b7d99319..65b4e402575de10ef2513bb25a1d21443f3235ca 100644 GIT binary patch delta 479 zcmV<50U-YR1MCBk7k@Yi000006IWwm0005ANklj3#$+rU#$G;b@ow$+iiEDsTwfrU+i|&bQfPXCJn=_yV$c3vg{+E59 zA&f`8S7>Un>Uj`3qZP*gYV{7vdKO1Zi{DHC15u5LwCw_8dA-~Zx+zwQ4|U+~}Kuj{)#d;k9rxk@vO zU-;|<)2D0yCx0CItMUIt&HscKAC311fko>l(=Mq0cHnuJ zIM?w1#l`>M185n?H+8|(${BC||KG83-Ly|P)~xvdf8D;#v`$?AW$)pV`4}-UmBgFI zH8izYVkHiV@BfBrB_XI0gB`E zP5&QVq_f3FdqL#peLLyxO4Tp)o(#}X#O;7R12y>acyzdF)Z$T#M=c&$ivjQ3 V%;jb2;!OYm002ovPDHLkV1n{o0g(Uz delta 493 zcmVVP4Bt@3Ph~{FOY>~-6-Ym~#Fk@(%=gn7d8s##_ z|N9-h|Ka!jzuw_<_^dgPGQ4DaHy6D zPTURhgFj~rC+X$d*Zcb|6!U@Jf`%y^^#b7U_EF#qbX=t@;hh41RPS2=ydL}DDms+_ z!Qf*H+cWR{nSXlP#p9skEE^kT_Bu?A%yIA*vV%==kNz}Qp>q`(??MII3`luOhtmxV zHZX&+S@xjHmcBS8ICMZZCNQe*yTvW{Ia zo?K$2M~*ie0O?xdfm<&i8Ue;I67~?JUDnVX5>L}kre<*X_7pVUfpwTU6oFEhiU{JM6vkUXP!URf!8x6Ff_6g` zbvV!h9hAa&IY3*W?K4l>oV2AYB%IPL>*wzD=E>>#<(re7MSq0JqDVLrj)WuOg%0aK z8wpGJ>^qYLF@XSBjl&;mgkhbmVQoi#CzYq&*IR z8)`cjNd7B!8jq2~WZf0f10Kk3&yE|kL zA2>7`y`v2zx__$6h#nC00ort+w>J$Tcc=_?r6Logk@8pgk?w0g`-xo`cyRTjdJZ zyaKS{7R$OpGEGrE_=nTV*;pg*sqV1zw18w^`e<}a8(`O@mFynJ6CUX2y({wGG32NN zZT0oZeSh`WU7oOby_KNhaToMi@Ff_794>vuq!1ts>n1=7n^ahWgr_l7Cz$KO~kGacLmP@+&Ig5}2jRnVcu=9xpME02WmU!4}!B6}ykZ&@} zHNYn5a<1Yx>`q`UhqVyx0CID@?if{qjNybkKn!fjk1aQFOa-LqmXAOT!1OfU*60zr z1b_VKPZP+E8Uw;feSom_&Srr6c03Hws(-W{`YM1hrExc6b1 zfdoT<@ShtS5f!>T*ZuHoj%EZju74enV(sLUcyXes$48}7r5XmtH=a-m$1Oo7)Al#M z1R=tmFkm=A!sOTirC3)Ns=UBs4uX zi5MpN#v3w(zk34U>=4hi1>R~aqPReMKhGa?8%?Cu$r&)95wNp1+LwdN>X(0Kk2`z83 z-O-z$RZK%D!^R_7_mMa%DtmuFq&{ubowre2bw4EG=I6pRAk!;a!xQHAYEyW^^z0mo jd<=<%BjHH+{}%oSB0>?K?nN@?00000NkvXXu0mjf|ENs2 delta 1274 zcmVrYc>9Kdn^1CwRhJB?Y) zF5a>&ZrPTxON^VuOZ3Imd5g=y&27eLM1iP7MMRt(193zc-UUIFyL1d?C^8r#av3Tx zF4cmiEiKTN_O$)ZbCUI-bR7w&HjDk_MaCW&kcmQl}@0oH~Im24}LD++si+O zYyg#)x+fIQdmo_v%wmu=WK(#qL;5;Vd-nbG&&wVAb;t(*S8*!+-VTm0c^D{1;V}^ zYzHLQ)S@9J7D&`&{!40Ii62(xuIIqFjvYG+@XGI_hQNjY@>6&ueOR(|;>SIzx~ZM!J$RY(UfX`0u05Qdlzbu1?ue+VZC4uzZv#0Ta}!@-dqxEm z>3Q7n1s~xgb62OAu#FJz%Hi{OxaupMeEc|f%Xm^vEn}Tt0kG%QxzlZU#D3hfF02<= zbN18Ha!|I_$n!Y$Fs?MR6DE0=*x@I(6RY@TMmHMnd^cXemCLEv)iO zIAJP4y7l~iCmvJO>Yaq5ervn=y>oNwt$qoo&j!fK+_>s49&-lJ5=t?-KQ6DP*N>y7 zYwfaDJg)7|FRGti7+!MWR{PeXGyV#DGXmgtxdAJF3D?XB&;k)=-40Bc#t-iYCX8mY kDfkyA7!HPm;b$%UH_GKbHUIzs07*qoM6N<$g1Z$_L;wH) diff --git a/tests/ref/math-mat-align-explicit-alternating.png b/tests/ref/math-mat-align-explicit-alternating.png index 1ebcc7b6847d96c69e3e5787510299955d3c7003..52a51378be2516fb650de8c05e6d4e71b8ccac96 100644 GIT binary patch delta 918 zcmV;H18Mw=2%iU#7k@Yi000006IWwm000AKNkl^q*=mxlCou4 zip~<-_UZQp!9j=dQ+|*ad)>S*Kb&(ozl+~E{3qiQ!-?Uf>;_yG91_z zg(#VA{KMhRAqewZ5#=5>TOm;SHQwRKsxbcb#aZ|f#NqIQJd!R)sqN*LIuNVQqX&6= zJMSAycu9nF*r|pLM|H;*>K7%JN5JeD)K*h1b%C9TrGVN`)z`l~Kt1i|98P-y83C0o zl{#?FIxhs~W`F*E8};=ma~fh57w&RXU!FFkQ=8WE4Tq;jA!AK*d#a!H##tnJS-a*S zyYwM|EeObl}kKV1qrew!z|k+nnbDm8(*Dpj5N&RS!y zINyX}(aTb$x(7hjO|QBc%*uMM;UWbDJ9vd_I3ojsU4J;nHLTD>Fw<79;T3KO=2^it zoLm9HDw4T|C#WEpYQh+JV*cSeN&AQT$(|nou{vBJwNh>b@wDC2c8p|rk_NF3gbYq< z`TgJ~YVgF+S-pEOw-WJOf8MeWL^FwNczh;em1z$fsjV5dRBHdpp|jeYM`v{$4JQrh zAerMw@qeoOW+2bt1F-tl`xL4v3v(pa?{jg!xDIj54>oM|js6=KOTMg5*7~wL`Aw`} zjjr2PFlMPX>nGyyIqzv&!9P4j2f=hxxQ3H#5X_b|w*TX_5(sAAz%_hO2f@^bxQ2B~ z2(~kiYq)v?1WWO94fjq4F*z+DcRg7H)DI=Ot$!d!hYfO~2D=kP@@OK|7RSYQkmeEj*TXrCqc4DNi|f7xj8l4?)5Y58 zs48k^?XtS6%OK;c)#WV*Qz|{whFG86sx4?^QGs{3XFCMi{DXTKm1Phn=Pvk%VXlE7 scl6*MCY=zZGjZLN7)}f)hDG}ezuAM7E-%;Wc1a95W(nsUK|L$s|}skSC}!)2i!}m zFekIQy_!xO3VTgCH$Q6(5$<{z%WEMC&FcSdIIA{IR zGZY+7B)ve+xj_>8$2lITIY%X~s6dRXY9EzYRm)o3P(&qOX=W|Hv64!hZDB38WK)S3 zRR!m|i^n@Vyjzof5hBIAN@vOYIYT`oBiJ`9s+Nu*G0G2@I(NYk{aJSn5eB zK5y4vNq z7Avo^7V8SB#2V)JxN;|zxMnYF@s?9m;=6?eK6{vOs?9@)9FHZwy%#~U^|~!e7`FQf z`3?sm`ujGOIPxQGKNdyu$#7#((+4z~?o|PUo5E{kz1)A}1thGEZ+-_nbKiIgh<=Vx ziGR^DI8AKRFAu@!xoKAeaI97OR`YiKlDM3B&Zhr`gzlzN3vj6A;zpo13(<*B*Wryb zUr_cY5E7OQ8tZ-V52%eQfE|UxDwmx2zDmsUVn%-roo;=PLy5&-3}@8VP>Lh)+O_X+ zeiF?h5IXT+@Q6N@;^#0PErF#rGn07*qoM6N<$g0=VWJ^%m! diff --git a/tests/ref/math-mat-align-explicit-left.png b/tests/ref/math-mat-align-explicit-left.png index cb9819248275a76b4ae40dff2472c993c64ee49d..09c5cb3d40a77a2dff1e7180a98355f326a8c716 100644 GIT binary patch delta 893 zcmV-@1A_eB2Zslc7k@Yi000006IWwm0009{NklPaYw zE|eh^(FM9-J=hg{u!|H5QV-dpEGlMV>NcChT*GX0Zd06kDle%`QnM66(oBt*Q%C9) zFO^R4()R8AKsd)Za1M43^{~&w@AG^3!-t*2`9Ew0{t+ue;eSv#6b^-D4NqN%jNF=* z8V*EzAtMv<0g2&9jgXc18foF-Gmx2BucWZ*BRiTHeE`BZ9OWPy7(F!O2wxqs(g-YGJ-&8RKgg5YuAEFpZpksbccLH*OW5aO8{JIh&*??9@x9>Brg zo|ANx+YB0Pa)C#6jD+yoF`7oE>cKFC7<%Hg=F*bQR6L-wHTD+|+X3D-kW*~7g2#__ z;$h!PD8Twb(QxZVD8SAZ(QwHjD8MnNXgE6t3J{Ym8h_5Z00p>^B^r)a~MmpWwZe-!BeOmSa$eIIEZV0V-dNy+V{s(^4(dytK;XjudoDb zy(boegkKR2pAG|I%SbelyKfg9#S&)jF5cC7I=ZX*bR^MTEkxL9iH~}EGjv85eSlqk zN5~wxDSz0(upSG?LrWd_9O&yh;rh#WY++m}qpUyJUS_s~&xpxcVx}dJsZE&#p9V*{ zrgORBxCkgfM4V_?9R&r5Qj3NS%6!KL(Xj0V6yUf`G+e(M3b3_NG~62o1z6oD8eUj4 z2icigwICYCv1Xc8Wht}d{(NmW2$Q3L#BBn6wST3%$w8yN2n17>79e*-LOiU~(Ny`3 z4sxl>cb4o^1PA?(siKLmEt}>C^RraIgDV2GbAKWHL z>rKlk^7k8-?Lk_!`uI}vVYh@Z&U%39EzsoD0EqnJK1|L{#Wp9vZ9?q#eu|>QS$b@! zV0P`@k6ZcgfYEc15^hdnM-kJQKF+$$QIXThdfe=+v;o{EL`%_YLpSRYv$MPyELNsI z!w)33NeUxj2(mI53u)nr1jxx;^n}DPJ|{y)ZgxOw7^9Gp(a_&bp>QZ13d_rHWFl3R TCZHZ^00000NkvXXu0mjf-nOI{ delta 980 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 U)b8MD5dZ)H07*qoM6N<$f^EX9@Bjb+ literal 2523 zcmV<12_*K3P)ZylHX;L)+<x3`=5vXw#RKd20!d{eC6uou{ZfqP)Cfdes*dm!Y&DKwn(cgp%Lq!tkUMXefMwFgzk24c+*?F#K#G z8Y+BN7#>=LhKhy?!-JBMiw!ELn=$589U?i!*r4-{11=i?N%EipK5@uCi;EeMtjN)g zMj-m1L0G%%-7)hlr#{dNo>&~7a)TuafF!*x-5HU+w!)dq2wVa)><=%FVR8`N(#DtD z7Y06!8esV0Xn+cTNmJz)hsJ(Gk8j7I5GCCgr3@dJwzJ{#f02iy9~Op(9Y}P`Syp=HV+8W%E-k{!4{uDGu$QD!2deFT$Cjj9 zfW!dVF=hnnDk|!+VkjvoX`!;BysiK6W65YJ`LPG@%k_-5;b(;5S2NI1%B#ZgUm_9d zjQXYtO0FR-66s3CoeN3Gg^m-3=O0C6o8GvToIS2ci$uB-Gq`c&Ix@Q#2*cam5%~Zn z7)f;nX^}`*Lec-Ga1k=gw+X}P;}CfbrWV>2KD>*v@yJY07lzF*m=PEbQ%dX#AKit1 z3cWgiWf6v1n25k1ER5~6D|~GO87t&}&qo~2n~N>FC0q>GeL<=FhY3iFM7k1NSGBH$ zUamToFjMbgODELig0Z2o5owV~S1K|a=~dx^TKL;_XkZBhRKxp&=}~cPG!zD={|6xUM655NksH?1*NtV{L9PrSWK0PP=e^a!9ZnX- z^w#$xJ6ZuJm}Gvh!u-p2`Z}|HfYaOeJ5<5}2T_EpUs@gn@FqClt>)e<+;4c-QI+tR z@08(+U9(1;ftO)wKDec-hOb0M&X0^NvKg)#B@Wk*Kp9@wg=ZA-7JS@H@XczITKHR? z&P}JQwHf9&_k`iF1<09Rht+op%(;7~g z!dlC&#Bo`KysHN!c~1`TsAnefk6VD`Q2y=F_RXu~=5xsHz6Rwk$;=I-Tkf*!_JwI? zv(Es-k0t@sL`qsyHYL9sLO0{lRVbuM_oXSrSETJC(9bi=!%2?{!-H-DbXH0%)D_Ms z=y|9X%P(r^t5LeIQ5mk0w%cgf)>R@8-yG<#FoCBl7a%DMz^S?P1rq{a`(VxFvd8NH zmcM1&{f--t`52Hi-=yNa=XVIiA3K(cm2!W7{{q5}1?u|`KaquovYrrzhv?9dZpef8 z$$a4!8p?iA82)24LY21xOC{1G(@}$fC8G_w^Y016E2+i2b_7;d zZ7xUVz)WFy;|@eVf(c!U>8<39D4gQ17^|({APirbhR6tTF0(5fw3E`Q$jpioh7H3I zc?Fyc>InA485 z^yu*0pgANVojQxcTb1Tt6 zjjI9m@TzPy@%;+*@YS#BQFUxSG=+qP0F)dKP9)7|Q8?DFt%;vLULG0P_cJER@!>8)|BS~%uQ<=9yF+C@)*(J;C8g0bWI zF7fb1eNetUP9;1!QyDfde`zhi%kW7q>^q2BxWi=HYBE`DhVv$i!={%}hCOQArey(d zz{DC-{W~pc;e%dY6TG}qY=(KUT^NpY0l9bJa|0Kn40*V;;Kc2{r3X#6!Y8ec`VLl{ z za|IwtALtO(EsA;NfMj`M>F?~DS0`*__on+Aluu6K^Ye7G?DMrR+?dSXF9#SNoC$zQ z!jXX1caGC7ebqS>x}^KMl;I9(+X8Ll>*V3g!NTw(i2#+yCCywtH7f~_WLbN~=)a~LA|{vnsh*|`yE=_`8aS#0pT=-H0U;iQlUI&elYy;c8Y7Of$9 zCo)@>3Bw@^5g7$js_hD&-A>6YWEKVq!_~tP`87<#<^JX0aZ^DE+ zyTYNK6waep<=cqEKKrqy={lb_FcDIgAT0`ePGIfsQX>55b$Fjj*zh5?d>=?%XmC0O zo@*kI7UbeLZ%M^kT;xGBy(St}!g$7_k%%3rg}df-qKUTI9jamS+|k5m`KX7n7-;i@FjL zf{HSvA}XN++J$Asg6tq21nH()l$~dGFsJD-!_2nkw#21xrIjlsvx5;t+QA~$%vH8T z?O+C#ZQlrQ_P`78Q@k(U>~rF77c4edDM~*#lP+S)Zw^jnY*k6%` z;Jd{!Edt;u-hW&JaKJ7RUfY6-vleF739D}gN1e?;((4z?ZxPLw&*Vnn-5mD0TY-jj ziSYPdRCJ&6I?KgXqSPPk1$^#HhuetORa$L#0uh^X<b~FJCI-f6VM9tf| zWLMt%zkg?Z-*vgL4ft5*y=X^Jq1?#*1l;!2FJ+^6vUCS`QQkQ19c{T=^+M*eL9ftwNc4~%)#{EQh17n6X!=$thU)kIr0B_D) zI{+y2RHE*cE6*>k$Z2;K07~ugFhF}U+Tnr%0Dtq~X%9e6V`vpX$ijl(sPfzmtQS|; zapnS$Mb1rtf+C4bk8%SRd`)x@rKY(tKhNwm*^zlhuu`-e({e?$1I`KTZ9TVV>on rqn+)@hUq~^J+aSCv2ZLL3+w9-Nq<$6)q=x?00000NkvXXu0mjf6dj#3 delta 967 zcmV;&133Ka2G9qP7k@bj00000*bA`7000A*NklZzc& z2&#z%U8rH%1+z&vODj#XizG=2-4G(EEVT$KEpSUqTTU>iWLE0jNb`mxX*sXkbenTt zwxe^KzyANTr~mlJ`6uU)5Bolx&-c&H;j?r2opazUf&Y{x5r1(+91%yvqKc=kh^e?X z%_S(nd^u(Ho7TDTTsI;MS^NAzgMoyE0+cAtd z8*R=nA4R1mkwLuDNfWEHd$!yq+Qbga)eIdfhucf#6~xZU9OI@(h<3TB}7X-2V}hUmQZPJ`^jZbV_*zRqWhjx}4s%z~Sn1j|VU(%zifz2^a$ozVuGIf_>q`tk1^V{`t@_ zjd(pgy|#HT&|~GM! diff --git a/tests/ref/math-mat-align-implicit.png b/tests/ref/math-mat-align-implicit.png index b184d9140a7eff77e8b69b544ddb31dabbe3f794..cd683315593520675aaf57c37cb30f714926a9e7 100644 GIT binary patch delta 945 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 FV1m!*%4h%p delta 2035 zcmV?)IPfYNzuA8}O z=6>{RZ0^p+xuWa3o4N74ygRK-%#5Uslq@l^RGQ2mgjoot0Xhx3RwJZDESX6DTz`ZR z#ZN)lRarJ+VSxZ)mjzj1cVEpn#@+pXcbDPwd4IFnneX#2{(pdZ%;)$^mc92XkLo5TCzpW& z8wl{#5b)-hc$EMn%=6XlM9qsS{|*yj}J z7hLdUg`|-|Na_QCm%7MsOat82B*jCVR+R&BpFZ*(fR?3H*sx5zy$!Sxklr$%zeI#% ztNGxPmuc|eJpgd!tWYrvjKB1g;1e$bfTdBWi3xuG7ztjx2i_GegQV1tASqG@q1_22 z_M7uEN;Lt#5Kb7G7lLtQn!~`|T(V%i@S!{*of}?MN`goJ>>MX-vkDDghaGo2Nq;aB z55beO@Hsm~4JT$eAnMxOZ(IP*Yyb`ZJ`$cT=V?wtRH;T&1yO~Xye0q(yNED0RD+|^ zb)drb7#EluSZpW5&~E^1u9tM6!?o=brhSb-Jz#=_0*V65FF2Ye-F1dNTW zZlc2@&t3&{!zu?E-lB!Oy0m17JAeE75s1rsrX4_WG8MKw0Z&e!Tc*?JUhN#cWj@|R zgx@*FccK3*nFhP=9pZzBr#ne-<$SMSWoLmguZ#r0n+DN|SNg#Fec(n2r6iN!*$3g3 z{_W+`MfsX_(nWb}iV*9C&iw=jAFTsE07GRuN-WHwC&ci~M0 zcxb{9IO`+*{20G&fmb!}t$&9&?aI#~?$f!g5YpWN3jDVU-jR9yVG{i6hrICKcoKYg z$#`LFuh5Vijw>X=*6HJg3-X2ZZg^Th2}Vq%XjZ=hNok)z(jo%@=oW(p*Dix6eah?7 zS-mkyI;&T2Hv(8yM}!gG364xrpu(-Iz|ry+G7L=)nA@fS9fqa_jDMMOl|7dNE3g79 z@C|}(AZFu(PwoJ*%u_tDeksUps$+*8k#>k{de08uoceV5X|Oa0fIZ5|4DZ_uN#|yE z!?)h52jQnHhgATue-A6{`kw>R=J9!g0AuK%E=Ks^8pr`7;V>f{bIN`8kJUmt zKKM*DBYgL;JSQIbJ%1ZqcOQJpzJF^Ae6sHTgL())88-Y~1EB}&nPAPMkf}wvOz`_l zAXD+%nc&wFAyZpkWrCx3LZ)^`v%$L{Q@hyUh&TwDGV2kz;ffUasZYBPp5h{SPF$bx zB?4!?7y&=Eo>YlW%mZVy8)lgu1UNEPE;u!k3BGgT7;u;R$A7xU1uwjl364sa>%{3% zJn#6#{1w#*n05CwzhipSzciwE92DQj55+;I;vNc)6F-*$naWTx!E2KsQ|l9$;Iudh z8FthnaQ33N;HRE~J@BN;V1o0Xf>7g|Um|d}<_P$yqgVw`GY>Ms#_4hmkb4>!Z<)RF zzys632FD&{hHQ0ep^sf6xbS60_^ajeoH$O)2xF!Vz=@~h$DGwO{Lbpc*&2BMVJ9n` zmkH3@@dY!C7%RwG7K0sbUJGiDs8$Xbwdo+1dLBG5Tp;FB7HkTvzzY0R^aE_NnIYHk R4VnM|002ovPDHLkV1fsk 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 KMNUMnLSTZSpzF8* diff --git a/tests/ref/math-mat-augment-set.png b/tests/ref/math-mat-augment-set.png index c5881b13976e15a853483b70e227573be1a4f367..1a66761591388a4bcd58dafec8824598b6a0000c 100644 GIT binary patch delta 1711 zcmV;g22lBu4zdl97k_C80{{R3#xNJ&0006#P)t-s|Ns9k%s`2WiL9)w>+9?N{r&s< z`@OxroSdA?%ggxq_^Yd{@bK{W_xJ7X?WLuqt*x!#;NYjHr@XwpOiWDW<>gjZR`K!i z+}zwlp`n-MppA`T4)zw>DTRS^D^z`(vudk}As-&c(prD|!v9Y$cwm3LA?(XijwY6DU zS?K8KXMbmBSXfx;>FN6V`ebBePEJlhKtMh|KHA#aLPA39?Ci?Q%1=*EP*6}eH#he7 z_B=d1Qc_YnIyyZ)J!oiXhK7dk@9#A=H90vsHa0f?{{H;@{D>*M#sB~WFiAu~RCwC$ z*=J8w2^5F%$B~&~7*UX-QUrE!7nNdRbrpNx-hX@Vz4x`3wZO{iBCLpt$bd@kAk55X z+uV|zT!t}olarX3`-?CBId7gg6Rzfh$K&xlxZ00m*|m%0RxUtUp#B(^dGknaNntm- zDh8nZ)e<}W@S^mG01{VaHL8l=2AJ=&!$ZWzFeI+dUNpV-7DzZY4&xXSS7j%PN^1ZK zr+>}B^j%=C)su#vl1+|qAPP}{xmMLP=ot%P2>)t@8I1iqh$(TscmOr=k9PxvDvk!N z@X~P8B}|Dc^K(C1mVSgV!uRyWmuPwSLpR}xAX>K7xe3>|p~Y9|CVbk5mZ?cM;mIkq z965~PkwxhPR=B+4S&*A^9X_J{H@*!+Mt|6Tl*mn%{EZdE{&ARzNmy>Ol*5r0c^XSt zZqC&bLCb}U0P!e9k+9sH>*59N?|96gF=mIuRUkLV(*KUh&U&=h3WusU29cXA<#2P4 zo1f#JX0$X!+=L?yXqoB+R?JJm3ZQceEyp|Dgijnt%Z)xaANBQL&~oZ448g_etACBK zy-ejR9|Pz5`Wv*2yypkVh1+J>ULcPWS%E#fQB$)4h|k#1YI`LK_x7Nty4LZmwpNmG z?OF6(8Up;&FpLqlR+4btDHP@A0r`QEd?RcxOY0Pl)qVg?Ih%mIb6pnY?M1NUjRVv|)1E}jF1zq0DB9F)8xt~gyZTog+TZ6i% z$`DEao!U@=vT!=|&)?|hY!oG?*Asuu)X9){#`Wrt~vfk zIRO)9*j{qu>J*AHTL1~SUx(-tBWy3Z;g5EsC;XfvJTeUb#Er1MKvu&(^nVm?afIoC zI~ZYmfvlJJqG$g?fY9msAf{_q+b)}7dx5O9Cs3334mf3GVutMn$lCuBTHgPFM0PmQ z@M^T|d(%z0ZarFdta20HyF>AB5xv!bvMEH(8?{XgSx6;e34L z7N%Er`mC_rWHq1DewR546Mx|NotxZbDSp(Axd01iAiBUAmYb~HM&(~$I`F=AbJeQM%TYp5)!A>9}3e$dM%^f~} z5Jlr7Kp#8~V6VBu@tPQl(wl&?@S3%Ny{vGkU|A58y>@OxQ}>Ag5Q|~3mKjbED2k)% z&~K?dK>j?0vZj-%gece5qArKowr*v%wcfiTkH_->`yczQPidWJ{mTFV002ovPDHLk FV1jcOdR+hj delta 1808 zcmV+r2k-c@4U!I!7k_IA0{{R3i}<$H00075P)t-s|NsB~{{H>_{rvp=`}_O)`uh3# z`S|$w_xJbq_V)Gl_4M@g^Yioa^78TV@$m5Q@9*#K?(XgF?d+9?4>gws~>FDU_ z=jZ3<=H}()<>ch#(^($dk<(a+D%&d$!w&CSfr%*)Hm%F4>g$;rsb$j8UW#>U2% z<)D`3pT)(+#KgqI!^6VD!ok78zrVk|y}i7=yt})*y1Kf#xw*KwxVN{rwzjslwY9Ue zv$C?Xv9YnQuYa$uuCA@Et*or9tE;Q3s;a4}sbbh~sHmu>rlzH(rK6*xp`oFmprD_h zpPrtcot>SWoSd4PnwgoIn3$NCmzS27mXwr~l9G~_eSCa;dwY9&dU|#NEijSF z1TSe?wrLugCSnCGl!O_gQcA@oyU}J|l3cTLkzxh9(8cclTlJmu&AfAjZFXng8E2gH z{PfKG!#=b7&Axl)TYP+cd~RLxk6M{Ei^RO1i!$kFW?E}#ATc>TL3C*;2Z|TkaIk7k z0DoYtEo(-VIUfTR$HF$;QZ(lk3}dZp9-6#$55SW)%w_l*!&qC^g(5RzfP}N-Fq8?5 zXZ?lfQG37>&WXcF1~8sA?njTaWu7p@FaR)~J#r2;0)J-#%x?W~z8ijV!O}*6@hszy zG_;ue7lvu2rMJ0Z7B0s!)_$Ik7OTHUFn{6ykyTfSF)R{`S zvk5JFV;C~lZ3x=%-Ah1bUW@f=zvI?|;#b{pMOzqTCMNG#GaQ4l7waEwb3YA9FAZZf&nBfGp|JsYoBr5(g*9^@Z9!K79lf_>3^Rw zlrF{%=M@(hA~P}NaBqJq3#4E9dt9>8rJ-SEVoH=h>Mo$NAqgshF1_&H2vAb#^53f` zrUW>>7cDk^g&}L>=1Jy^t-0J+4ql5tiWZB0#BemX=#U9pbEs$ugV%mrh!&ZDrg?U? zHIP*zmVP}8HDZhH1MKi*6Lu{@cz<3$dNkMBNA{74ChS^*aNW!3vEVa+I9$PmT}u$| zTZAH?7X!OjRPQ%oYYz9meYVVF`RCB2`T)S}QcT#ILsnki6py{O3RT`MOm0^fM$l!( ziKN1T$aIuh)@Y@&lEfS;N1Y;)pvb?a^6~NUxtY|G1ka&P`znNlKah)CP=6-02bhqJ zB|p~Z7xa?RT+Tw(*$&63)&?LOU^F(ah@eGx6vGG%;(raxOf1^1ea0QR zh40|9>!1^snV9|=lesRGgF{(OO_d4DOiVM}aXys=a=rsCj+{y*eCmkeM}3o4<_jOB z68`9UwAd2G;jTMC%nb*p;UE@!94*Sf!%+Lc+MS3w;fjth4r0IDj}}vZ$M7Y5ftdF{ zH?iweQR9a@fk|-~3V$Ltet706dc3h6#_Z(;;irzF$J#C!vzHTu-(QO&-(~{s1=EiM z*4%KQwk?Q*wLokFitO19OssxlGGNUOFA0Sn#=+XNHE0q%ou1qRNsk6lW%*0Vg%2)A ynca_ZBipudBhS>KPBlre#=oTU@$tEZ`4`!{o0>hWUX}m=00{s|MNUMnLSTZElAMwN diff --git a/tests/ref/math-mat-augment.png b/tests/ref/math-mat-augment.png index 0e2a42a241c8195744b2442e32bbaaffa1920ffe..306c4b1995f82e367be5442de9253dd9ae69e78e 100644 GIT binary patch literal 3563 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 B_7MO8 diff --git a/tests/ref/math-mat-baseline.png b/tests/ref/math-mat-baseline.png index d2f26621306cf21c89c4b80a0892857274b9da7a..01928f724d2784e7e1f60f56d014fb76371596d3 100644 GIT binary patch delta 806 zcmV+>1KIqt2CxQ@7k@qo00000#SVaf0008_Nklv8auqR4}@0$s&ViDZ+Jh%-!GRuWM%NT@n19-Xk4q&xgEPS;9J=@bR4025+J}UCn zIImFJy=o<%@*#mJlA>3p7jeM8c_Yghw%7} zST3BoxWnykw`IiI38MB;QzXKyzYIXkXRUMIO4V z^Xh#7%*_%BUyngk9@B-&=B(<2CQDk<*+u{wb~yps>_ihbo>=i4C0Fksg`wt!h-|nHg_^UAZetpP)Kb zb?QJNfPZK>WfH2w#{js}(e$<|O`vhmPQOVhl8ft3c6WDoe7iTLh7ToLf_~=B4H-D2$1uckbaCuCV#x{IeOo+8Gt3{QilW8>ja^2S~7Y` z3Sd-}NU13c)#(hOaN}b1b})doMbu1hMm4`tC`>{PI%~cIM*ACiwy_HU#~P%<Dtwi=ZyLD!Pp* z=pv#>Dq4l2E{deps34<_ii{Ss!oU_=h$4j2v7Eu0Qo_ozaw;{8)KpwT%TO^bmvTYP z(e#^{Cpdw8>Zsqqs}j!d`oP2goSQR>VKREuF!6@HVQ+ZEgnzX_&EUZxhru$2?8?WW zia-o87LKvfzruS^XbUa^;;eFE`w9lo;tQ|aA4LGHZ4nD!f64%If&s_zCV>0bq{7-P zCpztyENAKAYVh40%~YQb&@tB`6i(fW-kvONIX(C_lBf$$RKHXOMn| z9##=|8bFCUA%7H}lZ%dP69*Dr(A_~cQ~@YOb7jJ=v32O>BxIZ0=`KIEm@dw-PLXic zI6O4zyGS^5Iv$#tArgMP1P?8JEE10Q#Y4XFBH`!-7*lu>lm*d4y!gCsv0QlH%CNAo z&_lDQJLt=~K?zq+Ed#)mh@|*Ld@GdONBc!BQ9HwU_1JSh2(c(B$0h1Yr9Q zP<)!2*Fkk$@!*8ZFQVkW-k;nF4}Z<(5sDw?D~VV_--?E<3sLOH)K2nRORHOegogA9J zj6EXzbmNeP^};^+GX!CCukia9k^P6wgS0s16$TdnzaIhHJUrxM6A2ehp@*~zjs8sj zED~-RPj@x9!oSgvjxntwVJ0=Ar(M8}&g23>DSw-YOnB2v^vosn_Iqc2RFVxPWus6y zG6cOq+&C3U{^qABp;1EN%4O*JV#SI5wcy^U5E07r7f!UX(8=gWgM`d00aT)5^CdJg*L+EF7ICiMmeg(M>x=A(uo mS|KJz!_vHAZ`d0iG2y>=uN^j*@}lej0000r? zk^Cr1TpUC+Bsqv2{5*(@+CfZTMaz%!v&blgG7d^3cCh)eS{v@sgDwP8OMyJSNCC^$n zo5|p^J=S_sN`D6D7+C9VHW{4hVy*5>GFV|{tyTpY%$ZoLi6etEJgn7|k$x~|WUWSy z3|81!t4%=$XSrCbGm8w)yeu*zU2CzRu2>a>)s5eogmDa~{;WqgQVf31T$l=_Uw0&#lb-;BWDB<_^ zUxujil@x|W delta 483 zcmV<90UZ901n>ip7k@Pf00000ugP${0005ENklkhtA}BDSI(WNFGPnp&K8<^TWhCk#-+VCR3gL;wHZ zPoky8kK@3U39{Jk<^PLk0_ngJHlcF_-Xe7NNz2mp?}3HKd@QcbOA*EQKX&4 z7rp;uv3TXJ|2sgm(*>GY++7Ox+H8fX?~vkHMoV=kh*8r^GmB#<;gek!MKg=-*W;7j zV@orOmCoRky{t$xi#ea*lYPobGmF`u)!|3vWJt z%tbScmGRa;r+-vvX0gLAFtuaV0ul}3 z(qC0+*x0LE10-77{%1BJ#7=CQZ+H?&Z>pf7#V>t;#9aA#>)nt`lHaW+yMa{1OIr3M za>4QV{2!*w>3o`YVLm3{koXt-p0>Tk-P5s)SL~&A|9I;$to`FnqeBs+7LQsyYVq*5 Z7ywe-VaC$1f7Ac~002ovPDHLkV1ig@`9%N# diff --git a/tests/ref/math-mat-gaps.png b/tests/ref/math-mat-gaps.png index 405358776c3c42f9e20a7ac48ae152506cf3dfa1..95cd6cf113bbbedcb2fda0ac1af1bd39509cc87e 100644 GIT binary patch delta 1305 zcmV+!1?Kvl3ZDv)7k_IA00000nN1lx000E$Nkl1Kqlgn}bE5TV+*)YqC|&Q%k_VSfTVhfsVx5s7g5BlY2$nHYp` zHZwIB2(DHhjyz-(hLfKFWGzr0_U|wZ*Thl>xCW^XmrO!8(v8UV=_%Xy08BWeI2^MO zz_s-|TQdU1(SOm=)d0(56^CcUBW2eIio@eGkn-zT#o@ulNI5%5akx({QtJ9B4*On2 z%0*wrVV_c@l=>(R_pV1ueee4v>{Ei264g`tRv@LqS8>>{7AZA;io-+BAmy~b;&4z7 z8u$5b5UWCy{)*~HA_bs!Wy#np2$fAnW1XcqykQZ*`F}XZmm=b}SOUQ+yNYsaKgmO3CGg?dtlbZm>mK?^6Fj)l{%Fp!v z2zbNZuzxq~4c~*|Zj8B23U|$K!O+%Y=7htG!PtsYGs0PQ);L{c{IE@tZvguRjcDS6v2=Y=7(We;}+0(0yw)40mpD{#6dR`niw~ z+dl>_&OM1o^-`=$p(+f}S+W6Yz8RN{NL1i({|_aZc_+vK z2!BsQQy74pU2jN->xZM+KV^xX+Zsj=AGRJ~=R7n8fymiykq+;D9Rue^pgH&?ayE{U z4#&*FK%1XwC;oIbM*JGh3I}h)$iOpZg@ckX;-3eQ=Z~DEAn9=52#oZy0c1XgoY<$N z!_UTJr2j#)rncYEJ*T|hdLvx`oJ-R@4$X_7YQvh7U_4t4it%XM_Y#_I%P?zd#+^fB3v0zAwObD}{Wmm4vQsBdmJeGu0<^O^lG{$Cmw%16 z1MUDPn!1OP6CEZWZW{+2wUBDzw!crE8dL-vS%uEss{`Q5&xv~wE8l`9e2sjVg@xd- z?S1EW@JN3l5qPT^bOS2^qLN(?btBUK;W{)^l8_I#%mi@qUq{yfx{&<Q`1e3*<>gsx3<0lLJ+Xv#x#5PzvWen1a90_aJYgj@u=X4i2`?`i3Y91zpv1Mbd?cEkr!fb89(2jcV@2xlN4SU1)PxxO4781yvCZZiu P00000NkvXXu0mjfPUvZF delta 1303 zcmV+y1?c*p3Y`j&7k_OC00000PZatl000E!NklcPa0;jeqW?GhtmxUHynDfG;a*KJ19c7$ zXhcfm0G-2@a-@`7`fSH$*gRuD!vXh@QXimmctjaeE(Pfvo^S?5{?UVoxxPn{IZ@~E z{1kxSqM1;FP^uM0!a|+HJ68g@nq6Z%5V=*LhFPAVbAPzr}&7Z|EFm z#A8(9c?cY-xihgHaEwIn@Y)O@ZWGb#5h%?*5ONX7UZs1uc?NLRmSt^3#JMrl4$N-W zJE8K6);;0#OeE)z$><@TtPi`+(~u7k~2M%1?pglS_TO@ukxX;cgV5vgV_> z9p*&j+}v$3e*#P@K|UN&3$UaVd?$Ny4zTDts0OPq^(krTh@7?A4xspaw|ux}9{&i_ z$&YcsrA4TEp7{$Y->#MquS^kyX`0;w@MbHD+UJpzxIjJ}@<0%FZ0xQ9h{-@v5{#U~ zQ-7tyjiXVx7v_VP`{ONJ;{j5mQ526tPP$b(d~6DeQ)#J{cs;b)Y-Ipduc9anL5^*T zbT~c|4IKf7?Re=rv;?#m6%J2AOJJE%VQV^CMim69&OzH609cUSx z+h?PG>cYViXKY9~bSGMdzLYgz;iF?{3sCqSD)+P907uiWh9dUECKNGo z@?m02fQqyf4?fNuKLNbo3aY?50Q>nzLp_MxUZaM2?-26gwgn*eOB|q@a}vePFY*vd zJ*9>jQ;B?-tTcok%yt2){a>QGG=CM5>~=LwS1|sB*=8rnZV_b}lO zdbRG10paW4(3`u_7!YPpKH54yYBMCvzBV)+Y&3rxn_)9-hM%7B-&bRxJ`iZnT1o%_ N002ovPDHLkV1kEeWrhF% diff --git a/tests/ref/math-mat-linebreaks.png b/tests/ref/math-mat-linebreaks.png index 52ff0a8bbc61bba3604655887b7aeb893fc9115d..6666749dabf1fb1a75f67347472ee189f775a5eb 100644 GIT binary patch delta 636 zcmV-?0)zdF1&9TZ7k@Yi000006IWwm0006{Nkl? zsKBDYB3961&CpaTy2!Paixvn$vlz`*I53(naujW`)#wCE(FNJeUnvw43h7vwrO6`5 zwSg_SJ@TISz~Q|=`+9Spi|@1N>ccq~gRKIalnNczVI9_C5`TF9E^qe|Eu60V2RV7m zDk*&S7nFRnQ^Ngj^|N!75H{ujyRX4`OlDhVL9mkLf*$`z9Q&%m0#(%f#61&+EzLtlhlnMj$QxNsCupZIGC zJTq6JK9Y3izjeDjmdz<@b*TzPGwgPfaP!%fQ!KBd33yk0e`V;&Tash_Lt{Xx8Tl8^;Neo zVBAyMKe^I^rex;`KK(|tuRQQ)NrDPbyhbVfDiFhPWDKj(#2DbtY7AGmio3p+-3Ne} zk`Efg$2{V8G_yND4lzQSci3ZGvN*K WN34eLR6y+j0000Fp_9-l zGDtAGC{c8XR6@&wB3QQ48A}g}S`?Zq2AQdpA-F(8@h}obL~bT61Y6V&wl$2HZaQ@> zHn6R^Z-?Juy|=>c@!;=!d7j&c_c^=;Y*9N3wkRFeVIAIPuz&x)Haiz(;Ydq^wlv=T zDGE3B@6wi?&Fux7j(={VBfGavv&sGgK9vAi`3|s9E(ben0mdGU8UQNh#9*Txh!^wP z0$^(vgZC!@w~=P|3J@rkf&+yB@f+TwwkW{fA42eW82~YVJP1IkQwV-|5v8*>A=uoC zlBq)ou6c~oZGV#xT-}e-o$COEQ48lBE~*D0czO*0s>=Z0`T=033N2?KUoSrP_lr|K zXvNNemNifCQq9Lq7Ph|u=I1lKw0s8Ud|+A34?KE#*_4HwBGCKdxrxSZ_Y=(1bwMV^ zat!aQ0ie#8F49hZ0H}pfDZ_&t&v-74mjm2QA)7pnmFYhv*5Er3HQegV`v#Nc$v24I<; z90F1&5QDMW0m4auz7at*X7QFz>CPW%z<5%=m~ltqx>imX#p_tLd-~>#z<3 Ze*pLZl?g+Rl0X0e002ovPDHLkV1l@1Bn|)o diff --git a/tests/ref/math-mat-sparse.png b/tests/ref/math-mat-sparse.png index e9f0d948c363f982089c2fc19e5e2c57f3ae5ef1..c255fe3e59f72eeb47e723c5008be892d96f64da 100644 GIT binary patch delta 947 zcmV;k15Et#2D}H57k@kv;Lm;zwPk+tI!dW;AXW_R6-`|E( zbpbs%v@(iP$v=Zy@aj>FdPuMPR0Q)9_kdnc5}vQofm=2MFmiAKX*G9V`7(-Gy|q?o zb%oXe^)3y#a1x_rI*hZ0rJb18Z-s&xtj}OPPaVGKzzl}CC6XEb49c$o<(HM=-%n+Dqe`ffrI3b6{|Oky{Ik;`cm7|i0%C0Fqcj!x z2cxM|dBYq%7yS)@xdV#uiSNPaMTh4iAiCpryOUxJ#OY(^Ax2L-JY8_>^f&+|#}wgR zWoY%sPDQx#KeXCZp$PxH8?73xig4*(wAx#$2+yw+Cx36#7=SdTVt&AXJ`OJiRAWc(vQH-C00M$!$A9d?udr?1NHvhiIid0_(Mv5a88*x1=oT|oEsIEx=QO6LO&41`3)6ewZF(ZW_k|&G;qQVhvc;R}q zTE9>cE^a`phT;$H%Cv7stF3lLc;h;Zx`PmI^MEv^UU&e{=?DPMYDM@!82~LM?ckg_ zdyLnnTyKLF4tt|c09G7QguUNkRMpP#YG(40rsXKPhrhf;MR|*!h3cI=}{>Y%K&-G@Zu>}hJc#g%J9pt z0eXt7hWYo|{&ql~_S<}ux@*H|bl-w2xSK*wfMpZPFdI66Cqsi{aP0wrJR>j;5sW4V zhlU~OO#m1EM;#9TigLwc8ZgaAP^|tWIxH~tmr$y03wkhql=5f)lgPqZI19hEUIFYz VPg@A2?~4Ec002ovPDHLkV1ide%=Z8Q delta 872 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 delta 1812 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 delta 1224 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 diff --git a/tests/ref/math-vec-align.png b/tests/ref/math-vec-align.png index 680d0936d936349a26c04c5dfe416595c6f9be56..07d58df722224d48cc19c4a5ef0fbed5e6d819c2 100644 GIT binary patch delta 1118 zcmV-k1fl!N2<8Zo7k@Yi000006IWwm000CnNklQDi|$ zVy}M+!-`OdNV0-n_ApZ_HMQk>kdGEUEF*er>tO{!!%4KttZ6Te9uQ&=3TazSvj`up zmBV~A-FDmW+}*o#<}W=$P=DAq zSaT6$yHzgQx_16z;^Rifo~Y*se_P(o?s#yHcF86-ZC29m>}57}DO#3h=7q)ve z`-5*!ahY$sYJ0gy4VQd-jN7SgO}0m^sl4EO2WS^(^N4+^?Co)MTBrkEX_I#>qsM1X zB9m?41TSgt1Ai7WYbAfEsxP?wpyXd>3w^+C^ZCHfQb2oqpt&XR+cnaaH67GRQl|uV z&2V_*6?GnQleAX?3ma;>&z}i!B_b){k8V~CvW$sNhU4D}TtVu-sn#06F zj1bTkCQihJ7igMM!~xF9L;JQ3aYLz|j)P}bkutOuP4{MVfRhUGLn~~cx;Q+I(}CI) zcv_Ui0e?=^;GILPpxQ-vx=0IZ4Z%}QA_q7;ft1RWYQJBaK$W5Lj^}%*Qw&vtn%Kh` zPpia@gz!O(INvbPh_4?RXvF3uX~h3Ccv>0`4z35y4adRZxuEsII5=$@2Y7ru4h}Mc zrjNwIBh$&E2I1iN@q?J^eYXdiYW?AXrrNk}@PBK3Kkwk{{=qNu)z$rjEys4~`N5Hy z0@{KbK-ClRbfOxxAsA0HBRRl}3;yurY6d+MgQqcPK%4*Yy$@DbY;y-+Hm z@k7t90PRt9py_@U2l%K8?OQj7l(!K$cx)Xhp&!vS{Sl%n<(1JFMAh~MLYUCmB}xApGS#yeJT0?nf#jPWiPZF z1LtbJ*Pa0bKU(XZpDYaQ9Ovc%uX+mF(gdwb21mAt>18!&8%f(_u=EBksGE&>AIh%S>--oda4+(tlbR9M$TLqhX_wq<7`_>bz1euxqxHwy{>w zvwF#=zM|8UWCbj#lD~e4PTwVhx?`Pu|D6hN&{uU%(HB8*#LBB=OsCSv`2{teve6=+ z^*m}hEhl!t`IesN{L|L6rq3B_%6;2A-}|TLExzrFV*k{+)WQ4zxb~?3Ew+U+9apI? zHtmS-q}b%707*qoM6N<$f-FQWvj6}9 delta 1090 zcmV-I1iky_2+9bM7k@bj00000*bA`7000CLNkl+L8T)LAxuVK z&8){V`#qTM%I5$dJdB_+k9WyZwFm|;sl+nD0H#N?IlxJI2rPw3?&?@LjaB1KRlsR} z5(hZ30DplMY?Qld6`Zb8$=w)?4X}M2;BY0;x9vXQCb{Q?dPkZ*d!Ivd@Q4rC^n$1| zoPWlT5;Y^txccDP5oWBtltjO4|J1V(p90wdjj0iG2PgM+kqw?bfWh!U@D5Dbo= z#Q|QO3xgG%a=#o6gQG9Yy;A{$b64{(^_RFq&42!vx-l!^oByRYpeE=^`d{U zS+DQ*54LpZJNdz3N(9z8a*pT1>0EM-$HA#GY|MVS{$+4RE`mx_49Zdwg}{oekh@)B z0n=ld9N=^8hwPyCoIKk-MIo~6;hccElC!1O}^iyuH9`}8t%fIl>h($07*qo IM6N<$g8pp?od5s; diff --git a/tests/ref/math-vec-gap.png b/tests/ref/math-vec-gap.png index e48b3e9022b8742f1a3c1557e809c8a1ddee127f..ccfb217111766e8c1f7ac89688022582a4b0cfcc 100644 GIT binary patch delta 423 zcmV;Y0a*T|1GEE>7k@Me00000WR;^C0004dNklUnzH-(~#KmFch!>#z<>!x@LRlP|LHS6>RqLDuvs3V+X>!`YY@h&d(U+hKq` z4}j%H5Vl{H;Fv`YerTlx)AA?=2j+gmi+(Y=V zZmUdoObhWtl6wuDCdw{I7jr6qmeiV?nINn|R@qPnTzU^K*S-j8}l^?u-P8N5jqVm%^>11(B8k)r|bh6k}7nQH;Nhgcj zGEn&$Z3AZUaF1gz^f>k!?m^v!mXA}q=w$KYK(wG=La(yg3QXOcTKNnT#|PeTQMJecg2JwEfL~ z1!3Ha{72vZ@%L5;-SBO6C}Py&QHw_{9x)aJ05|AziVz_-XVd@y002ovPDHLkV1ma0 B%pw2) diff --git a/tests/ref/math-vec-linebreaks.png b/tests/ref/math-vec-linebreaks.png index 52ff0a8bbc61bba3604655887b7aeb893fc9115d..6666749dabf1fb1a75f67347472ee189f775a5eb 100644 GIT binary patch delta 636 zcmV-?0)zdF1&9TZ7k@Yi000006IWwm0006{Nkl? zsKBDYB3961&CpaTy2!Paixvn$vlz`*I53(naujW`)#wCE(FNJeUnvw43h7vwrO6`5 zwSg_SJ@TISz~Q|=`+9Spi|@1N>ccq~gRKIalnNczVI9_C5`TF9E^qe|Eu60V2RV7m zDk*&S7nFRnQ^Ngj^|N!75H{ujyRX4`OlDhVL9mkLf*$`z9Q&%m0#(%f#61&+EzLtlhlnMj$QxNsCupZIGC zJTq6JK9Y3izjeDjmdz<@b*TzPGwgPfaP!%fQ!KBd33yk0e`V;&Tash_Lt{Xx8Tl8^;Neo zVBAyMKe^I^rex;`KK(|tuRQQ)NrDPbyhbVfDiFhPWDKj(#2DbtY7AGmio3p+-3Ne} zk`Efg$2{V8G_yND4lzQSci3ZGvN*K WN34eLR6y+j0000Fp_9-l zGDtAGC{c8XR6@&wB3QQ48A}g}S`?Zq2AQdpA-F(8@h}obL~bT61Y6V&wl$2HZaQ@> zHn6R^Z-?Juy|=>c@!;=!d7j&c_c^=;Y*9N3wkRFeVIAIPuz&x)Haiz(;Ydq^wlv=T zDGE3B@6wi?&Fux7j(={VBfGavv&sGgK9vAi`3|s9E(ben0mdGU8UQNh#9*Txh!^wP z0$^(vgZC!@w~=P|3J@rkf&+yB@f+TwwkW{fA42eW82~YVJP1IkQwV-|5v8*>A=uoC zlBq)ou6c~oZGV#xT-}e-o$COEQ48lBE~*D0czO*0s>=Z0`T=033N2?KUoSrP_lr|K zXvNNemNifCQq9Lq7Ph|u=I1lKw0s8Ud|+A34?KE#*_4HwBGCKdxrxSZ_Y=(1bwMV^ zat!aQ0ie#8F49hZ0H}pfDZ_&t&v-74mjm2QA)7pnmFYhv*5Er3HQegV`v#Nc$v24I<; z90F1&5QDMW0m4auz7at*X7QFz>CPW%z<5%=m~ltqx>imX#p_tLd-~>#z<3 Ze*pLZl?g+Rl0X0e002ovPDHLkV1l@1Bn|)o diff --git a/tests/ref/math-vec-wide.png b/tests/ref/math-vec-wide.png index 9dc887a8cd7640ae9f0ba0c7707c6bfca25d0c52..000e3cf2a6f608878d55cfe87a78114e69a49222 100644 GIT binary patch delta 618 zcmV-w0+s#j1oi}w7k@Yi000006IWwm0006#Nkl=?-`3T7r@lqvMXjke}0$Sz{GgsfIt(T}Y}GhJmd(-Ah( z<$k!mV>^AH;hu~8 z{t7f%FABj;X4D4j%x$PW-YEnhu1B}A)k1KI8{LwNV>0kYFZ!(v$iPKI=vUk$0~dZl zzYSg)c%c^{(tmK?G)1OqGTGaYegt6oRmq8M*EoIyfN?tj$t~2L%h$vsjlLhvz|3^I zxC)Q;V|J-9Oq6}i7yO{*ZlVTw(v;3krX{b)^nG_67!CRwg25O-+p*686guY&vu85e z0fYwAjgV=Dqjetu%bMp7mw(b31^n&ly46)DPrP{9s;=5w zM$6uOt(w~%!zd|mugPTct1$Q6RX;_4r=_pa6c3=S$Rz`>7(lNyoOv!F z2UGW!9F3NzBwVbKQid9@n>tlkpKVy07*qoM6N<$ Ef_r}@Z2$lO delta 608 zcmV-m0-yc%1ndNm7k@bj00000*bA`70006rNklygdj{E$oyo{@e?5QG%;J|3DBMfQ zDBKB4Xl8N6I(!ztv!d2HcsNxSdZ-rD%B1>rL+HyTu#3|JU_G zitc&Py7GU~N`Gouym|J5`$<50cQ**kM~h?Et0)%#pB+cFIDT4u;r~K(LB>N+d)5Mp zAMI^b!EJ32C)7cLnqwN}LGAwk<~4L5^IQj0`}h4nj_l)$>HpW;gIS6@DYrQC#zq{8 z>yoaI$0}rtpO?IeolUjG_5CicRQ>VZy?Z|(74T}P+JD{GXq%7OP7x~FmJG9!i?GE< zXk)R`c>)T=uhGV0d-UA$5Vbb?!Tg0b7Dq2a6Np%V!o4FzqZUYOBPxGeJF3O|Eofx% zaSzlc*6vxT7SFAwk;Q-HP$uq5R2?XUW8T3LJ{5j9bV zrM^Jo?pbc3U2oCvJxY20?k#eA*5?E5EWT2STW)7JEk^{}&*GAK7Wt2+V<2_cv5Wsm uensO^9~|cY{4hGsHEQvw#iJI}7ytl}=_J?)Agx#c0000 Date: Wed, 4 Jun 2025 14:31:06 +0100 Subject: [PATCH 130/558] Numbering implementation refactor (#6122) --- crates/typst-library/src/model/numbering.rs | 637 ++++++++++---------- tests/suite/model/numbering.typ | 42 +- 2 files changed, 339 insertions(+), 340 deletions(-) diff --git a/crates/typst-library/src/model/numbering.rs b/crates/typst-library/src/model/numbering.rs index 320ed7d176..236ced3617 100644 --- a/crates/typst-library/src/model/numbering.rs +++ b/crates/typst-library/src/model/numbering.rs @@ -9,7 +9,6 @@ use ecow::{eco_format, EcoString, EcoVec}; use crate::diag::SourceResult; use crate::engine::Engine; use crate::foundations::{cast, func, Context, Func, Str, Value}; -use crate::text::Case; /// Applies a numbering to a sequence of numbers. /// @@ -381,40 +380,194 @@ impl NumberingKind { /// Apply the numbering to the given number. pub fn apply(self, n: u64) -> EcoString { match self { - Self::Arabic => eco_format!("{n}"), - Self::LowerRoman => roman_numeral(n, Case::Lower), - Self::UpperRoman => roman_numeral(n, Case::Upper), - Self::LowerGreek => greek_numeral(n, Case::Lower), - Self::UpperGreek => greek_numeral(n, Case::Upper), - Self::Symbol => { - if n == 0 { - return '-'.into(); - } - - const SYMBOLS: &[char] = &['*', '†', '‡', '§', '¶', '‖']; - let n_symbols = SYMBOLS.len() as u64; - let symbol = SYMBOLS[((n - 1) % n_symbols) as usize]; - let amount = ((n - 1) / n_symbols) + 1; - std::iter::repeat_n(symbol, amount.try_into().unwrap()).collect() + Self::Arabic => { + numeric(&['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'], n) } - Self::Hebrew => hebrew_numeral(n), - - Self::LowerLatin => zeroless( - [ + Self::LowerRoman => additive( + &[ + ("m̅", 1000000), + ("d̅", 500000), + ("c̅", 100000), + ("l̅", 50000), + ("x̅", 10000), + ("v̅", 5000), + ("i̅v̅", 4000), + ("m", 1000), + ("cm", 900), + ("d", 500), + ("cd", 400), + ("c", 100), + ("xc", 90), + ("l", 50), + ("xl", 40), + ("x", 10), + ("ix", 9), + ("v", 5), + ("iv", 4), + ("i", 1), + ("n", 0), + ], + n, + ), + Self::UpperRoman => additive( + &[ + ("M̅", 1000000), + ("D̅", 500000), + ("C̅", 100000), + ("L̅", 50000), + ("X̅", 10000), + ("V̅", 5000), + ("I̅V̅", 4000), + ("M", 1000), + ("CM", 900), + ("D", 500), + ("CD", 400), + ("C", 100), + ("XC", 90), + ("L", 50), + ("XL", 40), + ("X", 10), + ("IX", 9), + ("V", 5), + ("IV", 4), + ("I", 1), + ("N", 0), + ], + n, + ), + Self::LowerGreek => additive( + &[ + ("͵θ", 9000), + ("͵η", 8000), + ("͵ζ", 7000), + ("͵ϛ", 6000), + ("͵ε", 5000), + ("͵δ", 4000), + ("͵γ", 3000), + ("͵β", 2000), + ("͵α", 1000), + ("ϡ", 900), + ("ω", 800), + ("ψ", 700), + ("χ", 600), + ("φ", 500), + ("υ", 400), + ("τ", 300), + ("σ", 200), + ("ρ", 100), + ("ϟ", 90), + ("π", 80), + ("ο", 70), + ("ξ", 60), + ("ν", 50), + ("μ", 40), + ("λ", 30), + ("κ", 20), + ("ι", 10), + ("θ", 9), + ("η", 8), + ("ζ", 7), + ("ϛ", 6), + ("ε", 5), + ("δ", 4), + ("γ", 3), + ("β", 2), + ("α", 1), + ("𐆊", 0), + ], + n, + ), + Self::UpperGreek => additive( + &[ + ("͵Θ", 9000), + ("͵Η", 8000), + ("͵Ζ", 7000), + ("͵Ϛ", 6000), + ("͵Ε", 5000), + ("͵Δ", 4000), + ("͵Γ", 3000), + ("͵Β", 2000), + ("͵Α", 1000), + ("Ϡ", 900), + ("Ω", 800), + ("Ψ", 700), + ("Χ", 600), + ("Φ", 500), + ("Υ", 400), + ("Τ", 300), + ("Σ", 200), + ("Ρ", 100), + ("Ϟ", 90), + ("Π", 80), + ("Ο", 70), + ("Ξ", 60), + ("Ν", 50), + ("Μ", 40), + ("Λ", 30), + ("Κ", 20), + ("Ι", 10), + ("Θ", 9), + ("Η", 8), + ("Ζ", 7), + ("Ϛ", 6), + ("Ε", 5), + ("Δ", 4), + ("Γ", 3), + ("Β", 2), + ("Α", 1), + ("𐆊", 0), + ], + n, + ), + Self::Hebrew => additive( + &[ + ("ת", 400), + ("ש", 300), + ("ר", 200), + ("ק", 100), + ("צ", 90), + ("פ", 80), + ("ע", 70), + ("ס", 60), + ("נ", 50), + ("מ", 40), + ("ל", 30), + ("כ", 20), + ("יט", 19), + ("יח", 18), + ("יז", 17), + ("טז", 16), + ("טו", 15), + ("י", 10), + ("ט", 9), + ("ח", 8), + ("ז", 7), + ("ו", 6), + ("ה", 5), + ("ד", 4), + ("ג", 3), + ("ב", 2), + ("א", 1), + ("-", 0), + ], + n, + ), + Self::LowerLatin => alphabetic( + &[ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', ], n, ), - Self::UpperLatin => zeroless( - [ + Self::UpperLatin => alphabetic( + &[ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', ], n, ), - Self::HiraganaAiueo => zeroless( - [ + Self::HiraganaAiueo => alphabetic( + &[ 'あ', 'い', 'う', 'え', 'お', 'か', 'き', 'く', 'け', 'こ', 'さ', 'し', 'す', 'せ', 'そ', 'た', 'ち', 'つ', 'て', 'と', 'な', 'に', 'ぬ', 'ね', 'の', 'は', 'ひ', 'ふ', 'へ', 'ほ', 'ま', 'み', 'む', @@ -423,8 +576,8 @@ impl NumberingKind { ], n, ), - Self::HiraganaIroha => zeroless( - [ + Self::HiraganaIroha => alphabetic( + &[ 'い', 'ろ', 'は', 'に', 'ほ', 'へ', 'と', 'ち', 'り', 'ぬ', 'る', 'を', 'わ', 'か', 'よ', 'た', 'れ', 'そ', 'つ', 'ね', 'な', 'ら', 'む', 'う', 'ゐ', 'の', 'お', 'く', 'や', 'ま', 'け', 'ふ', 'こ', @@ -433,8 +586,8 @@ impl NumberingKind { ], n, ), - Self::KatakanaAiueo => zeroless( - [ + Self::KatakanaAiueo => alphabetic( + &[ 'ア', 'イ', 'ウ', 'エ', 'オ', 'カ', 'キ', 'ク', 'ケ', 'コ', 'サ', 'シ', 'ス', 'セ', 'ソ', 'タ', 'チ', 'ツ', 'テ', 'ト', 'ナ', 'ニ', 'ヌ', 'ネ', 'ノ', 'ハ', 'ヒ', 'フ', 'ヘ', 'ホ', 'マ', 'ミ', 'ム', @@ -443,8 +596,8 @@ impl NumberingKind { ], n, ), - Self::KatakanaIroha => zeroless( - [ + Self::KatakanaIroha => alphabetic( + &[ 'イ', 'ロ', 'ハ', 'ニ', 'ホ', 'ヘ', 'ト', 'チ', 'リ', 'ヌ', 'ル', 'ヲ', 'ワ', 'カ', 'ヨ', 'タ', 'レ', 'ソ', 'ツ', 'ネ', 'ナ', 'ラ', 'ム', 'ウ', 'ヰ', 'ノ', 'オ', 'ク', 'ヤ', 'マ', 'ケ', 'フ', 'コ', @@ -453,40 +606,40 @@ impl NumberingKind { ], n, ), - Self::KoreanJamo => zeroless( - [ + Self::KoreanJamo => alphabetic( + &[ 'ㄱ', 'ㄴ', 'ㄷ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅅ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ', ], n, ), - Self::KoreanSyllable => zeroless( - [ + Self::KoreanSyllable => alphabetic( + &[ '가', '나', '다', '라', '마', '바', '사', '아', '자', '차', '카', '타', '파', '하', ], n, ), - Self::BengaliLetter => zeroless( - [ + Self::BengaliLetter => alphabetic( + &[ 'ক', 'খ', 'গ', 'ঘ', 'ঙ', 'চ', 'ছ', 'জ', 'ঝ', 'ঞ', 'ট', 'ঠ', 'ড', 'ঢ', 'ণ', 'ত', 'থ', 'দ', 'ধ', 'ন', 'প', 'ফ', 'ব', 'ভ', 'ম', 'য', 'র', 'ল', 'শ', 'ষ', 'স', 'হ', ], n, ), - Self::CircledNumber => zeroless( - [ - '①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫', '⑬', '⑭', - '⑮', '⑯', '⑰', '⑱', '⑲', '⑳', '㉑', '㉒', '㉓', '㉔', '㉕', '㉖', - '㉗', '㉘', '㉙', '㉚', '㉛', '㉜', '㉝', '㉞', '㉟', '㊱', '㊲', - '㊳', '㊴', '㊵', '㊶', '㊷', '㊸', '㊹', '㊺', '㊻', '㊼', '㊽', - '㊾', '㊿', + Self::CircledNumber => fixed( + &[ + '⓪', '①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫', '⑬', + '⑭', '⑮', '⑯', '⑰', '⑱', '⑲', '⑳', '㉑', '㉒', '㉓', '㉔', '㉕', + '㉖', '㉗', '㉘', '㉙', '㉚', '㉛', '㉜', '㉝', '㉞', '㉟', '㊱', + '㊲', '㊳', '㊴', '㊵', '㊶', '㊷', '㊸', '㊹', '㊺', '㊻', '㊼', + '㊽', '㊾', '㊿', ], n, ), Self::DoubleCircledNumber => { - zeroless(['⓵', '⓶', '⓷', '⓸', '⓹', '⓺', '⓻', '⓼', '⓽', '⓾'], n) + fixed(&['0', '⓵', '⓶', '⓷', '⓸', '⓹', '⓺', '⓻', '⓼', '⓽', '⓾'], n) } Self::LowerSimplifiedChinese => { @@ -502,306 +655,170 @@ impl NumberingKind { u64_to_chinese(ChineseVariant::Traditional, ChineseCase::Upper, n).into() } - Self::EasternArabic => decimal('\u{0660}', n), - Self::EasternArabicPersian => decimal('\u{06F0}', n), - Self::DevanagariNumber => decimal('\u{0966}', n), - Self::BengaliNumber => decimal('\u{09E6}', n), - } - } -} - -/// Stringify an integer to a Hebrew number. -fn hebrew_numeral(mut n: u64) -> EcoString { - if n == 0 { - return '-'.into(); - } - let mut fmt = EcoString::new(); - 'outer: for (name, value) in [ - ('ת', 400), - ('ש', 300), - ('ר', 200), - ('ק', 100), - ('צ', 90), - ('פ', 80), - ('ע', 70), - ('ס', 60), - ('נ', 50), - ('מ', 40), - ('ל', 30), - ('כ', 20), - ('י', 10), - ('ט', 9), - ('ח', 8), - ('ז', 7), - ('ו', 6), - ('ה', 5), - ('ד', 4), - ('ג', 3), - ('ב', 2), - ('א', 1), - ] { - while n >= value { - match n { - 15 => fmt.push_str("ט״ו"), - 16 => fmt.push_str("ט״ז"), - _ => { - let append_geresh = n == value && fmt.is_empty(); - if n == value && !fmt.is_empty() { - fmt.push('״'); - } - fmt.push(name); - if append_geresh { - fmt.push('׳'); - } - - n -= value; - continue; - } + Self::EasternArabic => { + numeric(&['٠', '١', '٢', '٣', '٤', '٥', '٦', '٧', '٨', '٩'], n) } - break 'outer; - } - } - fmt -} - -/// Stringify an integer to a Roman numeral. -fn roman_numeral(mut n: u64, case: Case) -> EcoString { - if n == 0 { - return match case { - Case::Lower => 'n'.into(), - Case::Upper => 'N'.into(), - }; - } - - // Adapted from Yann Villessuzanne's roman.rs under the - // Unlicense, at https://github.com/linfir/roman.rs/ - let mut fmt = EcoString::new(); - for &(name, value) in &[ - ("M̅", 1000000), - ("D̅", 500000), - ("C̅", 100000), - ("L̅", 50000), - ("X̅", 10000), - ("V̅", 5000), - ("I̅V̅", 4000), - ("M", 1000), - ("CM", 900), - ("D", 500), - ("CD", 400), - ("C", 100), - ("XC", 90), - ("L", 50), - ("XL", 40), - ("X", 10), - ("IX", 9), - ("V", 5), - ("IV", 4), - ("I", 1), - ] { - while n >= value { - n -= value; - for c in name.chars() { - match case { - Case::Lower => fmt.extend(c.to_lowercase()), - Case::Upper => fmt.push(c), - } + Self::EasternArabicPersian => { + numeric(&['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'], n) + } + Self::DevanagariNumber => { + numeric(&['०', '१', '२', '३', '४', '५', '६', '७', '८', '९'], n) + } + Self::BengaliNumber => { + numeric(&['০', '১', '২', '৩', '৪', '৫', '৬', '৭', '৮', '৯'], n) } + Self::Symbol => symbolic(&['*', '†', '‡', '§', '¶', '‖'], n), } } - - fmt } -/// Stringify an integer to Greek numbers. +/// Stringify a number using symbols representing values. The decimal +/// representation of the number is recovered by summing over the values of the +/// symbols present. /// -/// Greek numbers use the Greek Alphabet to represent numbers; it is based on 10 -/// (decimal). Here we implement the single digit M power representation from -/// [The Greek Number Converter][convert] and also described in -/// [Greek Numbers][numbers]. +/// Consider the situation where ['I': 1, 'IV': 4, 'V': 5], /// -/// [converter]: https://www.russellcottrell.com/greek/utilities/GreekNumberConverter.htm -/// [numbers]: https://mathshistory.st-andrews.ac.uk/HistTopics/Greek_numbers/ -fn greek_numeral(n: u64, case: Case) -> EcoString { - let thousands = [ - ["͵α", "͵Α"], - ["͵β", "͵Β"], - ["͵γ", "͵Γ"], - ["͵δ", "͵Δ"], - ["͵ε", "͵Ε"], - ["͵ϛ", "͵Ϛ"], - ["͵ζ", "͵Ζ"], - ["͵η", "͵Η"], - ["͵θ", "͵Θ"], - ]; - let hundreds = [ - ["ρ", "Ρ"], - ["σ", "Σ"], - ["τ", "Τ"], - ["υ", "Υ"], - ["φ", "Φ"], - ["χ", "Χ"], - ["ψ", "Ψ"], - ["ω", "Ω"], - ["ϡ", "Ϡ"], - ]; - let tens = [ - ["ι", "Ι"], - ["κ", "Κ"], - ["λ", "Λ"], - ["μ", "Μ"], - ["ν", "Ν"], - ["ξ", "Ξ"], - ["ο", "Ο"], - ["π", "Π"], - ["ϙ", "Ϟ"], - ]; - let ones = [ - ["α", "Α"], - ["β", "Β"], - ["γ", "Γ"], - ["δ", "Δ"], - ["ε", "Ε"], - ["ϛ", "Ϛ"], - ["ζ", "Ζ"], - ["η", "Η"], - ["θ", "Θ"], - ]; - +/// ```text +/// 1 => 'I' +/// 2 => 'II' +/// 3 => 'III' +/// 4 => 'IV' +/// 5 => 'V' +/// 6 => 'VI' +/// 7 => 'VII' +/// 8 => 'VIII' +/// ``` +/// +/// where this is the start of the familiar Roman numeral system. +fn additive(symbols: &[(&str, u64)], mut n: u64) -> EcoString { if n == 0 { - // Greek Zero Sign - return '𐆊'.into(); - } - - let mut fmt = EcoString::new(); - let case = match case { - Case::Lower => 0, - Case::Upper => 1, - }; - - // Extract a list of decimal digits from the number - let mut decimal_digits: Vec = Vec::new(); - let mut n = n; - while n > 0 { - decimal_digits.push((n % 10) as usize); - n /= 10; - } - - // Pad the digits with leading zeros to ensure we can form groups of 4 - while decimal_digits.len() % 4 != 0 { - decimal_digits.push(0); - } - decimal_digits.reverse(); - - let mut m_power = decimal_digits.len() / 4; - - // M are used to represent 10000, M_power = 2 means 10000^2 = 10000 0000 - // The prefix of M is also made of Greek numerals but only be single digits, so it is 9 at max. This enables us - // to represent up to (10000)^(9 + 1) - 1 = 10^40 -1 (9,999,999,999,999,999,999,999,999,999,999,999,999,999) - let get_m_prefix = |m_power: usize| { - if m_power == 0 { - None - } else { - assert!(m_power <= 9); - // the prefix of M is a single digit lowercase - Some(ones[m_power - 1][0]) + if let Some(&(symbol, 0)) = symbols.last() { + return symbol.into(); } - }; - - let mut previous_has_number = false; - for chunk in decimal_digits.chunks_exact(4) { - // chunk must be exact 4 item - assert_eq!(chunk.len(), 4); - - m_power = m_power.saturating_sub(1); + return '0'.into(); + } - // `th`ousan, `h`undred, `t`en and `o`ne - let (th, h, t, o) = (chunk[0], chunk[1], chunk[2], chunk[3]); - if th + h + t + o == 0 { + let mut s = EcoString::new(); + for (symbol, weight) in symbols { + if *weight == 0 || *weight > n { continue; } - - if previous_has_number { - fmt.push_str(", "); + let reps = n / weight; + for _ in 0..reps { + s.push_str(symbol); } - if let Some(m_prefix) = get_m_prefix(m_power) { - fmt.push_str(m_prefix); - fmt.push_str("Μ"); - } - if th != 0 { - let thousand_digit = thousands[th - 1][case]; - fmt.push_str(thousand_digit); - } - if h != 0 { - let hundred_digit = hundreds[h - 1][case]; - fmt.push_str(hundred_digit); - } - if t != 0 { - let ten_digit = tens[t - 1][case]; - fmt.push_str(ten_digit); - } - if o != 0 { - let one_digit = ones[o - 1][case]; - fmt.push_str(one_digit); + n -= weight * reps; + if n == 0 { + return s; } - // if we do not have thousan, we need to append 'ʹ' at the end. - if th == 0 { - fmt.push_str("ʹ"); - } - previous_has_number = true; } - fmt + s } -/// Stringify a number using a base-N counting system with no zero digit. +/// Stringify a number using a base-n (where n is the number of provided +/// symbols) system without a zero symbol. /// -/// This is best explained by example. Suppose our digits are 'A', 'B', and 'C'. -/// We would get the following: +/// Consider the situation where ['A', 'B', 'C'] are the provided symbols, /// /// ```text -/// 1 => "A" -/// 2 => "B" -/// 3 => "C" -/// 4 => "AA" -/// 5 => "AB" -/// 6 => "AC" -/// 7 => "BA" -/// 8 => "BB" -/// 9 => "BC" -/// 10 => "CA" -/// 11 => "CB" -/// 12 => "CC" -/// 13 => "AAA" -/// etc. +/// 1 => 'A' +/// 2 => 'B' +/// 3 => 'C' +/// 4 => 'AA +/// 5 => 'AB' +/// 6 => 'AC' +/// 7 => 'BA' +/// ... /// ``` /// -/// You might be familiar with this scheme from the way spreadsheet software -/// tends to label its columns. -fn zeroless(alphabet: [char; N_DIGITS], mut n: u64) -> EcoString { +/// This system is commonly used in spreadsheet software. +fn alphabetic(symbols: &[char], mut n: u64) -> EcoString { + let n_digits = symbols.len() as u64; if n == 0 { return '-'.into(); } - let n_digits = N_DIGITS as u64; - let mut cs = EcoString::new(); - while n > 0 { + let mut s = EcoString::new(); + while n != 0 { n -= 1; - cs.push(alphabet[(n % n_digits) as usize]); + s.push(symbols[(n % n_digits) as usize]); n /= n_digits; } - cs.chars().rev().collect() + s.chars().rev().collect() +} + +/// Stringify a number using the symbols provided, defaulting to the arabic +/// representation when the number is greater than the number of symbols. +/// +/// Consider the situation where ['0', 'A', 'B', 'C'] are the provided symbols, +/// +/// ```text +/// 0 => '0' +/// 1 => 'A' +/// 2 => 'B' +/// 3 => 'C' +/// 4 => '4' +/// ... +/// n => 'n' +/// ``` +fn fixed(symbols: &[char], n: u64) -> EcoString { + let n_digits = symbols.len() as u64; + if n < n_digits { + return symbols[(n) as usize].into(); + } + eco_format!("{n}") } -/// Stringify a number using a base-10 counting system with a zero digit. +/// Stringify a number using a base-n (where n is the number of provided +/// symbols) system with a zero symbol. +/// +/// Consider the situation where ['0', '1', '2'] are the provided symbols, /// -/// This function assumes that the digits occupy contiguous codepoints. -fn decimal(start: char, mut n: u64) -> EcoString { +/// ```text +/// 0 => '0' +/// 1 => '1' +/// 2 => '2' +/// 3 => '10' +/// 4 => '11' +/// 5 => '12' +/// 6 => '20' +/// ... +/// ``` +/// +/// which is the familiar trinary counting system. +fn numeric(symbols: &[char], mut n: u64) -> EcoString { + let n_digits = symbols.len() as u64; if n == 0 { - return start.into(); + return symbols[0].into(); + } + let mut s = EcoString::new(); + while n != 0 { + s.push(symbols[(n % n_digits) as usize]); + n /= n_digits; } - let mut cs = EcoString::new(); - while n > 0 { - cs.push(char::from_u32((start as u32) + ((n % 10) as u32)).unwrap()); - n /= 10; + s.chars().rev().collect() +} + +/// Stringify a number using repeating symbols. +/// +/// Consider the situation where ['A', 'B', 'C'] are the provided symbols, +/// +/// ```text +/// 0 => '-' +/// 1 => 'A' +/// 2 => 'B' +/// 3 => 'C' +/// 4 => 'AA' +/// 5 => 'BB' +/// 6 => 'CC' +/// 7 => 'AAA' +/// ... +/// ``` +fn symbolic(symbols: &[char], n: u64) -> EcoString { + let n_digits = symbols.len() as u64; + if n == 0 { + return '-'.into(); } - cs.chars().rev().collect() + EcoString::from(symbols[((n - 1) % n_digits) as usize]) + .repeat((n.div_ceil(n_digits)) as usize) } diff --git a/tests/suite/model/numbering.typ b/tests/suite/model/numbering.typ index 6af989ff1d..2d6a3d6a6a 100644 --- a/tests/suite/model/numbering.typ +++ b/tests/suite/model/numbering.typ @@ -19,50 +19,32 @@ // Greek. #t( pat: "α", - "𐆊", "αʹ", "βʹ", "γʹ", "δʹ", "εʹ", "ϛʹ", "ζʹ", "ηʹ", "θʹ", "ιʹ", - "ιαʹ", "ιβʹ", "ιγʹ", "ιδʹ", "ιεʹ", "ιϛʹ", "ιζʹ", "ιηʹ", "ιθʹ", "κʹ", - 241, "σμαʹ", - 999, "ϡϙθʹ", + "𐆊", "α", "β", "γ", "δ", "ε", "ϛ", "ζ", "η", "θ", "ι", + "ια", "ιβ", "ιγ", "ιδ", "ιε", "ιϛ", "ιζ", "ιη", "ιθ", "κ", + 241, "σμα", + 999, "ϡϟθ", 1005, "͵αε", - 1999, "͵αϡϙθ", - 2999, "͵βϡϙθ", + 1999, "͵αϡϟθ", + 2999, "͵βϡϟθ", 3000, "͵γ", - 3398, "͵γτϙη", + 3398, "͵γτϟη", 4444, "͵δυμδ", 5683, "͵εχπγ", 9184, "͵θρπδ", - 9999, "͵θϡϙθ", - 20000, "αΜβʹ", - 20001, "αΜβʹ, αʹ", - 97554, "αΜθʹ, ͵ζφνδ", - 99999, "αΜθʹ, ͵θϡϙθ", - 1000000, "αΜρʹ", - 1000001, "αΜρʹ, αʹ", - 1999999, "αΜρϙθʹ, ͵θϡϙθ", - 2345678, "αΜσλδʹ, ͵εχοη", - 9999999, "αΜϡϙθʹ, ͵θϡϙθ", - 10000000, "αΜ͵α", - 90000001, "αΜ͵θ, αʹ", - 100000000, "βΜαʹ", - 1000000000, "βΜιʹ", - 2000000000, "βΜκʹ", - 2000000001, "βΜκʹ, αʹ", - 2000010001, "βΜκʹ, αΜαʹ, αʹ", - 2056839184, "βΜκʹ, αΜ͵εχπγ, ͵θρπδ", - 12312398676, "βΜρκγʹ, αΜ͵ασλθ, ͵ηχοϛ", + 9999, "͵θϡϟθ", ) #t( pat: sym.Alpha, - "𐆊", "Αʹ", "Βʹ", "Γʹ", "Δʹ", "Εʹ", "Ϛʹ", "Ζʹ", "Ηʹ", "Θʹ", "Ιʹ", - "ΙΑʹ", "ΙΒʹ", "ΙΓʹ", "ΙΔʹ", "ΙΕʹ", "ΙϚʹ", "ΙΖʹ", "ΙΗʹ", "ΙΘʹ", "Κʹ", - 241, "ΣΜΑʹ", + "𐆊", "Α", "Β", "Γ", "Δ", "Ε", "Ϛ", "Ζ", "Η", "Θ", "Ι", + "ΙΑ", "ΙΒ", "ΙΓ", "ΙΔ", "ΙΕ", "ΙϚ", "ΙΖ", "ΙΗ", "ΙΘ", "Κ", + 241, "ΣΜΑ", ) // Symbols. #t(pat: "*", "-", "*", "†", "‡", "§", "¶", "‖", "**") // Hebrew. -#t(pat: "א", step: 2, 9, "ט׳", "י״א", "י״ג") +#t(pat: "א", step: 2, 9, "ט", "יא", "יג", 15, "טו", 16, "טז") // Chinese. #t(pat: "一", step: 2, 9, "九", "十一", "十三", "十五", "十七", "十九") From 6725061841e327227a49f90134136264a5b8c584 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Mon, 9 Jun 2025 10:46:29 -0300 Subject: [PATCH 131/558] Pin colspan and rowspan for blank cells (#6401) --- crates/typst-library/src/layout/grid/mod.rs | 9 ++++++++- crates/typst-library/src/model/table.rs | 9 ++++++++- .../ref/issue-6399-grid-cell-colspan-set-rule.png | Bin 0 -> 232 bytes .../ref/issue-6399-grid-cell-rowspan-set-rule.png | Bin 0 -> 232 bytes tests/suite/layout/grid/colspan.typ | 4 ++++ tests/suite/layout/grid/rowspan.typ | 4 ++++ 6 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 tests/ref/issue-6399-grid-cell-colspan-set-rule.png create mode 100644 tests/ref/issue-6399-grid-cell-rowspan-set-rule.png diff --git a/crates/typst-library/src/layout/grid/mod.rs b/crates/typst-library/src/layout/grid/mod.rs index 6616c33114..369df11ee0 100644 --- a/crates/typst-library/src/layout/grid/mod.rs +++ b/crates/typst-library/src/layout/grid/mod.rs @@ -755,7 +755,14 @@ impl Show for Packed { impl Default for Packed { fn default() -> Self { - Packed::new(GridCell::new(Content::default())) + Packed::new( + // Explicitly set colspan and rowspan to ensure they won't be + // overridden by set rules (default cells are created after + // colspans and rowspans are processed in the resolver) + GridCell::new(Content::default()) + .with_colspan(NonZeroUsize::ONE) + .with_rowspan(NonZeroUsize::ONE), + ) } } diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs index 6f4461bd44..373230897b 100644 --- a/crates/typst-library/src/model/table.rs +++ b/crates/typst-library/src/model/table.rs @@ -770,7 +770,14 @@ impl Show for Packed { impl Default for Packed { fn default() -> Self { - Packed::new(TableCell::new(Content::default())) + Packed::new( + // Explicitly set colspan and rowspan to ensure they won't be + // overridden by set rules (default cells are created after + // colspans and rowspans are processed in the resolver) + TableCell::new(Content::default()) + .with_colspan(NonZeroUsize::ONE) + .with_rowspan(NonZeroUsize::ONE), + ) } } diff --git a/tests/ref/issue-6399-grid-cell-colspan-set-rule.png b/tests/ref/issue-6399-grid-cell-colspan-set-rule.png new file mode 100644 index 0000000000000000000000000000000000000000..a40eda78dc1708901754f8c1ce78df5e1456bd85 GIT binary patch literal 232 zcmVP)z*yZ?bhY?VIE=NYmcAAT^~PQKe|$;bk39t~ z(J%jRT{)Fb7Mp(B-ul;Of9(7{A-^WBe&N2~@hE*P4*P$s=!*N($KUtV{9iC*UDngb zzv*J}O~3#9GXJQ){PTQM$^RMsPxZck{y-Ot7nD4oS^oM!(fnUs)!&-xf2}TGIa+d! iT0Cm;sKuieBLM(bBzWrxf}HRG0000P)z*yZ?bhY?VIE=NYmcAAT^~PQKe|$;bk39t~ z(J%jRT{)Fb7Mp(B-ul;Of9(7{A-^WBe&N2~@hE*P4*P$s=!*N($KUtV{9iC*UDngb zzv*J}O~3#9GXJQ){PTQM$^RMsPxZck{y-Ot7nD4oS^oM!(fnUs)!&-xf2}TGIa+d! iT0Cm;sKuieBLM(bBzWrxf}HRG0000 Date: Mon, 9 Jun 2025 09:48:55 -0400 Subject: [PATCH 132/558] Clean up some parser comments (#6398) --- crates/typst-syntax/src/parser.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/typst-syntax/src/parser.rs b/crates/typst-syntax/src/parser.rs index ecd0d78a5a..a688158066 100644 --- a/crates/typst-syntax/src/parser.rs +++ b/crates/typst-syntax/src/parser.rs @@ -1571,10 +1571,10 @@ struct Token { prev_end: usize, } -/// Information about a newline if present (currently only relevant in Markup). +/// Information about newlines in a group of trivia. #[derive(Debug, Clone, Copy)] struct Newline { - /// The column of the start of our token in its line. + /// The column of the start of the next token in its line. column: Option, /// Whether any of our newlines were paragraph breaks. parbreak: bool, @@ -1587,7 +1587,7 @@ enum AtNewline { Continue, /// Stop at any newline. Stop, - /// Continue only if there is no continuation with `else` or `.` (Code only). + /// Continue only if there is a continuation with `else` or `.` (Code only). ContextualContinue, /// Stop only at a parbreak, not normal newlines (Markup only). StopParBreak, @@ -1610,9 +1610,10 @@ impl AtNewline { }, AtNewline::StopParBreak => parbreak, AtNewline::RequireColumn(min_col) => { - // Don't stop if this newline doesn't start a column (this may - // be checked on the boundary of lexer modes, since we only - // report a column in Markup). + // When the column is `None`, the newline doesn't start a + // column, and we continue parsing. This may happen on the + // boundary of lexer modes, since we only report a column in + // Markup. column.is_some_and(|column| column <= min_col) } } From df4c08f852ba3342e69caa721067804a7152e166 Mon Sep 17 00:00:00 2001 From: cAttte <26514199+cAttte@users.noreply.github.com> Date: Mon, 9 Jun 2025 11:16:47 -0300 Subject: [PATCH 133/558] Autocomplete fixes for math mode (#6415) --- crates/typst-ide/src/complete.rs | 16 +++++++++++++++- crates/typst-ide/src/utils.rs | 4 +++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index 4a36045ae7..a042b16401 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -298,13 +298,20 @@ fn complete_math(ctx: &mut CompletionContext) -> bool { return false; } - // Start of an interpolated identifier: "#|". + // Start of an interpolated identifier: "$#|$". if ctx.leaf.kind() == SyntaxKind::Hash { ctx.from = ctx.cursor; code_completions(ctx, true); return true; } + // Behind existing interpolated identifier: "$#pa|$". + if ctx.leaf.kind() == SyntaxKind::Ident { + ctx.from = ctx.leaf.offset(); + code_completions(ctx, true); + return true; + } + // Behind existing atom or identifier: "$a|$" or "$abc|$". if matches!( ctx.leaf.kind(), @@ -1666,6 +1673,13 @@ mod tests { test("#{() .a}", -2).must_include(["at", "any", "all"]); } + /// Test that autocomplete in math uses the correct global scope. + #[test] + fn test_autocomplete_math_scope() { + test("$#col$", -2).must_include(["colbreak"]).must_exclude(["colon"]); + test("$col$", -2).must_include(["colon"]).must_exclude(["colbreak"]); + } + /// Test that the `before_window` doesn't slice into invalid byte /// boundaries. #[test] diff --git a/crates/typst-ide/src/utils.rs b/crates/typst-ide/src/utils.rs index 887e851f91..13de402baf 100644 --- a/crates/typst-ide/src/utils.rs +++ b/crates/typst-ide/src/utils.rs @@ -114,7 +114,9 @@ pub fn globals<'a>(world: &'a dyn IdeWorld, leaf: &LinkedNode) -> &'a Scope { | Some(SyntaxKind::Math) | Some(SyntaxKind::MathFrac) | Some(SyntaxKind::MathAttach) - ); + ) && leaf + .prev_leaf() + .is_none_or(|prev| !matches!(prev.kind(), SyntaxKind::Hash)); let library = world.library(); if in_math { From 2a3746c51de9231436013a2885a6d7096b0e4028 Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Mon, 9 Jun 2025 17:25:33 +0300 Subject: [PATCH 134/558] Update docs for gradient.repeat (#6385) --- crates/typst-library/src/visualize/gradient.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/typst-library/src/visualize/gradient.rs b/crates/typst-library/src/visualize/gradient.rs index 45f388ccdb..5d7859a37e 100644 --- a/crates/typst-library/src/visualize/gradient.rs +++ b/crates/typst-library/src/visualize/gradient.rs @@ -549,7 +549,7 @@ impl Gradient { } /// Repeats this gradient a given number of times, optionally mirroring it - /// at each repetition. + /// at every second repetition. /// /// ```example /// #circle( @@ -564,7 +564,17 @@ impl Gradient { &self, /// The number of times to repeat the gradient. repetitions: Spanned, - /// Whether to mirror the gradient at each repetition. + /// Whether to mirror the gradient at every second repetition, i.e., + /// the first instance (and all odd ones) stays unchanged. + /// + /// ```example + /// #circle( + /// radius: 40pt, + /// fill: gradient + /// .conic(green, black) + /// .repeat(2, mirror: true) + /// ) + /// ``` #[named] #[default(false)] mirror: bool, From e632bffc2ed4c005e5e989b527a05e87f077a8a0 Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Mon, 9 Jun 2025 19:34:39 +0300 Subject: [PATCH 135/558] Document how to escape lr delimiter auto-scaling (#6410) Co-authored-by: Laurenz --- docs/reference/groups.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/reference/groups.yml b/docs/reference/groups.yml index 8fea3a1f2b..e5aa7e9990 100644 --- a/docs/reference/groups.yml +++ b/docs/reference/groups.yml @@ -112,11 +112,18 @@ a few more functions that create delimiter pairings for absolute, ceiled, and floored values as well as norms. + To prevent a delimiter from being matched by Typst, and thus auto-scaled, + escape it with a backslash. To instead disable auto-scaling completely, use + `{set math.lr(size: 1em)}`. + # Example ```example $ [a, b/2] $ $ lr(]sum_(x=1)^n], size: #50%) x $ $ abs((x + y) / 2) $ + $ \{ (x / y) \} $ + #set math.lr(size: 1em) + $ { (a / b), a, b in (0; 1/2] } $ ``` - name: calc From 82da96ed957a68017e092e2606226b45c34324f1 Mon Sep 17 00:00:00 2001 From: Ian Wrzesinski <133046678+wrzian@users.noreply.github.com> Date: Tue, 10 Jun 2025 05:11:27 -0400 Subject: [PATCH 136/558] Improve number lexing (#5969) --- crates/typst-syntax/src/lexer.rs | 142 ++++++++++++++++-------------- tests/ref/double-percent.png | Bin 496 -> 0 bytes tests/suite/foundations/float.typ | 8 +- tests/suite/layout/length.typ | 36 ++++++-- tests/suite/layout/relative.typ | 7 +- 5 files changed, 112 insertions(+), 81 deletions(-) delete mode 100644 tests/ref/double-percent.png diff --git a/crates/typst-syntax/src/lexer.rs b/crates/typst-syntax/src/lexer.rs index ac69eb616e..7d363d7b5b 100644 --- a/crates/typst-syntax/src/lexer.rs +++ b/crates/typst-syntax/src/lexer.rs @@ -807,86 +807,96 @@ impl Lexer<'_> { } } - fn number(&mut self, mut start: usize, c: char) -> SyntaxKind { + fn number(&mut self, start: usize, first_c: char) -> SyntaxKind { // Handle alternative integer bases. - let mut base = 10; - if c == '0' { - if self.s.eat_if('b') { - base = 2; - } else if self.s.eat_if('o') { - base = 8; - } else if self.s.eat_if('x') { - base = 16; - } - if base != 10 { - start = self.s.cursor(); - } - } + let base = match first_c { + '0' if self.s.eat_if('b') => 2, + '0' if self.s.eat_if('o') => 8, + '0' if self.s.eat_if('x') => 16, + _ => 10, + }; - // Read the first part (integer or fractional depending on `first`). - self.s.eat_while(if base == 16 { - char::is_ascii_alphanumeric + // Read the initial digits. + if base == 16 { + self.s.eat_while(char::is_ascii_alphanumeric); } else { - char::is_ascii_digit - }); - - // Read the fractional part if not already done. - // Make sure not to confuse a range for the decimal separator. - if c != '.' - && !self.s.at("..") - && !self.s.scout(1).is_some_and(is_id_start) - && self.s.eat_if('.') - && base == 10 - { self.s.eat_while(char::is_ascii_digit); } - // Read the exponent. - if !self.s.at("em") && self.s.eat_if(['e', 'E']) && base == 10 { - self.s.eat_if(['+', '-']); - self.s.eat_while(char::is_ascii_digit); - } + // Read floating point digits and exponents. + let mut is_float = false; + if base == 10 { + // Read digits following a dot. Make sure not to confuse a spread + // operator or a method call for the decimal separator. + if first_c == '.' { + is_float = true; // We already ate the trailing digits above. + } else if !self.s.at("..") + && !self.s.scout(1).is_some_and(is_id_start) + && self.s.eat_if('.') + { + is_float = true; + self.s.eat_while(char::is_ascii_digit); + } - // Read the suffix. - let suffix_start = self.s.cursor(); - if !self.s.eat_if('%') { - self.s.eat_while(char::is_ascii_alphanumeric); + // Read the exponent. + if !self.s.at("em") && self.s.eat_if(['e', 'E']) { + is_float = true; + self.s.eat_if(['+', '-']); + self.s.eat_while(char::is_ascii_digit); + } } - let number = self.s.get(start..suffix_start); - let suffix = self.s.from(suffix_start); + let number = self.s.from(start); + let suffix = self.s.eat_while(|c: char| c.is_ascii_alphanumeric() || c == '%'); - let kind = if i64::from_str_radix(number, base).is_ok() { - SyntaxKind::Int - } else if base == 10 && number.parse::().is_ok() { - SyntaxKind::Float - } else { - return self.error(match base { - 2 => eco_format!("invalid binary number: 0b{}", number), - 8 => eco_format!("invalid octal number: 0o{}", number), - 16 => eco_format!("invalid hexadecimal number: 0x{}", number), - _ => eco_format!("invalid number: {}", number), - }); + let mut suffix_result = match suffix { + "" => Ok(None), + "pt" | "mm" | "cm" | "in" | "deg" | "rad" | "em" | "fr" | "%" => Ok(Some(())), + _ => Err(eco_format!("invalid number suffix: {suffix}")), }; - if suffix.is_empty() { - return kind; - } - - if !matches!( - suffix, - "pt" | "mm" | "cm" | "in" | "deg" | "rad" | "em" | "fr" | "%" - ) { - return self.error(eco_format!("invalid number suffix: {}", suffix)); - } + let number_result = if is_float && number.parse::().is_err() { + // The only invalid case should be when a float lacks digits after + // the exponent: e.g. `1.2e`, `2.3E-`, or `1EM`. + Err(eco_format!("invalid floating point number: {number}")) + } else if base == 10 { + Ok(()) + } else { + let name = match base { + 2 => "binary", + 8 => "octal", + 16 => "hexadecimal", + _ => unreachable!(), + }; + // The index `[2..]` skips the leading `0b`/`0o`/`0x`. + match i64::from_str_radix(&number[2..], base) { + Ok(_) if suffix.is_empty() => Ok(()), + Ok(value) => { + if suffix_result.is_ok() { + suffix_result = Err(eco_format!( + "try using a decimal number: {value}{suffix}" + )); + } + Err(eco_format!("{name} numbers cannot have a suffix")) + } + Err(_) => Err(eco_format!("invalid {name} number: {number}")), + } + }; - if base != 10 { - let kind = self.error(eco_format!("invalid base-{base} prefix")); - self.hint("numbers with a unit cannot have a base prefix"); - return kind; + // Return our number or write an error with helpful hints. + match (number_result, suffix_result) { + // Valid numbers :D + (Ok(()), Ok(None)) if is_float => SyntaxKind::Float, + (Ok(()), Ok(None)) => SyntaxKind::Int, + (Ok(()), Ok(Some(()))) => SyntaxKind::Numeric, + // Invalid numbers :( + (Err(number_err), Err(suffix_err)) => { + let err = self.error(number_err); + self.hint(suffix_err); + err + } + (Ok(()), Err(msg)) | (Err(msg), Ok(_)) => self.error(msg), } - - SyntaxKind::Numeric } fn string(&mut self) -> SyntaxKind { diff --git a/tests/ref/double-percent.png b/tests/ref/double-percent.png deleted file mode 100644 index 61a0d6143cd1615b0fa0051d0442b32be6fd2491..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 496 zcmV+=2f`SbJhrlzKag@yFj3(}_~!2H?CkmK@#wC} z)^MQTk+#!kn$&BZ+=Z^zaiQj?#p$ujwY9a5j*hIXtmfwC)^eiSeW~fQ&FJXp>gwu| zk&(y8$MMbHoSdBU^75mjqwT@g;g-6}%F6cO>gu-7`||eg?(V$2yr7_<{{H@khK6)> zbh5It#KgqGDRSzy&iUx@&|Q?$VwdpA+xFh+_xJby`~2XNx8aw%>a@-K@b&-x{>PGM zX#fBKrb$FWRCwC$(?t%$KoCUHa+sN!nVFdxY~TMVk>bQRm`IWOt-g9ws|F#2{4FP1O>0000 Date: Tue, 10 Jun 2025 14:46:27 +0200 Subject: [PATCH 137/558] Report errors in external files (#6308) Co-authored-by: Laurenz --- Cargo.lock | 3 + Cargo.toml | 1 + crates/typst-cli/src/compile.rs | 4 +- crates/typst-cli/src/timings.rs | 2 +- crates/typst-cli/src/world.rs | 23 +- crates/typst-layout/Cargo.toml | 1 + crates/typst-layout/src/image.rs | 14 +- crates/typst-library/Cargo.toml | 1 + crates/typst-library/src/diag.rs | 303 ++++++++++++- crates/typst-library/src/foundations/bytes.rs | 11 + .../typst-library/src/foundations/plugin.rs | 4 +- crates/typst-library/src/loading/cbor.rs | 4 +- crates/typst-library/src/loading/csv.rs | 36 +- crates/typst-library/src/loading/json.rs | 13 +- crates/typst-library/src/loading/mod.rs | 49 ++- crates/typst-library/src/loading/read.rs | 15 +- crates/typst-library/src/loading/toml.rs | 28 +- crates/typst-library/src/loading/xml.rs | 11 +- crates/typst-library/src/loading/yaml.rs | 23 +- .../typst-library/src/model/bibliography.rs | 119 +++--- crates/typst-library/src/text/raw.rs | 81 ++-- .../typst-library/src/visualize/image/mod.rs | 20 +- .../typst-library/src/visualize/image/svg.rs | 26 +- crates/typst-syntax/Cargo.toml | 1 + crates/typst-syntax/src/lib.rs | 2 + crates/typst-syntax/src/lines.rs | 402 ++++++++++++++++++ crates/typst-syntax/src/source.rs | 326 +------------- tests/src/collect.rs | 98 ++++- tests/src/run.rs | 74 ++-- tests/src/world.rs | 21 +- tests/suite/loading/csv.typ | 4 +- tests/suite/loading/json.typ | 2 +- tests/suite/loading/read.typ | 2 +- tests/suite/loading/toml.typ | 2 +- tests/suite/loading/xml.typ | 2 +- tests/suite/loading/yaml.typ | 2 +- tests/suite/scripting/import.typ | 1 + tests/suite/visualize/image.typ | 4 +- 38 files changed, 1164 insertions(+), 571 deletions(-) create mode 100644 crates/typst-syntax/src/lines.rs diff --git a/Cargo.lock b/Cargo.lock index a9b3756a6f..b699d2450f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3039,6 +3039,7 @@ dependencies = [ "icu_provider_blob", "icu_segmenter", "kurbo", + "memchr", "rustybuzz", "smallvec", "ttf-parser", @@ -3112,6 +3113,7 @@ dependencies = [ "unicode-segmentation", "unscanny", "usvg", + "utf8_iter", "wasmi", "xmlwriter", ] @@ -3200,6 +3202,7 @@ dependencies = [ name = "typst-syntax" version = "0.13.1" dependencies = [ + "comemo", "ecow", "serde", "toml", diff --git a/Cargo.toml b/Cargo.toml index b4890e3c16..b548245fac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -135,6 +135,7 @@ unicode-segmentation = "1" unscanny = "0.1" ureq = { version = "2", default-features = false, features = ["native-tls", "gzip", "json"] } usvg = { version = "0.45", default-features = false, features = ["text"] } +utf8_iter = "1.0.4" walkdir = "2" wasmi = "0.40.0" web-sys = "0.3" diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index 4edb4c3239..207bb7d09e 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -16,7 +16,7 @@ use typst::diag::{ use typst::foundations::{Datetime, Smart}; use typst::html::HtmlDocument; use typst::layout::{Frame, Page, PageRanges, PagedDocument}; -use typst::syntax::{FileId, Source, Span}; +use typst::syntax::{FileId, Lines, Span}; use typst::WorldExt; use typst_pdf::{PdfOptions, PdfStandards, Timestamp}; @@ -696,7 +696,7 @@ fn label(world: &SystemWorld, span: Span) -> Option> { impl<'a> codespan_reporting::files::Files<'a> for SystemWorld { type FileId = FileId; type Name = String; - type Source = Source; + type Source = Lines; fn name(&'a self, id: FileId) -> CodespanResult { let vpath = id.vpath(); diff --git a/crates/typst-cli/src/timings.rs b/crates/typst-cli/src/timings.rs index 9f017dc121..3d10bbc672 100644 --- a/crates/typst-cli/src/timings.rs +++ b/crates/typst-cli/src/timings.rs @@ -85,6 +85,6 @@ fn resolve_span(world: &SystemWorld, span: Span) -> Option<(String, u32)> { let id = span.id()?; let source = world.source(id).ok()?; let range = source.range(span)?; - let line = source.byte_to_line(range.start)?; + let line = source.lines().byte_to_line(range.start)?; Some((format!("{id:?}"), line as u32 + 1)) } diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs index 2da03d4d5a..f63d34b63a 100644 --- a/crates/typst-cli/src/world.rs +++ b/crates/typst-cli/src/world.rs @@ -9,7 +9,7 @@ use ecow::{eco_format, EcoString}; use parking_lot::Mutex; use typst::diag::{FileError, FileResult}; use typst::foundations::{Bytes, Datetime, Dict, IntoValue}; -use typst::syntax::{FileId, Source, VirtualPath}; +use typst::syntax::{FileId, Lines, Source, VirtualPath}; use typst::text::{Font, FontBook}; use typst::utils::LazyHash; use typst::{Library, World}; @@ -181,10 +181,20 @@ impl SystemWorld { } } - /// Lookup a source file by id. + /// Lookup line metadata for a file by id. #[track_caller] - pub fn lookup(&self, id: FileId) -> Source { - self.source(id).expect("file id does not point to any source file") + pub fn lookup(&self, id: FileId) -> Lines { + self.slot(id, |slot| { + if let Some(source) = slot.source.get() { + let source = source.as_ref().expect("file is not valid"); + source.lines() + } else if let Some(bytes) = slot.file.get() { + let bytes = bytes.as_ref().expect("file is not valid"); + Lines::try_from(bytes).expect("file is not valid utf-8") + } else { + panic!("file id does not point to any source file"); + } + }) } } @@ -339,6 +349,11 @@ impl SlotCell { self.accessed = false; } + /// Gets the contents of the cell. + fn get(&self) -> Option<&FileResult> { + self.data.as_ref() + } + /// Gets the contents of the cell or initialize them. fn get_or_init( &mut self, diff --git a/crates/typst-layout/Cargo.toml b/crates/typst-layout/Cargo.toml index 438e09e438..cc355a3db8 100644 --- a/crates/typst-layout/Cargo.toml +++ b/crates/typst-layout/Cargo.toml @@ -30,6 +30,7 @@ icu_provider_adapters = { workspace = true } icu_provider_blob = { workspace = true } icu_segmenter = { workspace = true } kurbo = { workspace = true } +memchr = { workspace = true } rustybuzz = { workspace = true } smallvec = { workspace = true } ttf-parser = { workspace = true } diff --git a/crates/typst-layout/src/image.rs b/crates/typst-layout/src/image.rs index 8136a25a3d..a8f4a0c81d 100644 --- a/crates/typst-layout/src/image.rs +++ b/crates/typst-layout/src/image.rs @@ -1,6 +1,6 @@ use std::ffi::OsStr; -use typst_library::diag::{warning, At, SourceResult, StrResult}; +use typst_library::diag::{warning, At, LoadedWithin, SourceResult, StrResult}; use typst_library::engine::Engine; use typst_library::foundations::{Bytes, Derived, Packed, Smart, StyleChain}; use typst_library::introspection::Locator; @@ -27,17 +27,17 @@ pub fn layout_image( // Take the format that was explicitly defined, or parse the extension, // or try to detect the format. - let Derived { source, derived: data } = &elem.source; + let Derived { source, derived: loaded } = &elem.source; let format = match elem.format(styles) { Smart::Custom(v) => v, - Smart::Auto => determine_format(source, data).at(span)?, + Smart::Auto => determine_format(source, &loaded.data).at(span)?, }; // Warn the user if the image contains a foreign object. Not perfect // because the svg could also be encoded, but that's an edge case. if format == ImageFormat::Vector(VectorFormat::Svg) { let has_foreign_object = - data.as_str().is_ok_and(|s| s.contains(" ImageKind::Raster( RasterImage::new( - data.clone(), + loaded.data.clone(), format, elem.icc(styles).as_ref().map(|icc| icc.derived.clone()), ) @@ -61,11 +61,11 @@ pub fn layout_image( ), ImageFormat::Vector(VectorFormat::Svg) => ImageKind::Svg( SvgImage::with_fonts( - data.clone(), + loaded.data.clone(), engine.world, &families(styles).map(|f| f.as_str()).collect::>(), ) - .at(span)?, + .within(loaded)?, ), }; diff --git a/crates/typst-library/Cargo.toml b/crates/typst-library/Cargo.toml index b210637a80..f4b219882a 100644 --- a/crates/typst-library/Cargo.toml +++ b/crates/typst-library/Cargo.toml @@ -66,6 +66,7 @@ unicode-normalization = { workspace = true } unicode-segmentation = { workspace = true } unscanny = { workspace = true } usvg = { workspace = true } +utf8_iter = { workspace = true } wasmi = { workspace = true } xmlwriter = { workspace = true } diff --git a/crates/typst-library/src/diag.rs b/crates/typst-library/src/diag.rs index 49cbd02c62..41b92ed65a 100644 --- a/crates/typst-library/src/diag.rs +++ b/crates/typst-library/src/diag.rs @@ -1,17 +1,20 @@ //! Diagnostics. -use std::fmt::{self, Display, Formatter}; +use std::fmt::{self, Display, Formatter, Write as _}; use std::io; use std::path::{Path, PathBuf}; use std::str::Utf8Error; use std::string::FromUtf8Error; +use az::SaturatingAs; use comemo::Tracked; use ecow::{eco_vec, EcoVec}; use typst_syntax::package::{PackageSpec, PackageVersion}; -use typst_syntax::{Span, Spanned, SyntaxError}; +use typst_syntax::{Lines, Span, Spanned, SyntaxError}; +use utf8_iter::ErrorReportingUtf8Chars; use crate::engine::Engine; +use crate::loading::{LoadSource, Loaded}; use crate::{World, WorldExt}; /// Early-return with a [`StrResult`] or [`SourceResult`]. @@ -148,7 +151,7 @@ pub struct Warned { pub warnings: EcoVec, } -/// An error or warning in a source file. +/// An error or warning in a source or text file. /// /// The contained spans will only be detached if any of the input source files /// were detached. @@ -568,31 +571,287 @@ impl From for EcoString { } } +/// A result type with a data-loading-related error. +pub type LoadResult = Result; + +/// A call site independent error that occurred during data loading. This avoids +/// polluting the memoization with [`Span`]s and [`FileId`]s from source files. +/// Can be turned into a [`SourceDiagnostic`] using the [`LoadedWithin::within`] +/// method available on [`LoadResult`]. +/// +/// [`FileId`]: typst_syntax::FileId +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct LoadError { + /// The position in the file at which the error occured. + pos: ReportPos, + /// Must contain a message formatted like this: `"failed to do thing (cause)"`. + message: EcoString, +} + +impl LoadError { + /// Creates a new error from a position in a file, a base message + /// (e.g. `failed to parse JSON`) and a concrete error (e.g. `invalid + /// number`) + pub fn new( + pos: impl Into, + message: impl std::fmt::Display, + error: impl std::fmt::Display, + ) -> Self { + Self { + pos: pos.into(), + message: eco_format!("{message} ({error})"), + } + } +} + +impl From for LoadError { + fn from(err: Utf8Error) -> Self { + let start = err.valid_up_to(); + let end = start + err.error_len().unwrap_or(0); + LoadError::new( + start..end, + "failed to convert to string", + "file is not valid utf-8", + ) + } +} + +/// Convert a [`LoadResult`] to a [`SourceResult`] by adding the [`Loaded`] +/// context. +pub trait LoadedWithin { + /// Report an error, possibly in an external file. + fn within(self, loaded: &Loaded) -> SourceResult; +} + +impl LoadedWithin for Result +where + E: Into, +{ + fn within(self, loaded: &Loaded) -> SourceResult { + self.map_err(|err| { + let LoadError { pos, message } = err.into(); + load_err_in_text(loaded, pos, message) + }) + } +} + +/// Report an error, possibly in an external file. This will delegate to +/// [`load_err_in_invalid_text`] if the data isn't valid utf-8. +fn load_err_in_text( + loaded: &Loaded, + pos: impl Into, + mut message: EcoString, +) -> EcoVec { + let pos = pos.into(); + // This also does utf-8 validation. Only report an error in an external + // file if it is human readable (valid utf-8), otherwise fall back to + // `load_err_in_invalid_text`. + let lines = Lines::try_from(&loaded.data); + match (loaded.source.v, lines) { + (LoadSource::Path(file_id), Ok(lines)) => { + if let Some(range) = pos.range(&lines) { + let span = Span::from_range(file_id, range); + return eco_vec![SourceDiagnostic::error(span, message)]; + } + + // Either `ReportPos::None` was provided, or resolving the range + // from the line/column failed. If present report the possibly + // wrong line/column in the error message anyway. + let span = Span::from_range(file_id, 0..loaded.data.len()); + if let Some(pair) = pos.line_col(&lines) { + message.pop(); + let (line, col) = pair.numbers(); + write!(&mut message, " at {line}:{col})").ok(); + } + eco_vec![SourceDiagnostic::error(span, message)] + } + (LoadSource::Bytes, Ok(lines)) => { + if let Some(pair) = pos.line_col(&lines) { + message.pop(); + let (line, col) = pair.numbers(); + write!(&mut message, " at {line}:{col})").ok(); + } + eco_vec![SourceDiagnostic::error(loaded.source.span, message)] + } + _ => load_err_in_invalid_text(loaded, pos, message), + } +} + +/// Report an error (possibly from an external file) that isn't valid utf-8. +fn load_err_in_invalid_text( + loaded: &Loaded, + pos: impl Into, + mut message: EcoString, +) -> EcoVec { + let line_col = pos.into().try_line_col(&loaded.data).map(|p| p.numbers()); + match (loaded.source.v, line_col) { + (LoadSource::Path(file), _) => { + message.pop(); + if let Some(package) = file.package() { + write!( + &mut message, + " in {package}{}", + file.vpath().as_rooted_path().display() + ) + .ok(); + } else { + write!(&mut message, " in {}", file.vpath().as_rootless_path().display()) + .ok(); + }; + if let Some((line, col)) = line_col { + write!(&mut message, ":{line}:{col}").ok(); + } + message.push(')'); + } + (LoadSource::Bytes, Some((line, col))) => { + message.pop(); + write!(&mut message, " at {line}:{col})").ok(); + } + (LoadSource::Bytes, None) => (), + } + eco_vec![SourceDiagnostic::error(loaded.source.span, message)] +} + +/// A position at which an error was reported. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub enum ReportPos { + /// Contains a range, and a line/column pair. + Full(std::ops::Range, LineCol), + /// Contains a range. + Range(std::ops::Range), + /// Contains a line/column pair. + LineCol(LineCol), + #[default] + None, +} + +impl From> for ReportPos { + fn from(value: std::ops::Range) -> Self { + Self::Range(value.start.saturating_as()..value.end.saturating_as()) + } +} + +impl From for ReportPos { + fn from(value: LineCol) -> Self { + Self::LineCol(value) + } +} + +impl ReportPos { + /// Creates a position from a pre-existing range and line-column pair. + pub fn full(range: std::ops::Range, pair: LineCol) -> Self { + let range = range.start.saturating_as()..range.end.saturating_as(); + Self::Full(range, pair) + } + + /// Tries to determine the byte range for this position. + fn range(&self, lines: &Lines) -> Option> { + match self { + ReportPos::Full(range, _) => Some(range.start as usize..range.end as usize), + ReportPos::Range(range) => Some(range.start as usize..range.end as usize), + &ReportPos::LineCol(pair) => { + let i = + lines.line_column_to_byte(pair.line as usize, pair.col as usize)?; + Some(i..i) + } + ReportPos::None => None, + } + } + + /// Tries to determine the line/column for this position. + fn line_col(&self, lines: &Lines) -> Option { + match self { + &ReportPos::Full(_, pair) => Some(pair), + ReportPos::Range(range) => { + let (line, col) = lines.byte_to_line_column(range.start as usize)?; + Some(LineCol::zero_based(line, col)) + } + &ReportPos::LineCol(pair) => Some(pair), + ReportPos::None => None, + } + } + + /// Either gets the line/column pair, or tries to compute it from possibly + /// invalid utf-8 data. + fn try_line_col(&self, bytes: &[u8]) -> Option { + match self { + &ReportPos::Full(_, pair) => Some(pair), + ReportPos::Range(range) => { + LineCol::try_from_byte_pos(range.start as usize, bytes) + } + &ReportPos::LineCol(pair) => Some(pair), + ReportPos::None => None, + } + } +} + +/// A line/column pair. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LineCol { + /// The 0-based line. + line: u32, + /// The 0-based column. + col: u32, +} + +impl LineCol { + /// Constructs the line/column pair from 0-based indices. + pub fn zero_based(line: usize, col: usize) -> Self { + Self { + line: line.saturating_as(), + col: col.saturating_as(), + } + } + + /// Constructs the line/column pair from 1-based numbers. + pub fn one_based(line: usize, col: usize) -> Self { + Self::zero_based(line.saturating_sub(1), col.saturating_sub(1)) + } + + /// Try to compute a line/column pair from possibly invalid utf-8 data. + pub fn try_from_byte_pos(pos: usize, bytes: &[u8]) -> Option { + let bytes = &bytes[..pos]; + let mut line = 0; + #[allow(clippy::double_ended_iterator_last)] + let line_start = memchr::memchr_iter(b'\n', bytes) + .inspect(|_| line += 1) + .last() + .map(|i| i + 1) + .unwrap_or(bytes.len()); + + let col = ErrorReportingUtf8Chars::new(&bytes[line_start..]).count(); + Some(LineCol::zero_based(line, col)) + } + + /// Returns the 0-based line/column indices. + pub fn indices(&self) -> (usize, usize) { + (self.line as usize, self.col as usize) + } + + /// Returns the 1-based line/column numbers. + pub fn numbers(&self) -> (usize, usize) { + (self.line as usize + 1, self.col as usize + 1) + } +} + /// Format a user-facing error message for an XML-like file format. -pub fn format_xml_like_error(format: &str, error: roxmltree::Error) -> EcoString { - match error { - roxmltree::Error::UnexpectedCloseTag(expected, actual, pos) => { - eco_format!( - "failed to parse {format} (found closing tag '{actual}' \ - instead of '{expected}' in line {})", - pos.row - ) +pub fn format_xml_like_error(format: &str, error: roxmltree::Error) -> LoadError { + let pos = LineCol::one_based(error.pos().row as usize, error.pos().col as usize); + let message = match error { + roxmltree::Error::UnexpectedCloseTag(expected, actual, _) => { + eco_format!("failed to parse {format} (found closing tag '{actual}' instead of '{expected}')") } - roxmltree::Error::UnknownEntityReference(entity, pos) => { - eco_format!( - "failed to parse {format} (unknown entity '{entity}' in line {})", - pos.row - ) + roxmltree::Error::UnknownEntityReference(entity, _) => { + eco_format!("failed to parse {format} (unknown entity '{entity}')") } - roxmltree::Error::DuplicatedAttribute(attr, pos) => { - eco_format!( - "failed to parse {format} (duplicate attribute '{attr}' in line {})", - pos.row - ) + roxmltree::Error::DuplicatedAttribute(attr, _) => { + eco_format!("failed to parse {format} (duplicate attribute '{attr}')") } roxmltree::Error::NoRootNode => { eco_format!("failed to parse {format} (missing root node)") } err => eco_format!("failed to parse {format} ({err})"), - } + }; + + LoadError { pos: pos.into(), message } } diff --git a/crates/typst-library/src/foundations/bytes.rs b/crates/typst-library/src/foundations/bytes.rs index d633c99ad1..180dcdad59 100644 --- a/crates/typst-library/src/foundations/bytes.rs +++ b/crates/typst-library/src/foundations/bytes.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use ecow::{eco_format, EcoString}; use serde::{Serialize, Serializer}; +use typst_syntax::Lines; use typst_utils::LazyHash; use crate::diag::{bail, StrResult}; @@ -286,6 +287,16 @@ impl Serialize for Bytes { } } +impl TryFrom<&Bytes> for Lines { + type Error = Utf8Error; + + #[comemo::memoize] + fn try_from(value: &Bytes) -> Result, Utf8Error> { + let text = value.as_str()?; + Ok(Lines::new(text.to_string())) + } +} + /// Any type that can back a byte buffer. trait Bytelike: Send + Sync { fn as_bytes(&self) -> &[u8]; diff --git a/crates/typst-library/src/foundations/plugin.rs b/crates/typst-library/src/foundations/plugin.rs index 31f8cd7327..a04443bf41 100644 --- a/crates/typst-library/src/foundations/plugin.rs +++ b/crates/typst-library/src/foundations/plugin.rs @@ -151,8 +151,8 @@ pub fn plugin( /// A [path]($syntax/#paths) to a WebAssembly file or raw WebAssembly bytes. source: Spanned, ) -> SourceResult { - let data = source.load(engine.world)?; - Plugin::module(data).at(source.span) + let loaded = source.load(engine.world)?; + Plugin::module(loaded.data).at(source.span) } #[scope] diff --git a/crates/typst-library/src/loading/cbor.rs b/crates/typst-library/src/loading/cbor.rs index aa14c5c776..d95f738446 100644 --- a/crates/typst-library/src/loading/cbor.rs +++ b/crates/typst-library/src/loading/cbor.rs @@ -23,8 +23,8 @@ pub fn cbor( /// A [path]($syntax/#paths) to a CBOR file or raw CBOR bytes. source: Spanned, ) -> SourceResult { - let data = source.load(engine.world)?; - ciborium::from_reader(data.as_slice()) + let loaded = source.load(engine.world)?; + ciborium::from_reader(loaded.data.as_slice()) .map_err(|err| eco_format!("failed to parse CBOR ({err})")) .at(source.span) } diff --git a/crates/typst-library/src/loading/csv.rs b/crates/typst-library/src/loading/csv.rs index 6afb5baeb0..d5b54a06cd 100644 --- a/crates/typst-library/src/loading/csv.rs +++ b/crates/typst-library/src/loading/csv.rs @@ -1,7 +1,7 @@ -use ecow::{eco_format, EcoString}; +use az::SaturatingAs; use typst_syntax::Spanned; -use crate::diag::{bail, At, SourceResult}; +use crate::diag::{bail, LineCol, LoadError, LoadedWithin, ReportPos, SourceResult}; use crate::engine::Engine; use crate::foundations::{cast, func, scope, Array, Dict, IntoValue, Type, Value}; use crate::loading::{DataSource, Load, Readable}; @@ -44,7 +44,7 @@ pub fn csv( #[default(RowType::Array)] row_type: RowType, ) -> SourceResult { - let data = source.load(engine.world)?; + let loaded = source.load(engine.world)?; let mut builder = ::csv::ReaderBuilder::new(); let has_headers = row_type == RowType::Dict; @@ -53,7 +53,7 @@ pub fn csv( // Counting lines from 1 by default. let mut line_offset: usize = 1; - let mut reader = builder.from_reader(data.as_slice()); + let mut reader = builder.from_reader(loaded.data.as_slice()); let mut headers: Option<::csv::StringRecord> = None; if has_headers { @@ -62,9 +62,9 @@ pub fn csv( headers = Some( reader .headers() + .cloned() .map_err(|err| format_csv_error(err, 1)) - .at(source.span)? - .clone(), + .within(&loaded)?, ); } @@ -74,7 +74,7 @@ pub fn csv( // incorrect with `has_headers` set to `false`. See issue: // https://github.com/BurntSushi/rust-csv/issues/184 let line = line + line_offset; - let row = result.map_err(|err| format_csv_error(err, line)).at(source.span)?; + let row = result.map_err(|err| format_csv_error(err, line)).within(&loaded)?; let item = if let Some(headers) = &headers { let mut dict = Dict::new(); for (field, value) in headers.iter().zip(&row) { @@ -164,15 +164,23 @@ cast! { } /// Format the user-facing CSV error message. -fn format_csv_error(err: ::csv::Error, line: usize) -> EcoString { +fn format_csv_error(err: ::csv::Error, line: usize) -> LoadError { + let msg = "failed to parse CSV"; + let pos = (err.kind().position()) + .map(|pos| { + let start = pos.byte().saturating_as(); + ReportPos::from(start..start) + }) + .unwrap_or(LineCol::one_based(line, 1).into()); match err.kind() { - ::csv::ErrorKind::Utf8 { .. } => "file is not valid utf-8".into(), + ::csv::ErrorKind::Utf8 { .. } => { + LoadError::new(pos, msg, "file is not valid utf-8") + } ::csv::ErrorKind::UnequalLengths { expected_len, len, .. } => { - eco_format!( - "failed to parse CSV (found {len} instead of \ - {expected_len} fields in line {line})" - ) + let err = + format!("found {len} instead of {expected_len} fields in line {line}"); + LoadError::new(pos, msg, err) } - _ => eco_format!("failed to parse CSV ({err})"), + _ => LoadError::new(pos, "failed to parse CSV", err), } } diff --git a/crates/typst-library/src/loading/json.rs b/crates/typst-library/src/loading/json.rs index aa908cca4c..7d0732ba04 100644 --- a/crates/typst-library/src/loading/json.rs +++ b/crates/typst-library/src/loading/json.rs @@ -1,7 +1,7 @@ use ecow::eco_format; use typst_syntax::Spanned; -use crate::diag::{At, SourceResult}; +use crate::diag::{At, LineCol, LoadError, LoadedWithin, SourceResult}; use crate::engine::Engine; use crate::foundations::{func, scope, Str, Value}; use crate::loading::{DataSource, Load, Readable}; @@ -54,10 +54,13 @@ pub fn json( /// A [path]($syntax/#paths) to a JSON file or raw JSON bytes. source: Spanned, ) -> SourceResult { - let data = source.load(engine.world)?; - serde_json::from_slice(data.as_slice()) - .map_err(|err| eco_format!("failed to parse JSON ({err})")) - .at(source.span) + let loaded = source.load(engine.world)?; + serde_json::from_slice(loaded.data.as_slice()) + .map_err(|err| { + let pos = LineCol::one_based(err.line(), err.column()); + LoadError::new(pos, "failed to parse JSON", err) + }) + .within(&loaded) } #[scope] diff --git a/crates/typst-library/src/loading/mod.rs b/crates/typst-library/src/loading/mod.rs index c57e02888c..67f4be8341 100644 --- a/crates/typst-library/src/loading/mod.rs +++ b/crates/typst-library/src/loading/mod.rs @@ -17,7 +17,7 @@ mod yaml_; use comemo::Tracked; use ecow::EcoString; -use typst_syntax::Spanned; +use typst_syntax::{FileId, Spanned}; pub use self::cbor_::*; pub use self::csv_::*; @@ -74,39 +74,44 @@ pub trait Load { } impl Load for Spanned { - type Output = Bytes; + type Output = Loaded; - fn load(&self, world: Tracked) -> SourceResult { + fn load(&self, world: Tracked) -> SourceResult { self.as_ref().load(world) } } impl Load for Spanned<&DataSource> { - type Output = Bytes; + type Output = Loaded; - fn load(&self, world: Tracked) -> SourceResult { + fn load(&self, world: Tracked) -> SourceResult { match &self.v { DataSource::Path(path) => { let file_id = self.span.resolve_path(path).at(self.span)?; - world.file(file_id).at(self.span) + let data = world.file(file_id).at(self.span)?; + let source = Spanned::new(LoadSource::Path(file_id), self.span); + Ok(Loaded::new(source, data)) + } + DataSource::Bytes(data) => { + let source = Spanned::new(LoadSource::Bytes, self.span); + Ok(Loaded::new(source, data.clone())) } - DataSource::Bytes(bytes) => Ok(bytes.clone()), } } } impl Load for Spanned> { - type Output = Vec; + type Output = Vec; - fn load(&self, world: Tracked) -> SourceResult> { + fn load(&self, world: Tracked) -> SourceResult { self.as_ref().load(world) } } impl Load for Spanned<&OneOrMultiple> { - type Output = Vec; + type Output = Vec; - fn load(&self, world: Tracked) -> SourceResult> { + fn load(&self, world: Tracked) -> SourceResult { self.v .0 .iter() @@ -115,6 +120,28 @@ impl Load for Spanned<&OneOrMultiple> { } } +/// Data loaded from a [`DataSource`]. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Loaded { + /// Details about where `data` was loaded from. + pub source: Spanned, + /// The loaded data. + pub data: Bytes, +} + +impl Loaded { + pub fn new(source: Spanned, bytes: Bytes) -> Self { + Self { source, data: bytes } + } +} + +/// A loaded [`DataSource`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum LoadSource { + Path(FileId), + Bytes, +} + /// A value that can be read from a file. #[derive(Debug, Clone, PartialEq, Hash)] pub enum Readable { diff --git a/crates/typst-library/src/loading/read.rs b/crates/typst-library/src/loading/read.rs index 32dadc7990..91e6e4366e 100644 --- a/crates/typst-library/src/loading/read.rs +++ b/crates/typst-library/src/loading/read.rs @@ -1,11 +1,10 @@ use ecow::EcoString; use typst_syntax::Spanned; -use crate::diag::{At, FileError, SourceResult}; +use crate::diag::{LoadedWithin, SourceResult}; use crate::engine::Engine; use crate::foundations::{func, Cast}; -use crate::loading::Readable; -use crate::World; +use crate::loading::{DataSource, Load, Readable}; /// Reads plain text or data from a file. /// @@ -36,14 +35,10 @@ pub fn read( #[default(Some(Encoding::Utf8))] encoding: Option, ) -> SourceResult { - let Spanned { v: path, span } = path; - let id = span.resolve_path(&path).at(span)?; - let data = engine.world.file(id).at(span)?; + let loaded = path.map(DataSource::Path).load(engine.world)?; Ok(match encoding { - None => Readable::Bytes(data), - Some(Encoding::Utf8) => { - Readable::Str(data.to_str().map_err(FileError::from).at(span)?) - } + None => Readable::Bytes(loaded.data), + Some(Encoding::Utf8) => Readable::Str(loaded.data.to_str().within(&loaded)?), }) } diff --git a/crates/typst-library/src/loading/toml.rs b/crates/typst-library/src/loading/toml.rs index f04b2e746d..a4252feca3 100644 --- a/crates/typst-library/src/loading/toml.rs +++ b/crates/typst-library/src/loading/toml.rs @@ -1,7 +1,7 @@ -use ecow::{eco_format, EcoString}; -use typst_syntax::{is_newline, Spanned}; +use ecow::eco_format; +use typst_syntax::Spanned; -use crate::diag::{At, FileError, SourceResult}; +use crate::diag::{At, LoadError, LoadedWithin, ReportPos, SourceResult}; use crate::engine::Engine; use crate::foundations::{func, scope, Str, Value}; use crate::loading::{DataSource, Load, Readable}; @@ -32,11 +32,9 @@ pub fn toml( /// A [path]($syntax/#paths) to a TOML file or raw TOML bytes. source: Spanned, ) -> SourceResult { - let data = source.load(engine.world)?; - let raw = data.as_str().map_err(FileError::from).at(source.span)?; - ::toml::from_str(raw) - .map_err(|err| format_toml_error(err, raw)) - .at(source.span) + let loaded = source.load(engine.world)?; + let raw = loaded.data.as_str().within(&loaded)?; + ::toml::from_str(raw).map_err(format_toml_error).within(&loaded) } #[scope] @@ -71,15 +69,7 @@ impl toml { } /// Format the user-facing TOML error message. -fn format_toml_error(error: ::toml::de::Error, raw: &str) -> EcoString { - if let Some(head) = error.span().and_then(|range| raw.get(..range.start)) { - let line = head.lines().count(); - let column = 1 + head.chars().rev().take_while(|&c| !is_newline(c)).count(); - eco_format!( - "failed to parse TOML ({} at line {line} column {column})", - error.message(), - ) - } else { - eco_format!("failed to parse TOML ({})", error.message()) - } +fn format_toml_error(error: ::toml::de::Error) -> LoadError { + let pos = error.span().map(ReportPos::from).unwrap_or_default(); + LoadError::new(pos, "failed to parse TOML", error.message()) } diff --git a/crates/typst-library/src/loading/xml.rs b/crates/typst-library/src/loading/xml.rs index e76c4e9cfa..0023c5df56 100644 --- a/crates/typst-library/src/loading/xml.rs +++ b/crates/typst-library/src/loading/xml.rs @@ -1,8 +1,7 @@ -use ecow::EcoString; use roxmltree::ParsingOptions; use typst_syntax::Spanned; -use crate::diag::{format_xml_like_error, At, FileError, SourceResult}; +use crate::diag::{format_xml_like_error, LoadError, LoadedWithin, SourceResult}; use crate::engine::Engine; use crate::foundations::{dict, func, scope, Array, Dict, IntoValue, Str, Value}; use crate::loading::{DataSource, Load, Readable}; @@ -61,14 +60,14 @@ pub fn xml( /// A [path]($syntax/#paths) to an XML file or raw XML bytes. source: Spanned, ) -> SourceResult { - let data = source.load(engine.world)?; - let text = data.as_str().map_err(FileError::from).at(source.span)?; + let loaded = source.load(engine.world)?; + let text = loaded.data.as_str().within(&loaded)?; let document = roxmltree::Document::parse_with_options( text, ParsingOptions { allow_dtd: true, ..Default::default() }, ) .map_err(format_xml_error) - .at(source.span)?; + .within(&loaded)?; Ok(convert_xml(document.root())) } @@ -111,6 +110,6 @@ fn convert_xml(node: roxmltree::Node) -> Value { } /// Format the user-facing XML error message. -fn format_xml_error(error: roxmltree::Error) -> EcoString { +fn format_xml_error(error: roxmltree::Error) -> LoadError { format_xml_like_error("XML", error) } diff --git a/crates/typst-library/src/loading/yaml.rs b/crates/typst-library/src/loading/yaml.rs index 3f48113e8e..0edf1f9013 100644 --- a/crates/typst-library/src/loading/yaml.rs +++ b/crates/typst-library/src/loading/yaml.rs @@ -1,7 +1,7 @@ use ecow::eco_format; use typst_syntax::Spanned; -use crate::diag::{At, SourceResult}; +use crate::diag::{At, LineCol, LoadError, LoadedWithin, ReportPos, SourceResult}; use crate::engine::Engine; use crate::foundations::{func, scope, Str, Value}; use crate::loading::{DataSource, Load, Readable}; @@ -44,10 +44,10 @@ pub fn yaml( /// A [path]($syntax/#paths) to a YAML file or raw YAML bytes. source: Spanned, ) -> SourceResult { - let data = source.load(engine.world)?; - serde_yaml::from_slice(data.as_slice()) - .map_err(|err| eco_format!("failed to parse YAML ({err})")) - .at(source.span) + let loaded = source.load(engine.world)?; + serde_yaml::from_slice(loaded.data.as_slice()) + .map_err(format_yaml_error) + .within(&loaded) } #[scope] @@ -76,3 +76,16 @@ impl yaml { .at(span) } } + +/// Format the user-facing YAML error message. +pub fn format_yaml_error(error: serde_yaml::Error) -> LoadError { + let pos = error + .location() + .map(|loc| { + let line_col = LineCol::one_based(loc.line(), loc.column()); + let range = loc.index()..loc.index(); + ReportPos::full(range, line_col) + }) + .unwrap_or_default(); + LoadError::new(pos, "failed to parse YAML", error) +} diff --git a/crates/typst-library/src/model/bibliography.rs b/crates/typst-library/src/model/bibliography.rs index 51e3b03b06..1143565754 100644 --- a/crates/typst-library/src/model/bibliography.rs +++ b/crates/typst-library/src/model/bibliography.rs @@ -19,7 +19,10 @@ use smallvec::{smallvec, SmallVec}; use typst_syntax::{Span, Spanned}; use typst_utils::{Get, ManuallyHash, NonZeroExt, PicoStr}; -use crate::diag::{bail, error, At, FileError, HintedStrResult, SourceResult, StrResult}; +use crate::diag::{ + bail, error, At, HintedStrResult, LoadError, LoadResult, LoadedWithin, ReportPos, + SourceResult, StrResult, +}; use crate::engine::{Engine, Sink}; use crate::foundations::{ elem, Bytes, CastInfo, Content, Derived, FromValue, IntoValue, Label, NativeElement, @@ -31,7 +34,7 @@ use crate::layout::{ BlockBody, BlockElem, Em, GridCell, GridChild, GridElem, GridItem, HElem, PadElem, Sides, Sizing, TrackSizings, }; -use crate::loading::{DataSource, Load}; +use crate::loading::{format_yaml_error, DataSource, Load, LoadSource, Loaded}; use crate::model::{ CitationForm, CiteGroup, Destination, FootnoteElem, HeadingElem, LinkElem, ParElem, Url, @@ -294,24 +297,21 @@ impl Bibliography { world: Tracked, sources: Spanned>, ) -> SourceResult, Self>> { - let data = sources.load(world)?; - let bibliography = Self::decode(&sources.v, &data).at(sources.span)?; + let loaded = sources.load(world)?; + let bibliography = Self::decode(&loaded)?; Ok(Derived::new(sources.v, bibliography)) } /// Decode a bibliography from loaded data sources. #[comemo::memoize] #[typst_macros::time(name = "load bibliography")] - fn decode( - sources: &OneOrMultiple, - data: &[Bytes], - ) -> StrResult { + fn decode(data: &[Loaded]) -> SourceResult { let mut map = IndexMap::new(); let mut duplicates = Vec::::new(); // We might have multiple bib/yaml files - for (source, data) in sources.0.iter().zip(data) { - let library = decode_library(source, data)?; + for d in data.iter() { + let library = decode_library(d)?; for entry in library { match map.entry(Label::new(PicoStr::intern(entry.key()))) { indexmap::map::Entry::Vacant(vacant) => { @@ -325,7 +325,11 @@ impl Bibliography { } if !duplicates.is_empty() { - bail!("duplicate bibliography keys: {}", duplicates.join(", ")); + // TODO: Store spans of entries for duplicate key error messages. + // Requires hayagriva entries to store their location, which should + // be fine, since they are 1kb anyway. + let span = data.first().unwrap().source.span; + bail!(span, "duplicate bibliography keys: {}", duplicates.join(", ")); } Ok(Bibliography(Arc::new(ManuallyHash::new(map, typst_utils::hash128(data))))) @@ -351,36 +355,47 @@ impl Debug for Bibliography { } /// Decode on library from one data source. -fn decode_library(source: &DataSource, data: &Bytes) -> StrResult { - let src = data.as_str().map_err(FileError::from)?; +fn decode_library(loaded: &Loaded) -> SourceResult { + let data = loaded.data.as_str().within(loaded)?; - if let DataSource::Path(path) = source { + if let LoadSource::Path(file_id) = loaded.source.v { // If we got a path, use the extension to determine whether it is // YAML or BibLaTeX. - let ext = Path::new(path.as_str()) + let ext = file_id + .vpath() + .as_rooted_path() .extension() .and_then(OsStr::to_str) .unwrap_or_default(); match ext.to_lowercase().as_str() { - "yml" | "yaml" => hayagriva::io::from_yaml_str(src) - .map_err(|err| eco_format!("failed to parse YAML ({err})")), - "bib" => hayagriva::io::from_biblatex_str(src) - .map_err(|errors| format_biblatex_error(src, Some(path), errors)), - _ => bail!("unknown bibliography format (must be .yml/.yaml or .bib)"), + "yml" | "yaml" => hayagriva::io::from_yaml_str(data) + .map_err(format_yaml_error) + .within(loaded), + "bib" => hayagriva::io::from_biblatex_str(data) + .map_err(format_biblatex_error) + .within(loaded), + _ => bail!( + loaded.source.span, + "unknown bibliography format (must be .yml/.yaml or .bib)" + ), } } else { // If we just got bytes, we need to guess. If it can be decoded as // hayagriva YAML, we'll use that. - let haya_err = match hayagriva::io::from_yaml_str(src) { + let haya_err = match hayagriva::io::from_yaml_str(data) { Ok(library) => return Ok(library), Err(err) => err, }; // If it can be decoded as BibLaTeX, we use that isntead. - let bib_errs = match hayagriva::io::from_biblatex_str(src) { - Ok(library) => return Ok(library), - Err(err) => err, + let bib_errs = match hayagriva::io::from_biblatex_str(data) { + // If the file is almost valid yaml, but contains no `@` character + // it will be successfully parsed as an empty BibLaTeX library, + // since BibLaTeX does support arbitrary text outside of entries. + Ok(library) if !library.is_empty() => return Ok(library), + Ok(_) => None, + Err(err) => Some(err), }; // If neither decoded correctly, check whether `:` or `{` appears @@ -388,7 +403,7 @@ fn decode_library(source: &DataSource, data: &Bytes) -> StrResult { // and emit the more appropriate error. let mut yaml = 0; let mut biblatex = 0; - for c in src.chars() { + for c in data.chars() { match c { ':' => yaml += 1, '{' => biblatex += 1, @@ -396,37 +411,33 @@ fn decode_library(source: &DataSource, data: &Bytes) -> StrResult { } } - if yaml > biblatex { - bail!("failed to parse YAML ({haya_err})") - } else { - Err(format_biblatex_error(src, None, bib_errs)) + match bib_errs { + Some(bib_errs) if biblatex >= yaml => { + Err(format_biblatex_error(bib_errs)).within(loaded) + } + _ => Err(format_yaml_error(haya_err)).within(loaded), } } } /// Format a BibLaTeX loading error. -fn format_biblatex_error( - src: &str, - path: Option<&str>, - errors: Vec, -) -> EcoString { - let Some(error) = errors.first() else { - return match path { - Some(path) => eco_format!("failed to parse BibLaTeX file ({path})"), - None => eco_format!("failed to parse BibLaTeX"), - }; +fn format_biblatex_error(errors: Vec) -> LoadError { + // TODO: return multiple errors? + let Some(error) = errors.into_iter().next() else { + // TODO: can this even happen, should we just unwrap? + return LoadError::new( + ReportPos::None, + "failed to parse BibLaTeX", + "something went wrong", + ); }; - let (span, msg) = match error { - BibLaTeXError::Parse(error) => (&error.span, error.kind.to_string()), - BibLaTeXError::Type(error) => (&error.span, error.kind.to_string()), + let (range, msg) = match error { + BibLaTeXError::Parse(error) => (error.span, error.kind.to_string()), + BibLaTeXError::Type(error) => (error.span, error.kind.to_string()), }; - let line = src.get(..span.start).unwrap_or_default().lines().count(); - match path { - Some(path) => eco_format!("failed to parse BibLaTeX file ({path}:{line}: {msg})"), - None => eco_format!("failed to parse BibLaTeX ({line}: {msg})"), - } + LoadError::new(range, "failed to parse BibLaTeX", msg) } /// A loaded CSL style. @@ -442,8 +453,8 @@ impl CslStyle { let style = match &source { CslSource::Named(style) => Self::from_archived(*style), CslSource::Normal(source) => { - let data = Spanned::new(source, span).load(world)?; - Self::from_data(data).at(span)? + let loaded = Spanned::new(source, span).load(world)?; + Self::from_data(&loaded.data).within(&loaded)? } }; Ok(Derived::new(source, style)) @@ -464,16 +475,18 @@ impl CslStyle { /// Load a CSL style from file contents. #[comemo::memoize] - pub fn from_data(data: Bytes) -> StrResult { - let text = data.as_str().map_err(FileError::from)?; + pub fn from_data(bytes: &Bytes) -> LoadResult { + let text = bytes.as_str()?; citationberg::IndependentStyle::from_xml(text) .map(|style| { Self(Arc::new(ManuallyHash::new( style, - typst_utils::hash128(&(TypeId::of::(), data)), + typst_utils::hash128(&(TypeId::of::(), bytes)), ))) }) - .map_err(|err| eco_format!("failed to load CSL style ({err})")) + .map_err(|err| { + LoadError::new(ReportPos::None, "failed to load CSL style", err) + }) } /// Get the underlying independent style. diff --git a/crates/typst-library/src/text/raw.rs b/crates/typst-library/src/text/raw.rs index d5c07424d4..f2485e16be 100644 --- a/crates/typst-library/src/text/raw.rs +++ b/crates/typst-library/src/text/raw.rs @@ -3,15 +3,17 @@ use std::ops::Range; use std::sync::{Arc, LazyLock}; use comemo::Tracked; -use ecow::{eco_format, EcoString, EcoVec}; -use syntect::highlighting as synt; -use syntect::parsing::{SyntaxDefinition, SyntaxSet, SyntaxSetBuilder}; +use ecow::{EcoString, EcoVec}; +use syntect::highlighting::{self as synt}; +use syntect::parsing::{ParseSyntaxError, SyntaxDefinition, SyntaxSet, SyntaxSetBuilder}; use typst_syntax::{split_newlines, LinkedNode, Span, Spanned}; use typst_utils::ManuallyHash; use unicode_segmentation::UnicodeSegmentation; use super::Lang; -use crate::diag::{At, FileError, SourceResult, StrResult}; +use crate::diag::{ + LineCol, LoadError, LoadResult, LoadedWithin, ReportPos, SourceResult, +}; use crate::engine::Engine; use crate::foundations::{ cast, elem, scope, Bytes, Content, Derived, NativeElement, OneOrMultiple, Packed, @@ -539,40 +541,29 @@ impl RawSyntax { world: Tracked, sources: Spanned>, ) -> SourceResult, Vec>> { - let data = sources.load(world)?; - let list = sources - .v - .0 + let loaded = sources.load(world)?; + let list = loaded .iter() - .zip(&data) - .map(|(source, data)| Self::decode(source, data)) - .collect::>() - .at(sources.span)?; + .map(|data| Self::decode(&data.data).within(data)) + .collect::>()?; Ok(Derived::new(sources.v, list)) } /// Decode a syntax from a loaded source. #[comemo::memoize] #[typst_macros::time(name = "load syntaxes")] - fn decode(source: &DataSource, data: &Bytes) -> StrResult { - let src = data.as_str().map_err(FileError::from)?; - let syntax = SyntaxDefinition::load_from_str(src, false, None).map_err( - |err| match source { - DataSource::Path(path) => { - eco_format!("failed to parse syntax file `{path}` ({err})") - } - DataSource::Bytes(_) => { - eco_format!("failed to parse syntax ({err})") - } - }, - )?; + fn decode(bytes: &Bytes) -> LoadResult { + let str = bytes.as_str()?; + + let syntax = SyntaxDefinition::load_from_str(str, false, None) + .map_err(format_syntax_error)?; let mut builder = SyntaxSetBuilder::new(); builder.add(syntax); Ok(RawSyntax(Arc::new(ManuallyHash::new( builder.build(), - typst_utils::hash128(data), + typst_utils::hash128(bytes), )))) } @@ -582,6 +573,24 @@ impl RawSyntax { } } +fn format_syntax_error(error: ParseSyntaxError) -> LoadError { + let pos = syntax_error_pos(&error); + LoadError::new(pos, "failed to parse syntax", error) +} + +fn syntax_error_pos(error: &ParseSyntaxError) -> ReportPos { + match error { + ParseSyntaxError::InvalidYaml(scan_error) => { + let m = scan_error.marker(); + ReportPos::full( + m.index()..m.index(), + LineCol::one_based(m.line(), m.col() + 1), + ) + } + _ => ReportPos::None, + } +} + /// A loaded syntect theme. #[derive(Debug, Clone, PartialEq, Hash)] pub struct RawTheme(Arc>); @@ -592,18 +601,18 @@ impl RawTheme { world: Tracked, source: Spanned, ) -> SourceResult> { - let data = source.load(world)?; - let theme = Self::decode(&data).at(source.span)?; + let loaded = source.load(world)?; + let theme = Self::decode(&loaded.data).within(&loaded)?; Ok(Derived::new(source.v, theme)) } /// Decode a theme from bytes. #[comemo::memoize] - fn decode(data: &Bytes) -> StrResult { - let mut cursor = std::io::Cursor::new(data.as_slice()); - let theme = synt::ThemeSet::load_from_reader(&mut cursor) - .map_err(|err| eco_format!("failed to parse theme ({err})"))?; - Ok(RawTheme(Arc::new(ManuallyHash::new(theme, typst_utils::hash128(data))))) + fn decode(bytes: &Bytes) -> LoadResult { + let mut cursor = std::io::Cursor::new(bytes.as_slice()); + let theme = + synt::ThemeSet::load_from_reader(&mut cursor).map_err(format_theme_error)?; + Ok(RawTheme(Arc::new(ManuallyHash::new(theme, typst_utils::hash128(bytes))))) } /// Get the underlying syntect theme. @@ -612,6 +621,14 @@ impl RawTheme { } } +fn format_theme_error(error: syntect::LoadingError) -> LoadError { + let pos = match &error { + syntect::LoadingError::ParseSyntax(err, _) => syntax_error_pos(err), + _ => ReportPos::None, + }; + LoadError::new(pos, "failed to parse theme", error) +} + /// A highlighted line of raw text. /// /// This is a helper element that is synthesized by [`raw`] elements. diff --git a/crates/typst-library/src/visualize/image/mod.rs b/crates/typst-library/src/visualize/image/mod.rs index f9e345e70c..f5109798b7 100644 --- a/crates/typst-library/src/visualize/image/mod.rs +++ b/crates/typst-library/src/visualize/image/mod.rs @@ -22,7 +22,7 @@ use crate::foundations::{ Smart, StyleChain, }; use crate::layout::{BlockElem, Length, Rel, Sizing}; -use crate::loading::{DataSource, Load, Readable}; +use crate::loading::{DataSource, Load, LoadSource, Loaded, Readable}; use crate::model::Figurable; use crate::text::LocalName; @@ -65,10 +65,10 @@ pub struct ImageElem { #[required] #[parse( let source = args.expect::>("source")?; - let data = source.load(engine.world)?; - Derived::new(source.v, data) + let loaded = source.load(engine.world)?; + Derived::new(source.v, loaded) )] - pub source: Derived, + pub source: Derived, /// The image's format. /// @@ -154,8 +154,8 @@ pub struct ImageElem { /// to `{auto}`, Typst will try to extract an ICC profile from the image. #[parse(match args.named::>>("icc")? { Some(Spanned { v: Smart::Custom(source), span }) => Some(Smart::Custom({ - let data = Spanned::new(&source, span).load(engine.world)?; - Derived::new(source, data) + let loaded = Spanned::new(&source, span).load(engine.world)?; + Derived::new(source, loaded.data) })), Some(Spanned { v: Smart::Auto, .. }) => Some(Smart::Auto), None => None, @@ -173,7 +173,7 @@ impl ImageElem { pub fn decode( span: Span, /// The data to decode as an image. Can be a string for SVGs. - data: Readable, + data: Spanned, /// The image's format. Detected automatically by default. #[named] format: Option>, @@ -193,8 +193,10 @@ impl ImageElem { #[named] scaling: Option>, ) -> StrResult { - let bytes = data.into_bytes(); - let source = Derived::new(DataSource::Bytes(bytes.clone()), bytes); + let bytes = data.v.into_bytes(); + let loaded = + Loaded::new(Spanned::new(LoadSource::Bytes, data.span), bytes.clone()); + let source = Derived::new(DataSource::Bytes(bytes), loaded); let mut elem = ImageElem::new(source); if let Some(format) = format { elem.push_format(format); diff --git a/crates/typst-library/src/visualize/image/svg.rs b/crates/typst-library/src/visualize/image/svg.rs index 9bf1ead0d0..1a3f6d474b 100644 --- a/crates/typst-library/src/visualize/image/svg.rs +++ b/crates/typst-library/src/visualize/image/svg.rs @@ -3,10 +3,9 @@ use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex}; use comemo::Tracked; -use ecow::EcoString; use siphasher::sip128::{Hasher128, SipHasher13}; -use crate::diag::{format_xml_like_error, StrResult}; +use crate::diag::{format_xml_like_error, LoadError, LoadResult, ReportPos}; use crate::foundations::Bytes; use crate::layout::Axes; use crate::text::{ @@ -30,7 +29,7 @@ impl SvgImage { /// Decode an SVG image without fonts. #[comemo::memoize] #[typst_macros::time(name = "load svg")] - pub fn new(data: Bytes) -> StrResult { + pub fn new(data: Bytes) -> LoadResult { let tree = usvg::Tree::from_data(&data, &base_options()).map_err(format_usvg_error)?; Ok(Self(Arc::new(Repr { data, size: tree_size(&tree), font_hash: 0, tree }))) @@ -43,7 +42,7 @@ impl SvgImage { data: Bytes, world: Tracked, families: &[&str], - ) -> StrResult { + ) -> LoadResult { let book = world.book(); let resolver = Mutex::new(FontResolver::new(world, book, families)); let tree = usvg::Tree::from_data( @@ -125,16 +124,15 @@ fn tree_size(tree: &usvg::Tree) -> Axes { } /// Format the user-facing SVG decoding error message. -fn format_usvg_error(error: usvg::Error) -> EcoString { - match error { - usvg::Error::NotAnUtf8Str => "file is not valid utf-8".into(), - usvg::Error::MalformedGZip => "file is not compressed correctly".into(), - usvg::Error::ElementsLimitReached => "file is too large".into(), - usvg::Error::InvalidSize => { - "failed to parse SVG (width, height, or viewbox is invalid)".into() - } - usvg::Error::ParsingFailed(error) => format_xml_like_error("SVG", error), - } +fn format_usvg_error(error: usvg::Error) -> LoadError { + let error = match error { + usvg::Error::NotAnUtf8Str => "file is not valid utf-8", + usvg::Error::MalformedGZip => "file is not compressed correctly", + usvg::Error::ElementsLimitReached => "file is too large", + usvg::Error::InvalidSize => "width, height, or viewbox is invalid", + usvg::Error::ParsingFailed(error) => return format_xml_like_error("SVG", error), + }; + LoadError::new(ReportPos::None, "failed to parse SVG", error) } /// Provides Typst's fonts to usvg. diff --git a/crates/typst-syntax/Cargo.toml b/crates/typst-syntax/Cargo.toml index 263595bd46..c20f6a0872 100644 --- a/crates/typst-syntax/Cargo.toml +++ b/crates/typst-syntax/Cargo.toml @@ -15,6 +15,7 @@ readme = { workspace = true } [dependencies] typst-timing = { workspace = true } typst-utils = { workspace = true } +comemo = { workspace = true } ecow = { workspace = true } serde = { workspace = true } toml = { workspace = true } diff --git a/crates/typst-syntax/src/lib.rs b/crates/typst-syntax/src/lib.rs index 5e7b710fcf..1249f88e9b 100644 --- a/crates/typst-syntax/src/lib.rs +++ b/crates/typst-syntax/src/lib.rs @@ -7,6 +7,7 @@ mod file; mod highlight; mod kind; mod lexer; +mod lines; mod node; mod parser; mod path; @@ -22,6 +23,7 @@ pub use self::lexer::{ is_id_continue, is_id_start, is_ident, is_newline, is_valid_label_literal_id, link_prefix, split_newlines, }; +pub use self::lines::Lines; pub use self::node::{LinkedChildren, LinkedNode, Side, SyntaxError, SyntaxNode}; pub use self::parser::{parse, parse_code, parse_math}; pub use self::path::VirtualPath; diff --git a/crates/typst-syntax/src/lines.rs b/crates/typst-syntax/src/lines.rs new file mode 100644 index 0000000000..fa1e775630 --- /dev/null +++ b/crates/typst-syntax/src/lines.rs @@ -0,0 +1,402 @@ +use std::hash::{Hash, Hasher}; +use std::iter::zip; +use std::ops::Range; +use std::sync::Arc; + +use crate::is_newline; + +/// A text buffer and metadata about lines. +#[derive(Clone)] +pub struct Lines(Arc>); + +#[derive(Clone)] +struct Repr { + lines: Vec, + text: T, +} + +/// Metadata about a line. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub struct Line { + /// The UTF-8 byte offset where the line starts. + byte_idx: usize, + /// The UTF-16 codepoint offset where the line starts. + utf16_idx: usize, +} + +impl> Lines { + /// Create from the text buffer and compute the line metadata. + pub fn new(text: T) -> Self { + let lines = lines(text.as_ref()); + Lines(Arc::new(Repr { lines, text })) + } + + /// The text as a string slice. + pub fn text(&self) -> &str { + self.0.text.as_ref() + } + + /// Get the length of the file in UTF-8 encoded bytes. + pub fn len_bytes(&self) -> usize { + self.0.text.as_ref().len() + } + + /// Get the length of the file in UTF-16 code units. + pub fn len_utf16(&self) -> usize { + let last = self.0.lines.last().unwrap(); + last.utf16_idx + len_utf16(&self.text()[last.byte_idx..]) + } + + /// Get the length of the file in lines. + pub fn len_lines(&self) -> usize { + self.0.lines.len() + } + + /// Return the index of the UTF-16 code unit at the byte index. + pub fn byte_to_utf16(&self, byte_idx: usize) -> Option { + let line_idx = self.byte_to_line(byte_idx)?; + let line = self.0.lines.get(line_idx)?; + let head = self.text().get(line.byte_idx..byte_idx)?; + Some(line.utf16_idx + len_utf16(head)) + } + + /// Return the index of the line that contains the given byte index. + pub fn byte_to_line(&self, byte_idx: usize) -> Option { + (byte_idx <= self.text().len()).then(|| { + match self.0.lines.binary_search_by_key(&byte_idx, |line| line.byte_idx) { + Ok(i) => i, + Err(i) => i - 1, + } + }) + } + + /// Return the index of the column at the byte index. + /// + /// The column is defined as the number of characters in the line before the + /// byte index. + pub fn byte_to_column(&self, byte_idx: usize) -> Option { + let line = self.byte_to_line(byte_idx)?; + let start = self.line_to_byte(line)?; + let head = self.text().get(start..byte_idx)?; + Some(head.chars().count()) + } + + /// Return the index of the line and column at the byte index. + pub fn byte_to_line_column(&self, byte_idx: usize) -> Option<(usize, usize)> { + let line = self.byte_to_line(byte_idx)?; + let start = self.line_to_byte(line)?; + let head = self.text().get(start..byte_idx)?; + let col = head.chars().count(); + Some((line, col)) + } + + /// Return the byte index at the UTF-16 code unit. + pub fn utf16_to_byte(&self, utf16_idx: usize) -> Option { + let line = self.0.lines.get( + match self.0.lines.binary_search_by_key(&utf16_idx, |line| line.utf16_idx) { + Ok(i) => i, + Err(i) => i - 1, + }, + )?; + + let text = self.text(); + let mut k = line.utf16_idx; + for (i, c) in text[line.byte_idx..].char_indices() { + if k >= utf16_idx { + return Some(line.byte_idx + i); + } + k += c.len_utf16(); + } + + (k == utf16_idx).then_some(text.len()) + } + + /// Return the byte position at which the given line starts. + pub fn line_to_byte(&self, line_idx: usize) -> Option { + self.0.lines.get(line_idx).map(|line| line.byte_idx) + } + + /// Return the range which encloses the given line. + pub fn line_to_range(&self, line_idx: usize) -> Option> { + let start = self.line_to_byte(line_idx)?; + let end = self.line_to_byte(line_idx + 1).unwrap_or(self.text().len()); + Some(start..end) + } + + /// Return the byte index of the given (line, column) pair. + /// + /// The column defines the number of characters to go beyond the start of + /// the line. + pub fn line_column_to_byte( + &self, + line_idx: usize, + column_idx: usize, + ) -> Option { + let range = self.line_to_range(line_idx)?; + let line = self.text().get(range.clone())?; + let mut chars = line.chars(); + for _ in 0..column_idx { + chars.next(); + } + Some(range.start + (line.len() - chars.as_str().len())) + } +} + +impl Lines { + /// Fully replace the source text. + /// + /// This performs a naive (suffix/prefix-based) diff of the old and new text + /// to produce the smallest single edit that transforms old into new and + /// then calls [`edit`](Self::edit) with it. + /// + /// Returns whether any changes were made. + pub fn replace(&mut self, new: &str) -> bool { + let Some((prefix, suffix)) = self.replacement_range(new) else { + return false; + }; + + let old = self.text(); + let replace = prefix..old.len() - suffix; + let with = &new[prefix..new.len() - suffix]; + self.edit(replace, with); + + true + } + + /// Returns the common prefix and suffix lengths. + /// Returns [`None`] if the old and new strings are equal. + pub fn replacement_range(&self, new: &str) -> Option<(usize, usize)> { + let old = self.text(); + + let mut prefix = + zip(old.bytes(), new.bytes()).take_while(|(x, y)| x == y).count(); + + if prefix == old.len() && prefix == new.len() { + return None; + } + + while !old.is_char_boundary(prefix) || !new.is_char_boundary(prefix) { + prefix -= 1; + } + + let mut suffix = zip(old[prefix..].bytes().rev(), new[prefix..].bytes().rev()) + .take_while(|(x, y)| x == y) + .count(); + + while !old.is_char_boundary(old.len() - suffix) + || !new.is_char_boundary(new.len() - suffix) + { + suffix += 1; + } + + Some((prefix, suffix)) + } + + /// Edit the source file by replacing the given range. + /// + /// Returns the range in the new source that was ultimately reparsed. + /// + /// The method panics if the `replace` range is out of bounds. + #[track_caller] + pub fn edit(&mut self, replace: Range, with: &str) { + let start_byte = replace.start; + let start_utf16 = self.byte_to_utf16(start_byte).unwrap(); + let line = self.byte_to_line(start_byte).unwrap(); + + let inner = Arc::make_mut(&mut self.0); + + // Update the text itself. + inner.text.replace_range(replace.clone(), with); + + // Remove invalidated line starts. + inner.lines.truncate(line + 1); + + // Handle adjoining of \r and \n. + if inner.text[..start_byte].ends_with('\r') && with.starts_with('\n') { + inner.lines.pop(); + } + + // Recalculate the line starts after the edit. + inner.lines.extend(lines_from( + start_byte, + start_utf16, + &inner.text[start_byte..], + )); + } +} + +impl Hash for Lines { + fn hash(&self, state: &mut H) { + self.0.text.hash(state); + } +} + +impl> AsRef for Lines { + fn as_ref(&self) -> &str { + self.0.text.as_ref() + } +} + +/// Create a line vector. +fn lines(text: &str) -> Vec { + std::iter::once(Line { byte_idx: 0, utf16_idx: 0 }) + .chain(lines_from(0, 0, text)) + .collect() +} + +/// Compute a line iterator from an offset. +fn lines_from( + byte_offset: usize, + utf16_offset: usize, + text: &str, +) -> impl Iterator + '_ { + let mut s = unscanny::Scanner::new(text); + let mut utf16_idx = utf16_offset; + + std::iter::from_fn(move || { + s.eat_until(|c: char| { + utf16_idx += c.len_utf16(); + is_newline(c) + }); + + if s.done() { + return None; + } + + if s.eat() == Some('\r') && s.eat_if('\n') { + utf16_idx += 1; + } + + Some(Line { byte_idx: byte_offset + s.cursor(), utf16_idx }) + }) +} + +/// The number of code units this string would use if it was encoded in +/// UTF16. This runs in linear time. +fn len_utf16(string: &str) -> usize { + string.chars().map(char::len_utf16).sum() +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST: &str = "ä\tcde\nf💛g\r\nhi\rjkl"; + + #[test] + fn test_source_file_new() { + let lines = Lines::new(TEST); + assert_eq!( + lines.0.lines, + [ + Line { byte_idx: 0, utf16_idx: 0 }, + Line { byte_idx: 7, utf16_idx: 6 }, + Line { byte_idx: 15, utf16_idx: 12 }, + Line { byte_idx: 18, utf16_idx: 15 }, + ] + ); + } + + #[test] + fn test_source_file_pos_to_line() { + let lines = Lines::new(TEST); + assert_eq!(lines.byte_to_line(0), Some(0)); + assert_eq!(lines.byte_to_line(2), Some(0)); + assert_eq!(lines.byte_to_line(6), Some(0)); + assert_eq!(lines.byte_to_line(7), Some(1)); + assert_eq!(lines.byte_to_line(8), Some(1)); + assert_eq!(lines.byte_to_line(12), Some(1)); + assert_eq!(lines.byte_to_line(21), Some(3)); + assert_eq!(lines.byte_to_line(22), None); + } + + #[test] + fn test_source_file_pos_to_column() { + let lines = Lines::new(TEST); + assert_eq!(lines.byte_to_column(0), Some(0)); + assert_eq!(lines.byte_to_column(2), Some(1)); + assert_eq!(lines.byte_to_column(6), Some(5)); + assert_eq!(lines.byte_to_column(7), Some(0)); + assert_eq!(lines.byte_to_column(8), Some(1)); + assert_eq!(lines.byte_to_column(12), Some(2)); + } + + #[test] + fn test_source_file_utf16() { + #[track_caller] + fn roundtrip(lines: &Lines<&str>, byte_idx: usize, utf16_idx: usize) { + let middle = lines.byte_to_utf16(byte_idx).unwrap(); + let result = lines.utf16_to_byte(middle).unwrap(); + assert_eq!(middle, utf16_idx); + assert_eq!(result, byte_idx); + } + + let lines = Lines::new(TEST); + roundtrip(&lines, 0, 0); + roundtrip(&lines, 2, 1); + roundtrip(&lines, 3, 2); + roundtrip(&lines, 8, 7); + roundtrip(&lines, 12, 9); + roundtrip(&lines, 21, 18); + assert_eq!(lines.byte_to_utf16(22), None); + assert_eq!(lines.utf16_to_byte(19), None); + } + + #[test] + fn test_source_file_roundtrip() { + #[track_caller] + fn roundtrip(lines: &Lines<&str>, byte_idx: usize) { + let line = lines.byte_to_line(byte_idx).unwrap(); + let column = lines.byte_to_column(byte_idx).unwrap(); + let result = lines.line_column_to_byte(line, column).unwrap(); + assert_eq!(result, byte_idx); + } + + let lines = Lines::new(TEST); + roundtrip(&lines, 0); + roundtrip(&lines, 7); + roundtrip(&lines, 12); + roundtrip(&lines, 21); + } + + #[test] + fn test_source_file_edit() { + // This tests only the non-parser parts. The reparsing itself is + // tested separately. + #[track_caller] + fn test(prev: &str, range: Range, with: &str, after: &str) { + let reference = Lines::new(after); + + let mut edited = Lines::new(prev.to_string()); + edited.edit(range.clone(), with); + assert_eq!(edited.text(), reference.text()); + assert_eq!(edited.0.lines, reference.0.lines); + + let mut replaced = Lines::new(prev.to_string()); + replaced.replace(&{ + let mut s = prev.to_string(); + s.replace_range(range, with); + s + }); + assert_eq!(replaced.text(), reference.text()); + assert_eq!(replaced.0.lines, reference.0.lines); + } + + // Test inserting at the beginning. + test("abc\n", 0..0, "hi\n", "hi\nabc\n"); + test("\nabc", 0..0, "hi\r", "hi\r\nabc"); + + // Test editing in the middle. + test(TEST, 4..16, "❌", "ä\tc❌i\rjkl"); + + // Test appending. + test("abc\ndef", 7..7, "hi", "abc\ndefhi"); + test("abc\ndef\n", 8..8, "hi", "abc\ndef\nhi"); + + // Test appending with adjoining \r and \n. + test("abc\ndef\r", 8..8, "\nghi", "abc\ndef\r\nghi"); + + // Test removing everything. + test(TEST, 0..21, "", ""); + } +} diff --git a/crates/typst-syntax/src/source.rs b/crates/typst-syntax/src/source.rs index 6ff94c73f6..514cb9a4a8 100644 --- a/crates/typst-syntax/src/source.rs +++ b/crates/typst-syntax/src/source.rs @@ -2,14 +2,14 @@ use std::fmt::{self, Debug, Formatter}; use std::hash::{Hash, Hasher}; -use std::iter::zip; use std::ops::Range; use std::sync::Arc; use typst_utils::LazyHash; +use crate::lines::Lines; use crate::reparser::reparse; -use crate::{is_newline, parse, FileId, LinkedNode, Span, SyntaxNode, VirtualPath}; +use crate::{parse, FileId, LinkedNode, Span, SyntaxNode, VirtualPath}; /// A source file. /// @@ -24,9 +24,8 @@ pub struct Source(Arc); #[derive(Clone)] struct Repr { id: FileId, - text: LazyHash, root: LazyHash, - lines: Vec, + lines: LazyHash>, } impl Source { @@ -37,8 +36,7 @@ impl Source { root.numberize(id, Span::FULL).unwrap(); Self(Arc::new(Repr { id, - lines: lines(&text), - text: LazyHash::new(text), + lines: LazyHash::new(Lines::new(text)), root: LazyHash::new(root), })) } @@ -58,9 +56,14 @@ impl Source { self.0.id } + /// The whole source as a string slice. + pub fn lines(&self) -> Lines { + Lines::clone(&self.0.lines) + } + /// The whole source as a string slice. pub fn text(&self) -> &str { - &self.0.text + self.0.lines.text() } /// Slice out the part of the source code enclosed by the range. @@ -77,29 +80,12 @@ impl Source { /// Returns the range in the new source that was ultimately reparsed. pub fn replace(&mut self, new: &str) -> Range { let _scope = typst_timing::TimingScope::new("replace source"); - let old = self.text(); - - let mut prefix = - zip(old.bytes(), new.bytes()).take_while(|(x, y)| x == y).count(); - if prefix == old.len() && prefix == new.len() { + let Some((prefix, suffix)) = self.0.lines.replacement_range(new) else { return 0..0; - } - - while !old.is_char_boundary(prefix) || !new.is_char_boundary(prefix) { - prefix -= 1; - } - - let mut suffix = zip(old[prefix..].bytes().rev(), new[prefix..].bytes().rev()) - .take_while(|(x, y)| x == y) - .count(); - - while !old.is_char_boundary(old.len() - suffix) - || !new.is_char_boundary(new.len() - suffix) - { - suffix += 1; - } + }; + let old = self.text(); let replace = prefix..old.len() - suffix; let with = &new[prefix..new.len() - suffix]; self.edit(replace, with) @@ -112,48 +98,28 @@ impl Source { /// The method panics if the `replace` range is out of bounds. #[track_caller] pub fn edit(&mut self, replace: Range, with: &str) -> Range { - let start_byte = replace.start; - let start_utf16 = self.byte_to_utf16(start_byte).unwrap(); - let line = self.byte_to_line(start_byte).unwrap(); - let inner = Arc::make_mut(&mut self.0); - // Update the text itself. - inner.text.replace_range(replace.clone(), with); - - // Remove invalidated line starts. - inner.lines.truncate(line + 1); - - // Handle adjoining of \r and \n. - if inner.text[..start_byte].ends_with('\r') && with.starts_with('\n') { - inner.lines.pop(); - } - - // Recalculate the line starts after the edit. - inner.lines.extend(lines_from( - start_byte, - start_utf16, - &inner.text[start_byte..], - )); + // Update the text and lines. + inner.lines.edit(replace.clone(), with); // Incrementally reparse the replaced range. - reparse(&mut inner.root, &inner.text, replace, with.len()) + reparse(&mut inner.root, inner.lines.text(), replace, with.len()) } /// Get the length of the file in UTF-8 encoded bytes. pub fn len_bytes(&self) -> usize { - self.text().len() + self.0.lines.len_bytes() } /// Get the length of the file in UTF-16 code units. pub fn len_utf16(&self) -> usize { - let last = self.0.lines.last().unwrap(); - last.utf16_idx + len_utf16(&self.0.text[last.byte_idx..]) + self.0.lines.len_utf16() } /// Get the length of the file in lines. pub fn len_lines(&self) -> usize { - self.0.lines.len() + self.0.lines.len_lines() } /// Find the node with the given span. @@ -171,85 +137,6 @@ impl Source { pub fn range(&self, span: Span) -> Option> { Some(self.find(span)?.range()) } - - /// Return the index of the UTF-16 code unit at the byte index. - pub fn byte_to_utf16(&self, byte_idx: usize) -> Option { - let line_idx = self.byte_to_line(byte_idx)?; - let line = self.0.lines.get(line_idx)?; - let head = self.0.text.get(line.byte_idx..byte_idx)?; - Some(line.utf16_idx + len_utf16(head)) - } - - /// Return the index of the line that contains the given byte index. - pub fn byte_to_line(&self, byte_idx: usize) -> Option { - (byte_idx <= self.0.text.len()).then(|| { - match self.0.lines.binary_search_by_key(&byte_idx, |line| line.byte_idx) { - Ok(i) => i, - Err(i) => i - 1, - } - }) - } - - /// Return the index of the column at the byte index. - /// - /// The column is defined as the number of characters in the line before the - /// byte index. - pub fn byte_to_column(&self, byte_idx: usize) -> Option { - let line = self.byte_to_line(byte_idx)?; - let start = self.line_to_byte(line)?; - let head = self.get(start..byte_idx)?; - Some(head.chars().count()) - } - - /// Return the byte index at the UTF-16 code unit. - pub fn utf16_to_byte(&self, utf16_idx: usize) -> Option { - let line = self.0.lines.get( - match self.0.lines.binary_search_by_key(&utf16_idx, |line| line.utf16_idx) { - Ok(i) => i, - Err(i) => i - 1, - }, - )?; - - let mut k = line.utf16_idx; - for (i, c) in self.0.text[line.byte_idx..].char_indices() { - if k >= utf16_idx { - return Some(line.byte_idx + i); - } - k += c.len_utf16(); - } - - (k == utf16_idx).then_some(self.0.text.len()) - } - - /// Return the byte position at which the given line starts. - pub fn line_to_byte(&self, line_idx: usize) -> Option { - self.0.lines.get(line_idx).map(|line| line.byte_idx) - } - - /// Return the range which encloses the given line. - pub fn line_to_range(&self, line_idx: usize) -> Option> { - let start = self.line_to_byte(line_idx)?; - let end = self.line_to_byte(line_idx + 1).unwrap_or(self.0.text.len()); - Some(start..end) - } - - /// Return the byte index of the given (line, column) pair. - /// - /// The column defines the number of characters to go beyond the start of - /// the line. - pub fn line_column_to_byte( - &self, - line_idx: usize, - column_idx: usize, - ) -> Option { - let range = self.line_to_range(line_idx)?; - let line = self.get(range.clone())?; - let mut chars = line.chars(); - for _ in 0..column_idx { - chars.next(); - } - Some(range.start + (line.len() - chars.as_str().len())) - } } impl Debug for Source { @@ -261,7 +148,7 @@ impl Debug for Source { impl Hash for Source { fn hash(&self, state: &mut H) { self.0.id.hash(state); - self.0.text.hash(state); + self.0.lines.hash(state); self.0.root.hash(state); } } @@ -271,176 +158,3 @@ impl AsRef for Source { self.text() } } - -/// Metadata about a line. -#[derive(Debug, Copy, Clone, Eq, PartialEq)] -struct Line { - /// The UTF-8 byte offset where the line starts. - byte_idx: usize, - /// The UTF-16 codepoint offset where the line starts. - utf16_idx: usize, -} - -/// Create a line vector. -fn lines(text: &str) -> Vec { - std::iter::once(Line { byte_idx: 0, utf16_idx: 0 }) - .chain(lines_from(0, 0, text)) - .collect() -} - -/// Compute a line iterator from an offset. -fn lines_from( - byte_offset: usize, - utf16_offset: usize, - text: &str, -) -> impl Iterator + '_ { - let mut s = unscanny::Scanner::new(text); - let mut utf16_idx = utf16_offset; - - std::iter::from_fn(move || { - s.eat_until(|c: char| { - utf16_idx += c.len_utf16(); - is_newline(c) - }); - - if s.done() { - return None; - } - - if s.eat() == Some('\r') && s.eat_if('\n') { - utf16_idx += 1; - } - - Some(Line { byte_idx: byte_offset + s.cursor(), utf16_idx }) - }) -} - -/// The number of code units this string would use if it was encoded in -/// UTF16. This runs in linear time. -fn len_utf16(string: &str) -> usize { - string.chars().map(char::len_utf16).sum() -} - -#[cfg(test)] -mod tests { - use super::*; - - const TEST: &str = "ä\tcde\nf💛g\r\nhi\rjkl"; - - #[test] - fn test_source_file_new() { - let source = Source::detached(TEST); - assert_eq!( - source.0.lines, - [ - Line { byte_idx: 0, utf16_idx: 0 }, - Line { byte_idx: 7, utf16_idx: 6 }, - Line { byte_idx: 15, utf16_idx: 12 }, - Line { byte_idx: 18, utf16_idx: 15 }, - ] - ); - } - - #[test] - fn test_source_file_pos_to_line() { - let source = Source::detached(TEST); - assert_eq!(source.byte_to_line(0), Some(0)); - assert_eq!(source.byte_to_line(2), Some(0)); - assert_eq!(source.byte_to_line(6), Some(0)); - assert_eq!(source.byte_to_line(7), Some(1)); - assert_eq!(source.byte_to_line(8), Some(1)); - assert_eq!(source.byte_to_line(12), Some(1)); - assert_eq!(source.byte_to_line(21), Some(3)); - assert_eq!(source.byte_to_line(22), None); - } - - #[test] - fn test_source_file_pos_to_column() { - let source = Source::detached(TEST); - assert_eq!(source.byte_to_column(0), Some(0)); - assert_eq!(source.byte_to_column(2), Some(1)); - assert_eq!(source.byte_to_column(6), Some(5)); - assert_eq!(source.byte_to_column(7), Some(0)); - assert_eq!(source.byte_to_column(8), Some(1)); - assert_eq!(source.byte_to_column(12), Some(2)); - } - - #[test] - fn test_source_file_utf16() { - #[track_caller] - fn roundtrip(source: &Source, byte_idx: usize, utf16_idx: usize) { - let middle = source.byte_to_utf16(byte_idx).unwrap(); - let result = source.utf16_to_byte(middle).unwrap(); - assert_eq!(middle, utf16_idx); - assert_eq!(result, byte_idx); - } - - let source = Source::detached(TEST); - roundtrip(&source, 0, 0); - roundtrip(&source, 2, 1); - roundtrip(&source, 3, 2); - roundtrip(&source, 8, 7); - roundtrip(&source, 12, 9); - roundtrip(&source, 21, 18); - assert_eq!(source.byte_to_utf16(22), None); - assert_eq!(source.utf16_to_byte(19), None); - } - - #[test] - fn test_source_file_roundtrip() { - #[track_caller] - fn roundtrip(source: &Source, byte_idx: usize) { - let line = source.byte_to_line(byte_idx).unwrap(); - let column = source.byte_to_column(byte_idx).unwrap(); - let result = source.line_column_to_byte(line, column).unwrap(); - assert_eq!(result, byte_idx); - } - - let source = Source::detached(TEST); - roundtrip(&source, 0); - roundtrip(&source, 7); - roundtrip(&source, 12); - roundtrip(&source, 21); - } - - #[test] - fn test_source_file_edit() { - // This tests only the non-parser parts. The reparsing itself is - // tested separately. - #[track_caller] - fn test(prev: &str, range: Range, with: &str, after: &str) { - let reference = Source::detached(after); - - let mut edited = Source::detached(prev); - edited.edit(range.clone(), with); - assert_eq!(edited.text(), reference.text()); - assert_eq!(edited.0.lines, reference.0.lines); - - let mut replaced = Source::detached(prev); - replaced.replace(&{ - let mut s = prev.to_string(); - s.replace_range(range, with); - s - }); - assert_eq!(replaced.text(), reference.text()); - assert_eq!(replaced.0.lines, reference.0.lines); - } - - // Test inserting at the beginning. - test("abc\n", 0..0, "hi\n", "hi\nabc\n"); - test("\nabc", 0..0, "hi\r", "hi\r\nabc"); - - // Test editing in the middle. - test(TEST, 4..16, "❌", "ä\tc❌i\rjkl"); - - // Test appending. - test("abc\ndef", 7..7, "hi", "abc\ndefhi"); - test("abc\ndef\n", 8..8, "hi", "abc\ndef\nhi"); - - // Test appending with adjoining \r and \n. - test("abc\ndef\r", 8..8, "\nghi", "abc\ndef\r\nghi"); - - // Test removing everything. - test(TEST, 0..21, "", ""); - } -} diff --git a/tests/src/collect.rs b/tests/src/collect.rs index 84af04d2dc..173488b01b 100644 --- a/tests/src/collect.rs +++ b/tests/src/collect.rs @@ -7,7 +7,9 @@ use std::sync::LazyLock; use ecow::{eco_format, EcoString}; use typst_syntax::package::PackageVersion; -use typst_syntax::{is_id_continue, is_ident, is_newline, FileId, Source, VirtualPath}; +use typst_syntax::{ + is_id_continue, is_ident, is_newline, FileId, Lines, Source, VirtualPath, +}; use unscanny::Scanner; /// Collects all tests from all files. @@ -79,6 +81,8 @@ impl Display for FileSize { pub struct Note { pub pos: FilePos, pub kind: NoteKind, + /// The file [`Self::range`] belongs to. + pub file: FileId, pub range: Option>, pub message: String, } @@ -341,9 +345,28 @@ impl<'a> Parser<'a> { let kind: NoteKind = head.parse().ok()?; self.s.eat_if(' '); + let mut file = None; + if self.s.eat_if('"') { + let path = self.s.eat_until(|c| is_newline(c) || c == '"'); + if !self.s.eat_if('"') { + self.error("expected closing quote after file path"); + return None; + } + + let vpath = VirtualPath::new(path); + file = Some(FileId::new(None, vpath)); + + self.s.eat_if(' '); + } + let mut range = None; if self.s.at('-') || self.s.at(char::is_numeric) { - range = self.parse_range(source); + if let Some(file) = file { + range = self.parse_range_external(file); + } else { + range = self.parse_range(source); + } + if range.is_none() { self.error("range is malformed"); return None; @@ -359,11 +382,78 @@ impl<'a> Parser<'a> { Some(Note { pos: FilePos::new(self.path, self.line), kind, + file: file.unwrap_or(source.id()), range, message, }) } + #[cfg(not(feature = "default"))] + fn parse_range_external(&mut self, _file: FileId) -> Option> { + panic!("external file ranges are not expected when testing `typst_syntax`"); + } + + /// Parse a range in an external file, optionally abbreviated as just a position + /// if the range is empty. + #[cfg(feature = "default")] + fn parse_range_external(&mut self, file: FileId) -> Option> { + use typst::foundations::Bytes; + + use crate::world::{read, system_path}; + + let path = match system_path(file) { + Ok(path) => path, + Err(err) => { + self.error(err.to_string()); + return None; + } + }; + + let bytes = match read(&path) { + Ok(data) => Bytes::new(data), + Err(err) => { + self.error(err.to_string()); + return None; + } + }; + + let start = self.parse_line_col()?; + let lines = Lines::try_from(&bytes).expect( + "errors shouldn't be annotated for files \ + that aren't human readable (not valid utf-8)", + ); + let range = if self.s.eat_if('-') { + let (line, col) = start; + let start = lines.line_column_to_byte(line, col); + let (line, col) = self.parse_line_col()?; + let end = lines.line_column_to_byte(line, col); + Option::zip(start, end).map(|(a, b)| a..b) + } else { + let (line, col) = start; + lines.line_column_to_byte(line, col).map(|i| i..i) + }; + if range.is_none() { + self.error("range is out of bounds"); + } + range + } + + /// Parses absolute `line:column` indices in an external file. + fn parse_line_col(&mut self) -> Option<(usize, usize)> { + let line = self.parse_number()?; + if !self.s.eat_if(':') { + self.error("positions in external files always require both `:`"); + return None; + } + let col = self.parse_number()?; + if line < 0 || col < 0 { + self.error("line and column numbers must be positive"); + return None; + } + + Some(((line as usize).saturating_sub(1), (col as usize).saturating_sub(1))) + } + /// Parse a range, optionally abbreviated as just a position if the range /// is empty. fn parse_range(&mut self, source: &Source) -> Option> { @@ -389,13 +479,13 @@ impl<'a> Parser<'a> { let line_idx = (line_idx_in_test + comments).checked_add_signed(line_delta)?; let column_idx = if column < 0 { // Negative column index is from the back. - let range = source.line_to_range(line_idx)?; + let range = source.lines().line_to_range(line_idx)?; text[range].chars().count().saturating_add_signed(column) } else { usize::try_from(column).ok()?.checked_sub(1)? }; - source.line_column_to_byte(line_idx, column_idx) + source.lines().line_column_to_byte(line_idx, column_idx) } /// Parse a number. diff --git a/tests/src/run.rs b/tests/src/run.rs index 4d08362cf3..a34e38db52 100644 --- a/tests/src/run.rs +++ b/tests/src/run.rs @@ -10,10 +10,11 @@ use typst::layout::{Abs, Frame, FrameItem, PagedDocument, Transform}; use typst::visualize::Color; use typst::{Document, WorldExt}; use typst_pdf::PdfOptions; +use typst_syntax::FileId; use crate::collect::{Attr, FileSize, NoteKind, Test}; use crate::logger::TestResult; -use crate::world::TestWorld; +use crate::world::{system_path, TestWorld}; /// Runs a single test. /// @@ -117,7 +118,7 @@ impl<'a> Runner<'a> { if seen { continue; } - let note_range = self.format_range(¬e.range); + let note_range = self.format_range(note.file, ¬e.range); if first { log!(self, "not emitted"); first = false; @@ -208,10 +209,6 @@ impl<'a> Runner<'a> { /// Compare a subset of notes with a given kind against diagnostics of /// that same kind. fn check_diagnostic(&mut self, kind: NoteKind, diag: &SourceDiagnostic) { - // Ignore diagnostics from other sources than the test file itself. - if diag.span.id().is_some_and(|id| id != self.test.source.id()) { - return; - } // TODO: remove this once HTML export is stable if diag.message == "html export is under active development and incomplete" { return; @@ -219,11 +216,11 @@ impl<'a> Runner<'a> { let message = diag.message.replace("\\", "/"); let range = self.world.range(diag.span); - self.validate_note(kind, range.clone(), &message); + self.validate_note(kind, diag.span.id(), range.clone(), &message); // Check hints. for hint in &diag.hints { - self.validate_note(NoteKind::Hint, range.clone(), hint); + self.validate_note(NoteKind::Hint, diag.span.id(), range.clone(), hint); } } @@ -235,15 +232,18 @@ impl<'a> Runner<'a> { fn validate_note( &mut self, kind: NoteKind, + file: Option, range: Option>, message: &str, ) { // Try to find perfect match. + let file = file.unwrap_or(self.test.source.id()); if let Some((i, _)) = self.test.notes.iter().enumerate().find(|&(i, note)| { !self.seen[i] && note.kind == kind && note.range == range && note.message == message + && note.file == file }) { self.seen[i] = true; return; @@ -257,7 +257,7 @@ impl<'a> Runner<'a> { && (note.range == range || note.message == message) }) else { // Not even a close match, diagnostic is not annotated. - let diag_range = self.format_range(&range); + let diag_range = self.format_range(file, &range); log!(into: self.not_annotated, " {kind}: {diag_range} {}", message); return; }; @@ -267,10 +267,10 @@ impl<'a> Runner<'a> { // Range is wrong. if range != note.range { - let note_range = self.format_range(¬e.range); - let note_text = self.text_for_range(¬e.range); - let diag_range = self.format_range(&range); - let diag_text = self.text_for_range(&range); + let note_range = self.format_range(note.file, ¬e.range); + let note_text = self.text_for_range(note.file, ¬e.range); + let diag_range = self.format_range(file, &range); + let diag_text = self.text_for_range(file, &range); log!(self, "mismatched range ({}):", note.pos); log!(self, " message | {}", note.message); log!(self, " annotated | {note_range:<9} | {note_text}"); @@ -286,39 +286,49 @@ impl<'a> Runner<'a> { } /// Display the text for a range. - fn text_for_range(&self, range: &Option>) -> String { + fn text_for_range(&self, file: FileId, range: &Option>) -> String { let Some(range) = range else { return "No text".into() }; if range.is_empty() { - "(empty)".into() - } else { - format!("`{}`", self.test.source.text()[range.clone()].replace('\n', "\\n")) + return "(empty)".into(); } + + let lines = self.world.lookup(file); + lines.text()[range.clone()].replace('\n', "\\n").replace('\r', "\\r") } /// Display a byte range as a line:column range. - fn format_range(&self, range: &Option>) -> String { + fn format_range(&self, file: FileId, range: &Option>) -> String { let Some(range) = range else { return "No range".into() }; + + let mut preamble = String::new(); + if file != self.test.source.id() { + preamble = format!("\"{}\" ", system_path(file).unwrap().display()); + } + if range.start == range.end { - self.format_pos(range.start) + format!("{preamble}{}", self.format_pos(file, range.start)) } else { - format!("{}-{}", self.format_pos(range.start,), self.format_pos(range.end,)) + format!( + "{preamble}{}-{}", + self.format_pos(file, range.start), + self.format_pos(file, range.end) + ) } } /// Display a position as a line:column pair. - fn format_pos(&self, pos: usize) -> String { - if let (Some(line_idx), Some(column_idx)) = - (self.test.source.byte_to_line(pos), self.test.source.byte_to_column(pos)) - { - let line = self.test.pos.line + line_idx; - let column = column_idx + 1; - if line == 1 { - format!("{column}") - } else { - format!("{line}:{column}") - } + fn format_pos(&self, file: FileId, pos: usize) -> String { + let lines = self.world.lookup(file); + + let res = lines.byte_to_line_column(pos).map(|(line, col)| (line + 1, col + 1)); + let Some((line, col)) = res else { + return "oob".into(); + }; + + if line == 1 { + format!("{col}") } else { - "oob".into() + format!("{line}:{col}") } } } diff --git a/tests/src/world.rs b/tests/src/world.rs index fe2bd45ea8..bc3e690b24 100644 --- a/tests/src/world.rs +++ b/tests/src/world.rs @@ -20,6 +20,7 @@ use typst::text::{Font, FontBook, TextElem, TextSize}; use typst::utils::{singleton, LazyHash}; use typst::visualize::Color; use typst::{Feature, Library, World}; +use typst_syntax::Lines; /// A world that provides access to the tests environment. #[derive(Clone)] @@ -84,6 +85,22 @@ impl TestWorld { let mut map = self.base.slots.lock(); f(map.entry(id).or_insert_with(|| FileSlot::new(id))) } + + /// Lookup line metadata for a file by id. + #[track_caller] + pub fn lookup(&self, id: FileId) -> Lines { + self.slot(id, |slot| { + if let Some(source) = slot.source.get() { + let source = source.as_ref().expect("file is not valid"); + source.lines() + } else if let Some(bytes) = slot.file.get() { + let bytes = bytes.as_ref().expect("file is not valid"); + Lines::try_from(bytes).expect("file is not valid utf-8") + } else { + panic!("file id does not point to any source file"); + } + }) + } } /// Shared foundation of all test worlds. @@ -149,7 +166,7 @@ impl FileSlot { } /// The file system path for a file ID. -fn system_path(id: FileId) -> FileResult { +pub(crate) fn system_path(id: FileId) -> FileResult { let root: PathBuf = match id.package() { Some(spec) => format!("tests/packages/{}-{}", spec.name, spec.version).into(), None => PathBuf::new(), @@ -159,7 +176,7 @@ fn system_path(id: FileId) -> FileResult { } /// Read a file. -fn read(path: &Path) -> FileResult> { +pub(crate) fn read(path: &Path) -> FileResult> { // Resolve asset. if let Ok(suffix) = path.strip_prefix("assets/") { return typst_dev_assets::get(&suffix.to_string_lossy()) diff --git a/tests/suite/loading/csv.typ b/tests/suite/loading/csv.typ index 6f57ec4586..046345becc 100644 --- a/tests/suite/loading/csv.typ +++ b/tests/suite/loading/csv.typ @@ -18,12 +18,12 @@ #csv("nope.csv") --- csv-invalid --- -// Error: 6-28 failed to parse CSV (found 3 instead of 2 fields in line 3) +// Error: "/assets/data/bad.csv" 3:1 failed to parse CSV (found 3 instead of 2 fields in line 3) #csv("/assets/data/bad.csv") --- csv-invalid-row-type-dict --- // Test error numbering with dictionary rows. -// Error: 6-28 failed to parse CSV (found 3 instead of 2 fields in line 3) +// Error: "/assets/data/bad.csv" 3:1 failed to parse CSV (found 3 instead of 2 fields in line 3) #csv("/assets/data/bad.csv", row-type: dictionary) --- csv-invalid-delimiter --- diff --git a/tests/suite/loading/json.typ b/tests/suite/loading/json.typ index c8df1ff6e8..9e433992d9 100644 --- a/tests/suite/loading/json.typ +++ b/tests/suite/loading/json.typ @@ -6,7 +6,7 @@ #test(data.at(2).weight, 150) --- json-invalid --- -// Error: 7-30 failed to parse JSON (expected value at line 3 column 14) +// Error: "/assets/data/bad.json" 3:14 failed to parse JSON (expected value at line 3 column 14) #json("/assets/data/bad.json") --- json-decode-deprecated --- diff --git a/tests/suite/loading/read.typ b/tests/suite/loading/read.typ index b5c9c0892e..57bfc1d5c9 100644 --- a/tests/suite/loading/read.typ +++ b/tests/suite/loading/read.typ @@ -8,5 +8,5 @@ #let data = read("/assets/text/missing.txt") --- read-invalid-utf-8 --- -// Error: 18-40 file is not valid utf-8 +// Error: 18-40 failed to convert to string (file is not valid utf-8 in assets/text/bad.txt:1:1) #let data = read("/assets/text/bad.txt") diff --git a/tests/suite/loading/toml.typ b/tests/suite/loading/toml.typ index a4318a015d..9d65da452c 100644 --- a/tests/suite/loading/toml.typ +++ b/tests/suite/loading/toml.typ @@ -37,7 +37,7 @@ )) --- toml-invalid --- -// Error: 7-30 failed to parse TOML (expected `.`, `=` at line 1 column 16) +// Error: "/assets/data/bad.toml" 1:16-2:1 failed to parse TOML (expected `.`, `=`) #toml("/assets/data/bad.toml") --- toml-decode-deprecated --- diff --git a/tests/suite/loading/xml.typ b/tests/suite/loading/xml.typ index 933f3c4806..eed7db0ae4 100644 --- a/tests/suite/loading/xml.typ +++ b/tests/suite/loading/xml.typ @@ -24,7 +24,7 @@ ),)) --- xml-invalid --- -// Error: 6-28 failed to parse XML (found closing tag 'data' instead of 'hello' in line 3) +// Error: "/assets/data/bad.xml" 3:0 failed to parse XML (found closing tag 'data' instead of 'hello') #xml("/assets/data/bad.xml") --- xml-decode-deprecated --- diff --git a/tests/suite/loading/yaml.typ b/tests/suite/loading/yaml.typ index a8089052c5..ad171c6efd 100644 --- a/tests/suite/loading/yaml.typ +++ b/tests/suite/loading/yaml.typ @@ -13,7 +13,7 @@ #test(data.at("1"), "ok") --- yaml-invalid --- -// Error: 7-30 failed to parse YAML (did not find expected ',' or ']' at line 2 column 1, while parsing a flow sequence at line 1 column 18) +// Error: "/assets/data/bad.yaml" 2:1 failed to parse YAML (did not find expected ',' or ']' at line 2 column 1, while parsing a flow sequence at line 1 column 18) #yaml("/assets/data/bad.yaml") --- yaml-decode-deprecated --- diff --git a/tests/suite/scripting/import.typ b/tests/suite/scripting/import.typ index 49b66ee565..382e444cc9 100644 --- a/tests/suite/scripting/import.typ +++ b/tests/suite/scripting/import.typ @@ -334,6 +334,7 @@ --- import-cyclic-in-other-file --- // Cyclic import in other file. +// Error: "tests/suite/scripting/modules/cycle2.typ" 2:9-2:21 cyclic import #import "./modules/cycle1.typ": * This is never reached. diff --git a/tests/suite/visualize/image.typ b/tests/suite/visualize/image.typ index 73c4feff8f..45c70c4b83 100644 --- a/tests/suite/visualize/image.typ +++ b/tests/suite/visualize/image.typ @@ -167,7 +167,7 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B #image("/assets/plugins/hello.wasm") --- image-bad-svg --- -// Error: 2-33 failed to parse SVG (found closing tag 'g' instead of 'style' in line 4) +// Error: "/assets/images/bad.svg" 4:3 failed to parse SVG (found closing tag 'g' instead of 'style') #image("/assets/images/bad.svg") --- image-decode-svg --- @@ -176,7 +176,7 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B #image.decode(``.text, format: "svg") --- image-decode-bad-svg --- -// Error: 2-168 failed to parse SVG (missing root node) +// Error: 15-152 failed to parse SVG (missing root node at 1:1) // Warning: 8-14 `image.decode` is deprecated, directly pass bytes to `image` instead #image.decode(``.text, format: "svg") From 7c7b962b98a09c1baabdd03ff4ccad8f6d817b37 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Tue, 10 Jun 2025 11:41:16 -0300 Subject: [PATCH 138/558] Table multiple headers and subheaders (#6168) --- crates/typst-layout/src/grid/layouter.rs | 598 +++++++++++------ crates/typst-layout/src/grid/lines.rs | 29 +- crates/typst-layout/src/grid/repeated.rs | 476 +++++++++++++- crates/typst-layout/src/grid/rowspans.rs | 245 ++++--- crates/typst-library/src/foundations/int.rs | 17 +- crates/typst-library/src/layout/grid/mod.rs | 13 +- .../typst-library/src/layout/grid/resolve.rs | 489 +++++++++----- crates/typst-library/src/model/table.rs | 70 +- crates/typst-syntax/src/span.rs | 13 +- crates/typst-utils/src/lib.rs | 11 +- crates/typst-utils/src/pico.rs | 5 +- ...grid-footer-non-repeatable-unbreakable.png | Bin 0 -> 365 bytes .../grid-footer-repeatable-unbreakable.png | Bin 0 -> 340 bytes .../grid-header-and-large-auto-contiguous.png | Bin 0 -> 894 bytes .../grid-header-and-rowspan-contiguous-1.png | Bin 0 -> 815 bytes .../grid-header-and-rowspan-contiguous-2.png | Bin 0 -> 815 bytes tests/ref/grid-header-multiple.png | Bin 0 -> 214 bytes ...header-non-repeating-orphan-prevention.png | Bin 0 -> 453 bytes ...id-header-not-at-first-row-two-columns.png | Bin 0 -> 176 bytes tests/ref/grid-header-not-at-first-row.png | Bin 0 -> 176 bytes tests/ref/grid-header-not-at-the-top.png | Bin 0 -> 605 bytes tests/ref/grid-header-replace-doesnt-fit.png | Bin 0 -> 559 bytes tests/ref/grid-header-replace-orphan.png | Bin 0 -> 559 bytes tests/ref/grid-header-replace.png | Bin 0 -> 692 bytes tests/ref/grid-header-skip.png | Bin 0 -> 432 bytes ...-header-too-large-non-repeating-orphan.png | Bin 0 -> 372 bytes ...arge-repeating-orphan-not-at-first-row.png | Bin 0 -> 398 bytes ...too-large-repeating-orphan-with-footer.png | Bin 0 -> 576 bytes ...grid-header-too-large-repeating-orphan.png | Bin 0 -> 321 bytes ...-subheaders-alone-no-orphan-prevention.png | Bin 0 -> 254 bytes ...alone-with-footer-no-orphan-prevention.png | Bin 0 -> 378 bytes .../ref/grid-subheaders-alone-with-footer.png | Bin 0 -> 319 bytes ...gutter-and-footer-no-orphan-prevention.png | Bin 0 -> 382 bytes ...alone-with-gutter-no-orphan-prevention.png | Bin 0 -> 254 bytes tests/ref/grid-subheaders-alone.png | Bin 0 -> 256 bytes ...ders-basic-non-consecutive-with-footer.png | Bin 0 -> 279 bytes .../grid-subheaders-basic-non-consecutive.png | Bin 0 -> 256 bytes tests/ref/grid-subheaders-basic-replace.png | Bin 0 -> 321 bytes .../ref/grid-subheaders-basic-with-footer.png | Bin 0 -> 256 bytes tests/ref/grid-subheaders-basic.png | Bin 0 -> 210 bytes tests/ref/grid-subheaders-colorful.png | Bin 0 -> 11005 bytes tests/ref/grid-subheaders-demo.png | Bin 0 -> 5064 bytes ...multi-page-row-right-after-with-footer.png | Bin 0 -> 1207 bytes ...-subheaders-multi-page-row-right-after.png | Bin 0 -> 1127 bytes ...-subheaders-multi-page-row-with-footer.png | Bin 0 -> 1345 bytes tests/ref/grid-subheaders-multi-page-row.png | Bin 0 -> 1173 bytes ...d-subheaders-multi-page-rowspan-gutter.png | Bin 0 -> 1560 bytes ...headers-multi-page-rowspan-right-after.png | Bin 0 -> 1421 bytes ...headers-multi-page-rowspan-with-footer.png | Bin 0 -> 1190 bytes .../grid-subheaders-multi-page-rowspan.png | Bin 0 -> 1048 bytes .../grid-subheaders-non-repeat-replace.png | Bin 0 -> 878 bytes tests/ref/grid-subheaders-non-repeat.png | Bin 0 -> 614 bytes ...repeating-header-before-multi-page-row.png | Bin 0 -> 410 bytes ...eaders-non-repeating-orphan-prevention.png | Bin 0 -> 347 bytes ...s-non-repeating-replace-didnt-fit-once.png | Bin 0 -> 895 bytes ...ubheaders-non-repeating-replace-orphan.png | Bin 0 -> 964 bytes tests/ref/grid-subheaders-repeat-gutter.png | Bin 0 -> 503 bytes ...grid-subheaders-repeat-non-consecutive.png | Bin 0 -> 599 bytes ...bheaders-repeat-replace-didnt-fit-once.png | Bin 0 -> 877 bytes ...ubheaders-repeat-replace-double-orphan.png | Bin 0 -> 950 bytes ...-repeat-replace-gutter-orphan-at-child.png | Bin 0 -> 806 bytes ...repeat-replace-gutter-orphan-at-gutter.png | Bin 0 -> 758 bytes .../grid-subheaders-repeat-replace-gutter.png | Bin 0 -> 782 bytes ...headers-repeat-replace-multiple-levels.png | Bin 0 -> 877 bytes .../grid-subheaders-repeat-replace-orphan.png | Bin 0 -> 939 bytes ...-subheaders-repeat-replace-short-lived.png | Bin 0 -> 795 bytes ...ders-repeat-replace-with-footer-orphan.png | Bin 0 -> 961 bytes ...-subheaders-repeat-replace-with-footer.png | Bin 0 -> 992 bytes tests/ref/grid-subheaders-repeat-replace.png | Bin 0 -> 953 bytes ...aders-repeat-short-lived-also-replaces.png | Bin 0 -> 899 bytes .../grid-subheaders-repeat-with-footer.png | Bin 0 -> 584 bytes tests/ref/grid-subheaders-repeat.png | Bin 0 -> 472 bytes ...subheaders-repeating-orphan-prevention.png | Bin 0 -> 347 bytes ...aders-short-lived-no-orphan-prevention.png | Bin 0 -> 287 bytes ...large-non-repeating-orphan-before-auto.png | Bin 0 -> 460 bytes ...e-non-repeating-orphan-before-relative.png | Bin 0 -> 542 bytes ...too-large-repeating-orphan-before-auto.png | Bin 0 -> 525 bytes ...large-repeating-orphan-before-relative.png | Bin 0 -> 437 bytes tests/ref/html/multi-header-inside-table.html | 69 ++ tests/ref/html/multi-header-table.html | 49 ++ ...59-column-override-stays-inside-header.png | Bin 0 -> 674 bytes tests/suite/layout/grid/footers.typ | 23 + tests/suite/layout/grid/headers.typ | 171 ++++- tests/suite/layout/grid/html.typ | 75 +++ tests/suite/layout/grid/subheaders.typ | 602 ++++++++++++++++++ 85 files changed, 2444 insertions(+), 511 deletions(-) create mode 100644 tests/ref/grid-footer-non-repeatable-unbreakable.png create mode 100644 tests/ref/grid-footer-repeatable-unbreakable.png create mode 100644 tests/ref/grid-header-and-large-auto-contiguous.png create mode 100644 tests/ref/grid-header-and-rowspan-contiguous-1.png create mode 100644 tests/ref/grid-header-and-rowspan-contiguous-2.png create mode 100644 tests/ref/grid-header-multiple.png create mode 100644 tests/ref/grid-header-non-repeating-orphan-prevention.png create mode 100644 tests/ref/grid-header-not-at-first-row-two-columns.png create mode 100644 tests/ref/grid-header-not-at-first-row.png create mode 100644 tests/ref/grid-header-not-at-the-top.png create mode 100644 tests/ref/grid-header-replace-doesnt-fit.png create mode 100644 tests/ref/grid-header-replace-orphan.png create mode 100644 tests/ref/grid-header-replace.png create mode 100644 tests/ref/grid-header-skip.png create mode 100644 tests/ref/grid-header-too-large-non-repeating-orphan.png create mode 100644 tests/ref/grid-header-too-large-repeating-orphan-not-at-first-row.png create mode 100644 tests/ref/grid-header-too-large-repeating-orphan-with-footer.png create mode 100644 tests/ref/grid-header-too-large-repeating-orphan.png create mode 100644 tests/ref/grid-subheaders-alone-no-orphan-prevention.png create mode 100644 tests/ref/grid-subheaders-alone-with-footer-no-orphan-prevention.png create mode 100644 tests/ref/grid-subheaders-alone-with-footer.png create mode 100644 tests/ref/grid-subheaders-alone-with-gutter-and-footer-no-orphan-prevention.png create mode 100644 tests/ref/grid-subheaders-alone-with-gutter-no-orphan-prevention.png create mode 100644 tests/ref/grid-subheaders-alone.png create mode 100644 tests/ref/grid-subheaders-basic-non-consecutive-with-footer.png create mode 100644 tests/ref/grid-subheaders-basic-non-consecutive.png create mode 100644 tests/ref/grid-subheaders-basic-replace.png create mode 100644 tests/ref/grid-subheaders-basic-with-footer.png create mode 100644 tests/ref/grid-subheaders-basic.png create mode 100644 tests/ref/grid-subheaders-colorful.png create mode 100644 tests/ref/grid-subheaders-demo.png create mode 100644 tests/ref/grid-subheaders-multi-page-row-right-after-with-footer.png create mode 100644 tests/ref/grid-subheaders-multi-page-row-right-after.png create mode 100644 tests/ref/grid-subheaders-multi-page-row-with-footer.png create mode 100644 tests/ref/grid-subheaders-multi-page-row.png create mode 100644 tests/ref/grid-subheaders-multi-page-rowspan-gutter.png create mode 100644 tests/ref/grid-subheaders-multi-page-rowspan-right-after.png create mode 100644 tests/ref/grid-subheaders-multi-page-rowspan-with-footer.png create mode 100644 tests/ref/grid-subheaders-multi-page-rowspan.png create mode 100644 tests/ref/grid-subheaders-non-repeat-replace.png create mode 100644 tests/ref/grid-subheaders-non-repeat.png create mode 100644 tests/ref/grid-subheaders-non-repeating-header-before-multi-page-row.png create mode 100644 tests/ref/grid-subheaders-non-repeating-orphan-prevention.png create mode 100644 tests/ref/grid-subheaders-non-repeating-replace-didnt-fit-once.png create mode 100644 tests/ref/grid-subheaders-non-repeating-replace-orphan.png create mode 100644 tests/ref/grid-subheaders-repeat-gutter.png create mode 100644 tests/ref/grid-subheaders-repeat-non-consecutive.png create mode 100644 tests/ref/grid-subheaders-repeat-replace-didnt-fit-once.png create mode 100644 tests/ref/grid-subheaders-repeat-replace-double-orphan.png create mode 100644 tests/ref/grid-subheaders-repeat-replace-gutter-orphan-at-child.png create mode 100644 tests/ref/grid-subheaders-repeat-replace-gutter-orphan-at-gutter.png create mode 100644 tests/ref/grid-subheaders-repeat-replace-gutter.png create mode 100644 tests/ref/grid-subheaders-repeat-replace-multiple-levels.png create mode 100644 tests/ref/grid-subheaders-repeat-replace-orphan.png create mode 100644 tests/ref/grid-subheaders-repeat-replace-short-lived.png create mode 100644 tests/ref/grid-subheaders-repeat-replace-with-footer-orphan.png create mode 100644 tests/ref/grid-subheaders-repeat-replace-with-footer.png create mode 100644 tests/ref/grid-subheaders-repeat-replace.png create mode 100644 tests/ref/grid-subheaders-repeat-short-lived-also-replaces.png create mode 100644 tests/ref/grid-subheaders-repeat-with-footer.png create mode 100644 tests/ref/grid-subheaders-repeat.png create mode 100644 tests/ref/grid-subheaders-repeating-orphan-prevention.png create mode 100644 tests/ref/grid-subheaders-short-lived-no-orphan-prevention.png create mode 100644 tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-auto.png create mode 100644 tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-relative.png create mode 100644 tests/ref/grid-subheaders-too-large-repeating-orphan-before-auto.png create mode 100644 tests/ref/grid-subheaders-too-large-repeating-orphan-before-relative.png create mode 100644 tests/ref/html/multi-header-inside-table.html create mode 100644 tests/ref/html/multi-header-table.html create mode 100644 tests/ref/issue-5359-column-override-stays-inside-header.png create mode 100644 tests/suite/layout/grid/subheaders.typ diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 99b85eddb1..42fe38dbe0 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -3,7 +3,9 @@ use std::fmt::Debug; use typst_library::diag::{bail, SourceResult}; use typst_library::engine::Engine; use typst_library::foundations::{Resolve, StyleChain}; -use typst_library::layout::grid::resolve::{Cell, CellGrid, LinePosition, Repeatable}; +use typst_library::layout::grid::resolve::{ + Cell, CellGrid, Header, LinePosition, Repeatable, +}; use typst_library::layout::{ Abs, Axes, Dir, Fr, Fragment, Frame, FrameItem, Length, Point, Region, Regions, Rel, Size, Sizing, @@ -30,10 +32,8 @@ pub struct GridLayouter<'a> { pub(super) rcols: Vec, /// The sum of `rcols`. pub(super) width: Abs, - /// Resolve row sizes, by region. + /// Resolved row sizes, by region. pub(super) rrows: Vec>, - /// Rows in the current region. - pub(super) lrows: Vec, /// The amount of unbreakable rows remaining to be laid out in the /// current unbreakable row group. While this is positive, no region breaks /// should occur. @@ -41,24 +41,155 @@ pub struct GridLayouter<'a> { /// Rowspans not yet laid out because not all of their spanned rows were /// laid out yet. pub(super) rowspans: Vec, - /// The initial size of the current region before we started subtracting. - pub(super) initial: Size, + /// Grid layout state for the current region. + pub(super) current: Current, /// Frames for finished regions. pub(super) finished: Vec, + /// The amount and height of header rows on each finished region. + pub(super) finished_header_rows: Vec, /// Whether this is an RTL grid. pub(super) is_rtl: bool, - /// The simulated header height. - /// This field is reset in `layout_header` and properly updated by + /// Currently repeating headers, one per level. Sorted by increasing + /// levels. + /// + /// Note that some levels may be absent, in particular level 0, which does + /// not exist (so all levels are >= 1). + pub(super) repeating_headers: Vec<&'a Header>, + /// Headers, repeating or not, awaiting their first successful layout. + /// Sorted by increasing levels. + pub(super) pending_headers: &'a [Repeatable

], + /// Next headers to be processed. + pub(super) upcoming_headers: &'a [Repeatable
], + /// State of the row being currently laid out. + /// + /// This is kept as a field to avoid passing down too many parameters from + /// `layout_row` into called functions, which would then have to pass them + /// down to `push_row`, which reads these values. + pub(super) row_state: RowState, + /// The span of the grid element. + pub(super) span: Span, +} + +/// Grid layout state for the current region. This should be reset or updated +/// on each region break. +pub(super) struct Current { + /// The initial size of the current region before we started subtracting. + pub(super) initial: Size, + /// The height of the region after repeated headers were placed and footers + /// prepared. This also includes pending repeating headers from the start, + /// even if they were not repeated yet, since they will be repeated in the + /// next region anyway (bar orphan prevention). + /// + /// This is used to quickly tell if any additional space in the region has + /// been occupied since then, meaning that additional space will become + /// available after a region break (see + /// [`GridLayouter::may_progress_with_repeats`]). + pub(super) initial_after_repeats: Abs, + /// Whether `layouter.regions.may_progress()` was `true` at the top of the + /// region. + pub(super) could_progress_at_top: bool, + /// Rows in the current region. + pub(super) lrows: Vec, + /// The amount of repeated header rows at the start of the current region. + /// Thus, excludes rows from pending headers (which were placed for the + /// first time). + /// + /// Note that `repeating_headers` and `pending_headers` can change if we + /// find a new header inside the region (not at the top), so this field + /// is required to access information from the top of the region. + /// + /// This information is used on finish region to calculate the total height + /// of resolved header rows at the top of the region, which is used by + /// multi-page rowspans so they can properly skip the header rows at the + /// top of each region during layout. + pub(super) repeated_header_rows: usize, + /// The end bound of the row range of the last repeating header at the + /// start of the region. + /// + /// The last row might have disappeared from layout due to being empty, so + /// this is how we can become aware of where the last header ends without + /// having to check the vector of rows. Line layout uses this to determine + /// when to prioritize the last lines under a header. + /// + /// A value of zero indicates no repeated headers were placed. + pub(super) last_repeated_header_end: usize, + /// Stores the length of `lrows` before a sequence of rows equipped with + /// orphan prevention was laid out. In this case, if no more rows without + /// orphan prevention are laid out after those rows before the region ends, + /// the rows will be removed, and there may be an attempt to place them + /// again in the new region. Effectively, this is the mechanism used for + /// orphan prevention of rows. + /// + /// At the moment, this is only used by repeated headers (they aren't laid + /// out if alone in the region) and by new headers, which are moved to the + /// `pending_headers` vector and so will automatically be placed again + /// until they fit and are not orphans in at least one region (or exactly + /// one, for non-repeated headers). + pub(super) lrows_orphan_snapshot: Option, + /// The height of effectively repeating headers, that is, ignoring + /// non-repeating pending headers, in the current region. + /// + /// This is used by multi-page auto rows so they can inform cell layout on + /// how much space should be taken by headers if they break across regions. + /// In particular, non-repeating headers only occupy the initial region, + /// but disappear on new regions, so they can be ignored. + /// + /// This field is reset on each new region and properly updated by /// `layout_auto_row` and `layout_relative_row`, and should not be read /// before all header rows are fully laid out. It is usually fine because /// header rows themselves are unbreakable, and unbreakable rows do not /// need to read this field at all. - pub(super) header_height: Abs, + /// + /// This height is not only computed at the beginning of the region. It is + /// updated whenever a new header is found, subtracting the height of + /// headers which stopped repeating and adding the height of all new + /// headers. + pub(super) repeating_header_height: Abs, + /// The height for each repeating header that was placed in this region. + /// Note that this includes headers not at the top of the region, before + /// their first repetition (pending headers), and excludes headers removed + /// by virtue of a new, conflicting header being found (short-lived + /// headers). + /// + /// This is used to know how much to update `repeating_header_height` by + /// when finding a new header and causing existing repeating headers to + /// stop. + pub(super) repeating_header_heights: Vec, /// The simulated footer height for this region. + /// /// The simulation occurs before any rows are laid out for a region. pub(super) footer_height: Abs, - /// The span of the grid element. - pub(super) span: Span, +} + +/// Data about the row being laid out right now. +#[derive(Debug, Default)] +pub(super) struct RowState { + /// If this is `Some`, this will be updated by the currently laid out row's + /// height if it is auto or relative. This is used for header height + /// calculation. + pub(super) current_row_height: Option, + /// This is `true` when laying out non-short lived headers and footers. + /// That is, headers and footers which are not immediately followed or + /// preceded (respectively) by conflicting headers and footers of same or + /// lower level, or the end or start of the table (respectively), which + /// would cause them to never repeat, even once. + /// + /// If this is `false`, the next row to be laid out will remove an active + /// orphan snapshot and will flush pending headers, as there is no risk + /// that they will be orphans anymore. + pub(super) in_active_repeatable: bool, +} + +/// Data about laid out repeated header rows for a specific finished region. +#[derive(Debug, Default)] +pub(super) struct FinishedHeaderRowInfo { + /// The amount of repeated headers at the top of the region. + pub(super) repeated_amount: usize, + /// The end bound of the row range of the last repeated header at the top + /// of the region. + pub(super) last_repeated_header_end: usize, + /// The total height of repeated headers at the top of the region. + pub(super) repeated_height: Abs, } /// Details about a resulting row piece. @@ -114,14 +245,27 @@ impl<'a> GridLayouter<'a> { rcols: vec![Abs::zero(); grid.cols.len()], width: Abs::zero(), rrows: vec![], - lrows: vec![], unbreakable_rows_left: 0, rowspans: vec![], - initial: regions.size, finished: vec![], + finished_header_rows: vec![], is_rtl: TextElem::dir_in(styles) == Dir::RTL, - header_height: Abs::zero(), - footer_height: Abs::zero(), + repeating_headers: vec![], + upcoming_headers: &grid.headers, + pending_headers: Default::default(), + row_state: RowState::default(), + current: Current { + initial: regions.size, + initial_after_repeats: regions.size.y, + could_progress_at_top: regions.may_progress(), + lrows: vec![], + repeated_header_rows: 0, + last_repeated_header_end: 0, + lrows_orphan_snapshot: None, + repeating_header_height: Abs::zero(), + repeating_header_heights: vec![], + footer_height: Abs::zero(), + }, span, } } @@ -130,38 +274,57 @@ impl<'a> GridLayouter<'a> { pub fn layout(mut self, engine: &mut Engine) -> SourceResult { self.measure_columns(engine)?; - if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { - // Ensure rows in the first region will be aware of the possible - // presence of the footer. - self.prepare_footer(footer, engine, 0)?; - if matches!(self.grid.header, None | Some(Repeatable::NotRepeated(_))) { - // No repeatable header, so we won't subtract it later. - self.regions.size.y -= self.footer_height; + if let Some(footer) = &self.grid.footer { + if footer.repeated { + // Ensure rows in the first region will be aware of the + // possible presence of the footer. + self.prepare_footer(footer, engine, 0)?; + self.regions.size.y -= self.current.footer_height; + self.current.initial_after_repeats = self.regions.size.y; } } - for y in 0..self.grid.rows.len() { - if let Some(Repeatable::Repeated(header)) = &self.grid.header { - if y < header.end { - if y == 0 { - self.layout_header(header, engine, 0)?; - self.regions.size.y -= self.footer_height; - } + let mut y = 0; + let mut consecutive_header_count = 0; + while y < self.grid.rows.len() { + if let Some(next_header) = self.upcoming_headers.get(consecutive_header_count) + { + if next_header.range.contains(&y) { + self.place_new_headers(&mut consecutive_header_count, engine)?; + y = next_header.range.end; + // Skip header rows during normal layout. continue; } } - if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { - if y >= footer.start { + if let Some(footer) = &self.grid.footer { + if footer.repeated && y >= footer.start { if y == footer.start { self.layout_footer(footer, engine, self.finished.len())?; + self.flush_orphans(); } + y = footer.end; continue; } } self.layout_row(y, engine, 0)?; + + // After the first non-header row is placed, pending headers are no + // longer orphans and can repeat, so we move them to repeating + // headers. + // + // Note that this is usually done in `push_row`, since the call to + // `layout_row` above might trigger region breaks (for multi-page + // auto rows), whereas this needs to be called as soon as any part + // of a row is laid out. However, it's possible a row has no + // visible output and thus does not push any rows even though it + // was successfully laid out, in which case we additionally flush + // here just in case. + self.flush_orphans(); + + y += 1; } self.finish_region(engine, true)?; @@ -184,12 +347,46 @@ impl<'a> GridLayouter<'a> { self.render_fills_strokes() } - /// Layout the given row. + /// Layout a row with a certain initial state, returning the final state. + #[inline] + pub(super) fn layout_row_with_state( + &mut self, + y: usize, + engine: &mut Engine, + disambiguator: usize, + initial_state: RowState, + ) -> SourceResult { + // Keep a copy of the previous value in the stack, as this function can + // call itself recursively (e.g. if a region break is triggered and a + // header is placed), so we shouldn't outright overwrite it, but rather + // save and later restore the state when back to this call. + let previous = std::mem::replace(&mut self.row_state, initial_state); + + // Keep it as a separate function to allow inlining the return below, + // as it's usually not needed. + self.layout_row_internal(y, engine, disambiguator)?; + + Ok(std::mem::replace(&mut self.row_state, previous)) + } + + /// Layout the given row with the default row state. + #[inline] pub(super) fn layout_row( &mut self, y: usize, engine: &mut Engine, disambiguator: usize, + ) -> SourceResult<()> { + self.layout_row_with_state(y, engine, disambiguator, RowState::default())?; + Ok(()) + } + + /// Layout the given row using the current state. + pub(super) fn layout_row_internal( + &mut self, + y: usize, + engine: &mut Engine, + disambiguator: usize, ) -> SourceResult<()> { // Skip to next region if current one is full, but only for content // rows, not for gutter rows, and only if we aren't laying out an @@ -206,13 +403,18 @@ impl<'a> GridLayouter<'a> { } // Don't layout gutter rows at the top of a region. - if is_content_row || !self.lrows.is_empty() { + if is_content_row || !self.current.lrows.is_empty() { match self.grid.rows[y] { Sizing::Auto => self.layout_auto_row(engine, disambiguator, y)?, Sizing::Rel(v) => { self.layout_relative_row(engine, disambiguator, v, y)? } - Sizing::Fr(v) => self.lrows.push(Row::Fr(v, y, disambiguator)), + Sizing::Fr(v) => { + if !self.row_state.in_active_repeatable { + self.flush_orphans(); + } + self.current.lrows.push(Row::Fr(v, y, disambiguator)) + } } } @@ -225,8 +427,13 @@ impl<'a> GridLayouter<'a> { fn render_fills_strokes(mut self) -> SourceResult { let mut finished = std::mem::take(&mut self.finished); let frame_amount = finished.len(); - for ((frame_index, frame), rows) in - finished.iter_mut().enumerate().zip(&self.rrows) + for (((frame_index, frame), rows), finished_header_rows) in + finished.iter_mut().enumerate().zip(&self.rrows).zip( + self.finished_header_rows + .iter() + .map(Some) + .chain(std::iter::repeat(None)), + ) { if self.rcols.is_empty() || rows.is_empty() { continue; @@ -347,7 +554,8 @@ impl<'a> GridLayouter<'a> { let hline_indices = rows .iter() .map(|piece| piece.y) - .chain(std::iter::once(self.grid.rows.len())); + .chain(std::iter::once(self.grid.rows.len())) + .enumerate(); // Converts a row to the corresponding index in the vector of // hlines. @@ -372,7 +580,7 @@ impl<'a> GridLayouter<'a> { }; let mut prev_y = None; - for (y, dy) in hline_indices.zip(hline_offsets) { + for ((i, y), dy) in hline_indices.zip(hline_offsets) { // Position of lines below the row index in the previous iteration. let expected_prev_line_position = prev_y .map(|prev_y| { @@ -383,47 +591,40 @@ impl<'a> GridLayouter<'a> { }) .unwrap_or(LinePosition::Before); - // FIXME: In the future, directly specify in 'self.rrows' when - // we place a repeated header rather than its original rows. - // That would let us remove most of those verbose checks, both - // in 'lines.rs' and here. Those checks also aren't fully - // accurate either, since they will also trigger when some rows - // have been removed between the header and what's below it. - let is_under_repeated_header = self - .grid - .header - .as_ref() - .and_then(Repeatable::as_repeated) - .zip(prev_y) - .is_some_and(|(header, prev_y)| { - // Note: 'y == header.end' would mean we're right below - // the NON-REPEATED header, so that case should return - // false. - prev_y < header.end && y > header.end - }); + // Header's lines at the bottom have priority when repeated. + // This will store the end bound of the last header if the + // current iteration is calculating lines under it. + let last_repeated_header_end_above = match finished_header_rows { + Some(info) if prev_y.is_some() && i == info.repeated_amount => { + Some(info.last_repeated_header_end) + } + _ => None, + }; // If some grid rows were omitted between the previous resolved // row and the current one, we ensure lines below the previous // row don't "disappear" and are considered, albeit with less // priority. However, don't do this when we're below a header, // as it must have more priority instead of less, so it is - // chained later instead of before. The exception is when the + // chained later instead of before (stored in the + // 'header_hlines' variable below). The exception is when the // last row in the header is removed, in which case we append // both the lines under the row above us and also (later) the // lines under the header's (removed) last row. - let prev_lines = prev_y - .filter(|prev_y| { - prev_y + 1 != y - && (!is_under_repeated_header - || self - .grid - .header - .as_ref() - .and_then(Repeatable::as_repeated) - .is_some_and(|header| prev_y + 1 != header.end)) - }) - .map(|prev_y| get_hlines_at(prev_y + 1)) - .unwrap_or(&[]); + let prev_lines = match prev_y { + Some(prev_y) + if prev_y + 1 != y + && last_repeated_header_end_above.is_none_or( + |last_repeated_header_end| { + prev_y + 1 != last_repeated_header_end + }, + ) => + { + get_hlines_at(prev_y + 1) + } + + _ => &[], + }; let expected_hline_position = expected_line_position(y, y == self.grid.rows.len()); @@ -441,15 +642,13 @@ impl<'a> GridLayouter<'a> { }; let mut expected_header_line_position = LinePosition::Before; - let header_hlines = if let Some((Repeatable::Repeated(header), prev_y)) = - self.grid.header.as_ref().zip(prev_y) - { - if is_under_repeated_header - && (!self.grid.has_gutter + let header_hlines = match (last_repeated_header_end_above, prev_y) { + (Some(header_end_above), Some(prev_y)) + if !self.grid.has_gutter || matches!( self.grid.rows[prev_y], Sizing::Rel(length) if length.is_zero() - )) + ) => { // For lines below a header, give priority to the // lines originally below the header rather than @@ -468,15 +667,13 @@ impl<'a> GridLayouter<'a> { // column-gutter is specified, for example. In that // case, we still repeat the line under the gutter. expected_header_line_position = expected_line_position( - header.end, - header.end == self.grid.rows.len(), + header_end_above, + header_end_above == self.grid.rows.len(), ); - get_hlines_at(header.end) - } else { - &[] + get_hlines_at(header_end_above) } - } else { - &[] + + _ => &[], }; // The effective hlines to be considered at this row index are @@ -529,6 +726,7 @@ impl<'a> GridLayouter<'a> { grid, rows, local_top_y, + last_repeated_header_end_above, in_last_region, y, x, @@ -941,15 +1139,9 @@ impl<'a> GridLayouter<'a> { let frame = self.layout_single_row(engine, disambiguator, first, y)?; self.push_row(frame, y, true); - if self - .grid - .header - .as_ref() - .and_then(Repeatable::as_repeated) - .is_some_and(|header| y < header.end) - { - // Add to header height. - self.header_height += first; + if let Some(row_height) = &mut self.row_state.current_row_height { + // Add to header height, as we are in a header row. + *row_height += first; } return Ok(()); @@ -958,19 +1150,21 @@ impl<'a> GridLayouter<'a> { // Expand all but the last region. // Skip the first region if the space is eaten up by an fr row. let len = resolved.len(); - for ((i, region), target) in self - .regions - .iter() - .enumerate() - .zip(&mut resolved[..len - 1]) - .skip(self.lrows.iter().any(|row| matches!(row, Row::Fr(..))) as usize) + for ((i, region), target) in + self.regions + .iter() + .enumerate() + .zip(&mut resolved[..len - 1]) + .skip(self.current.lrows.iter().any(|row| matches!(row, Row::Fr(..))) + as usize) { // Subtract header and footer heights from the region height when - // it's not the first. + // it's not the first. Ignore non-repeating headers as they only + // appear on the first region by definition. target.set_max( region.y - if i > 0 { - self.header_height + self.footer_height + self.current.repeating_header_height + self.current.footer_height } else { Abs::zero() }, @@ -1181,25 +1375,19 @@ impl<'a> GridLayouter<'a> { let resolved = v.resolve(self.styles).relative_to(self.regions.base().y); let frame = self.layout_single_row(engine, disambiguator, resolved, y)?; - if self - .grid - .header - .as_ref() - .and_then(Repeatable::as_repeated) - .is_some_and(|header| y < header.end) - { - // Add to header height. - self.header_height += resolved; + if let Some(row_height) = &mut self.row_state.current_row_height { + // Add to header height, as we are in a header row. + *row_height += resolved; } // Skip to fitting region, but only if we aren't part of an unbreakable - // row group. We use 'in_last_with_offset' so our 'in_last' call - // properly considers that a header and a footer would be added on each - // region break. + // row group. We use 'may_progress_with_repeats' to stop trying if we + // would skip to a region with the same height and where the same + // headers would be repeated. let height = frame.height(); while self.unbreakable_rows_left == 0 && !self.regions.size.y.fits(height) - && !in_last_with_offset(self.regions, self.header_height + self.footer_height) + && self.may_progress_with_repeats() { self.finish_region(engine, false)?; @@ -1323,8 +1511,13 @@ impl<'a> GridLayouter<'a> { /// will be pushed for this particular row. It can be `false` for rows /// spanning multiple regions. fn push_row(&mut self, frame: Frame, y: usize, is_last: bool) { + if !self.row_state.in_active_repeatable { + // There is now a row after the rows equipped with orphan + // prevention, so no need to keep moving them anymore. + self.flush_orphans(); + } self.regions.size.y -= frame.height(); - self.lrows.push(Row::Frame(frame, y, is_last)); + self.current.lrows.push(Row::Frame(frame, y, is_last)); } /// Finish rows for one region. @@ -1333,68 +1526,73 @@ impl<'a> GridLayouter<'a> { engine: &mut Engine, last: bool, ) -> SourceResult<()> { + // The latest rows have orphan prevention (headers) and no other rows + // were placed, so remove those rows and try again in a new region, + // unless this is the last region. + if let Some(orphan_snapshot) = self.current.lrows_orphan_snapshot.take() { + if !last { + self.current.lrows.truncate(orphan_snapshot); + self.current.repeated_header_rows = + self.current.repeated_header_rows.min(orphan_snapshot); + + if orphan_snapshot == 0 { + // Removed all repeated headers. + self.current.last_repeated_header_end = 0; + } + } + } + if self + .current .lrows .last() .is_some_and(|row| self.grid.is_gutter_track(row.index())) { // Remove the last row in the region if it is a gutter row. - self.lrows.pop().unwrap(); + self.current.lrows.pop().unwrap(); + self.current.repeated_header_rows = + self.current.repeated_header_rows.min(self.current.lrows.len()); } - // If no rows other than the footer have been laid out so far, and - // there are rows beside the footer, then don't lay it out at all. - // This check doesn't apply, and is thus overridden, when there is a - // header. - let mut footer_would_be_orphan = self.lrows.is_empty() - && !in_last_with_offset( - self.regions, - self.header_height + self.footer_height, - ) - && self - .grid - .footer - .as_ref() - .and_then(Repeatable::as_repeated) - .is_some_and(|footer| footer.start != 0); - - if let Some(Repeatable::Repeated(header)) = &self.grid.header { - if self.grid.rows.len() > header.end - && self - .grid - .footer - .as_ref() - .and_then(Repeatable::as_repeated) - .is_none_or(|footer| footer.start != header.end) - && self.lrows.last().is_some_and(|row| row.index() < header.end) - && !in_last_with_offset( - self.regions, - self.header_height + self.footer_height, - ) - { - // Header and footer would be alone in this region, but there are more - // rows beyond the header and the footer. Push an empty region. - self.lrows.clear(); - footer_would_be_orphan = true; - } - } + // If no rows other than the footer have been laid out so far + // (e.g. due to header orphan prevention), and there are rows + // beside the footer, then don't lay it out at all. + // + // It is worth noting that the footer is made non-repeatable at + // the grid resolving stage if it is short-lived, that is, if + // it is at the start of the table (or right after headers at + // the start of the table). + // + // TODO(subfooters): explicitly check for short-lived footers. + // TODO(subfooters): widow prevention for non-repeated footers with a + // similar mechanism / when implementing multiple footers. + let footer_would_be_widow = matches!(&self.grid.footer, Some(footer) if footer.repeated) + && self.current.lrows.is_empty() + && self.current.could_progress_at_top; let mut laid_out_footer_start = None; - if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { - // Don't layout the footer if it would be alone with the header in - // the page, and don't layout it twice. - if !footer_would_be_orphan - && self.lrows.iter().all(|row| row.index() < footer.start) - { - laid_out_footer_start = Some(footer.start); - self.layout_footer(footer, engine, self.finished.len())?; + if !footer_would_be_widow { + if let Some(footer) = &self.grid.footer { + // Don't layout the footer if it would be alone with the header + // in the page (hence the widow check), and don't layout it + // twice (check below). + // + // TODO(subfooters): this check can be replaced by a vector of + // repeating footers in the future, and/or some "pending + // footers" vector for footers we're about to place. + if footer.repeated + && self.current.lrows.iter().all(|row| row.index() < footer.start) + { + laid_out_footer_start = Some(footer.start); + self.layout_footer(footer, engine, self.finished.len())?; + } } } // Determine the height of existing rows in the region. let mut used = Abs::zero(); let mut fr = Fr::zero(); - for row in &self.lrows { + for row in &self.current.lrows { match row { Row::Frame(frame, _, _) => used += frame.height(), Row::Fr(v, _, _) => fr += *v, @@ -1403,9 +1601,9 @@ impl<'a> GridLayouter<'a> { // Determine the size of the grid in this region, expanding fully if // there are fr rows. - let mut size = Size::new(self.width, used).min(self.initial); - if fr.get() > 0.0 && self.initial.y.is_finite() { - size.y = self.initial.y; + let mut size = Size::new(self.width, used).min(self.current.initial); + if fr.get() > 0.0 && self.current.initial.y.is_finite() { + size.y = self.current.initial.y; } // The frame for the region. @@ -1413,9 +1611,10 @@ impl<'a> GridLayouter<'a> { let mut pos = Point::zero(); let mut rrows = vec![]; let current_region = self.finished.len(); + let mut repeated_header_row_height = Abs::zero(); // Place finished rows and layout fractional rows. - for row in std::mem::take(&mut self.lrows) { + for (i, row) in std::mem::take(&mut self.current.lrows).into_iter().enumerate() { let (frame, y, is_last) = match row { Row::Frame(frame, y, is_last) => (frame, y, is_last), Row::Fr(v, y, disambiguator) => { @@ -1426,6 +1625,9 @@ impl<'a> GridLayouter<'a> { }; let height = frame.height(); + if i < self.current.repeated_header_rows { + repeated_header_row_height += height; + } // Ensure rowspans which span this row will have enough space to // be laid out over it later. @@ -1504,7 +1706,11 @@ impl<'a> GridLayouter<'a> { // we have to check the same index again in the next // iteration. let rowspan = self.rowspans.remove(i); - self.layout_rowspan(rowspan, Some((&mut output, &rrows)), engine)?; + self.layout_rowspan( + rowspan, + Some((&mut output, repeated_header_row_height)), + engine, + )?; } else { i += 1; } @@ -1515,21 +1721,40 @@ impl<'a> GridLayouter<'a> { pos.y += height; } - self.finish_region_internal(output, rrows); + self.finish_region_internal( + output, + rrows, + FinishedHeaderRowInfo { + repeated_amount: self.current.repeated_header_rows, + last_repeated_header_end: self.current.last_repeated_header_end, + repeated_height: repeated_header_row_height, + }, + ); if !last { + self.current.repeated_header_rows = 0; + self.current.last_repeated_header_end = 0; + self.current.repeating_header_height = Abs::zero(); + self.current.repeating_header_heights.clear(); + let disambiguator = self.finished.len(); - if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { + if let Some(footer) = + self.grid.footer.as_ref().and_then(Repeatable::as_repeated) + { self.prepare_footer(footer, engine, disambiguator)?; } - if let Some(Repeatable::Repeated(header)) = &self.grid.header { - // Add a header to the new region. - self.layout_header(header, engine, disambiguator)?; - } - // Ensure rows don't try to overrun the footer. - self.regions.size.y -= self.footer_height; + // Note that header layout will only subtract this again if it has + // to skip regions to fit headers, so there is no risk of + // subtracting this twice. + self.regions.size.y -= self.current.footer_height; + self.current.initial_after_repeats = self.regions.size.y; + + if !self.repeating_headers.is_empty() || !self.pending_headers.is_empty() { + // Add headers to the new region. + self.layout_active_headers(engine)?; + } } Ok(()) @@ -1541,11 +1766,26 @@ impl<'a> GridLayouter<'a> { &mut self, output: Frame, resolved_rows: Vec, + header_row_info: FinishedHeaderRowInfo, ) { self.finished.push(output); self.rrows.push(resolved_rows); self.regions.next(); - self.initial = self.regions.size; + self.current.initial = self.regions.size; + + // Repeats haven't been laid out yet, so in the meantime, this will + // represent the initial height after repeats laid out so far, and will + // be gradually updated when preparing footers and repeating headers. + self.current.initial_after_repeats = self.current.initial.y; + + self.current.could_progress_at_top = self.regions.may_progress(); + + if !self.grid.headers.is_empty() { + self.finished_header_rows.push(header_row_info); + } + + // Ensure orphan prevention is handled before resolving rows. + debug_assert!(self.current.lrows_orphan_snapshot.is_none()); } } @@ -1560,13 +1800,3 @@ pub(super) fn points( offset }) } - -/// Checks if the first region of a sequence of regions is the last usable -/// region, assuming that the last region will always be occupied by some -/// specific offset height, even after calling `.next()`, due to some -/// additional logic which adds content automatically on each region turn (in -/// our case, headers). -pub(super) fn in_last_with_offset(regions: Regions<'_>, offset: Abs) -> bool { - regions.backlog.is_empty() - && regions.last.is_none_or(|height| regions.size.y + offset == height) -} diff --git a/crates/typst-layout/src/grid/lines.rs b/crates/typst-layout/src/grid/lines.rs index 7549673f1d..d5da7e2639 100644 --- a/crates/typst-layout/src/grid/lines.rs +++ b/crates/typst-layout/src/grid/lines.rs @@ -391,10 +391,12 @@ pub fn vline_stroke_at_row( /// /// This function assumes columns are sorted by increasing `x`, and rows are /// sorted by increasing `y`. +#[allow(clippy::too_many_arguments)] pub fn hline_stroke_at_column( grid: &CellGrid, rows: &[RowPiece], local_top_y: Option, + header_end_above: Option, in_last_region: bool, y: usize, x: usize, @@ -499,17 +501,15 @@ pub fn hline_stroke_at_column( // Top border stroke and header stroke are generally prioritized, unless // they don't have explicit hline overrides and one or more user-provided // hlines would appear at the same position, which then are prioritized. - let top_stroke_comes_from_header = grid - .header - .as_ref() - .and_then(Repeatable::as_repeated) - .zip(local_top_y) - .is_some_and(|(header, local_top_y)| { - // Ensure the row above us is a repeated header. - // FIXME: Make this check more robust when headers at arbitrary - // positions are added. - local_top_y < header.end && y > header.end - }); + let top_stroke_comes_from_header = header_end_above.zip(local_top_y).is_some_and( + |(last_repeated_header_end, local_top_y)| { + // Check if the last repeated header row is above this line. + // + // Note that `y == last_repeated_header_end` is impossible for a + // strictly repeated header (not in its original position). + local_top_y < last_repeated_header_end && y > last_repeated_header_end + }, + ); // Prioritize the footer's top stroke as well where applicable. let bottom_stroke_comes_from_footer = grid @@ -637,7 +637,7 @@ mod test { }, vec![], vec![], - None, + vec![], None, entries, ) @@ -1175,7 +1175,7 @@ mod test { }, vec![], vec![], - None, + vec![], None, entries, ) @@ -1268,6 +1268,7 @@ mod test { grid, &rows, y.checked_sub(1), + None, true, y, x, @@ -1461,6 +1462,7 @@ mod test { grid, &rows, y.checked_sub(1), + None, true, y, x, @@ -1506,6 +1508,7 @@ mod test { grid, &rows, if y == 4 { Some(2) } else { y.checked_sub(1) }, + None, true, y, x, diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 22d2a09ef3..8db33df5ef 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -1,57 +1,446 @@ +use std::ops::Deref; + use typst_library::diag::SourceResult; use typst_library::engine::Engine; use typst_library::layout::grid::resolve::{Footer, Header, Repeatable}; use typst_library::layout::{Abs, Axes, Frame, Regions}; -use super::layouter::GridLayouter; +use super::layouter::{GridLayouter, RowState}; use super::rowspans::UnbreakableRowGroup; -impl GridLayouter<'_> { - /// Layouts the header's rows. - /// Skips regions as necessary. - pub fn layout_header( +impl<'a> GridLayouter<'a> { + /// Checks whether a region break could help a situation where we're out of + /// space for the next row. The criteria are: + /// + /// 1. If we could progress at the top of the region, that indicates the + /// region has a backlog, or (if we're at the first region) a region break + /// is at all possible (`regions.last` is `Some()`), so that's sufficient. + /// + /// 2. Otherwise, we may progress if another region break is possible + /// (`regions.last` is still `Some()`) and non-repeating rows have been + /// placed, since that means the space they occupy will be available in the + /// next region. + #[inline] + pub fn may_progress_with_repeats(&self) -> bool { + // TODO(subfooters): check below isn't enough to detect non-repeating + // footers... we can also change 'initial_after_repeats' to stop being + // calculated if there were any non-repeating footers. + self.current.could_progress_at_top + || self.regions.last.is_some() + && self.regions.size.y != self.current.initial_after_repeats + } + + pub fn place_new_headers( + &mut self, + consecutive_header_count: &mut usize, + engine: &mut Engine, + ) -> SourceResult<()> { + *consecutive_header_count += 1; + let (consecutive_headers, new_upcoming_headers) = + self.upcoming_headers.split_at(*consecutive_header_count); + + if new_upcoming_headers.first().is_some_and(|next_header| { + consecutive_headers.last().is_none_or(|latest_header| { + !latest_header.short_lived + && next_header.range.start == latest_header.range.end + }) && !next_header.short_lived + }) { + // More headers coming, so wait until we reach them. + return Ok(()); + } + + self.upcoming_headers = new_upcoming_headers; + *consecutive_header_count = 0; + + let [first_header, ..] = consecutive_headers else { + self.flush_orphans(); + return Ok(()); + }; + + // Assuming non-conflicting headers sorted by increasing y, this must + // be the header with the lowest level (sorted by increasing levels). + let first_level = first_header.level; + + // Stop repeating conflicting headers, even if the new headers are + // short-lived or won't repeat. + // + // If we go to a new region before the new headers fit alongside their + // children (or in general, for short-lived), the old headers should + // not be displayed anymore. + let first_conflicting_pos = + self.repeating_headers.partition_point(|h| h.level < first_level); + self.repeating_headers.truncate(first_conflicting_pos); + + // Ensure upcoming rows won't see that these headers will occupy any + // space in future regions anymore. + for removed_height in + self.current.repeating_header_heights.drain(first_conflicting_pos..) + { + self.current.repeating_header_height -= removed_height; + } + + // Layout short-lived headers immediately. + if consecutive_headers.last().is_some_and(|h| h.short_lived) { + // No chance of orphans as we're immediately placing conflicting + // headers afterwards, which basically are not headers, for all intents + // and purposes. It is therefore guaranteed that all new headers have + // been placed at least once. + self.flush_orphans(); + + // Layout each conflicting header independently, without orphan + // prevention (as they don't go into 'pending_headers'). + // These headers are short-lived as they are immediately followed by a + // header of the same or lower level, such that they never actually get + // to repeat. + self.layout_new_headers(consecutive_headers, true, engine)?; + } else { + // Let's try to place pending headers at least once. + // This might be a waste as we could generate an orphan and thus have + // to try to place old and new headers all over again, but that happens + // for every new region anyway, so it's rather unavoidable. + let snapshot_created = + self.layout_new_headers(consecutive_headers, false, engine)?; + + // Queue the new headers for layout. They will remain in this + // vector due to orphan prevention. + // + // After the first subsequent row is laid out, move to repeating, as + // it's then confirmed the headers won't be moved due to orphan + // prevention anymore. + self.pending_headers = consecutive_headers; + + if !snapshot_created { + // Region probably couldn't progress. + // + // Mark new pending headers as final and ensure there isn't a + // snapshot. + self.flush_orphans(); + } + } + + Ok(()) + } + + /// Lays out rows belonging to a header, returning the calculated header + /// height only for that header. Indicates to the laid out rows that they + /// should inform their laid out heights if appropriate (auto or fixed + /// size rows only). + #[inline] + fn layout_header_rows( &mut self, header: &Header, engine: &mut Engine, disambiguator: usize, - ) -> SourceResult<()> { - let header_rows = - self.simulate_header(header, &self.regions, engine, disambiguator)?; + as_short_lived: bool, + ) -> SourceResult { + let mut header_height = Abs::zero(); + for y in header.range.clone() { + header_height += self + .layout_row_with_state( + y, + engine, + disambiguator, + RowState { + current_row_height: Some(Abs::zero()), + in_active_repeatable: !as_short_lived, + }, + )? + .current_row_height + .unwrap_or_default(); + } + Ok(header_height) + } + + /// This function should be called each time an additional row has been + /// laid out in a region to indicate that orphan prevention has succeeded. + /// + /// It removes the current orphan snapshot and flushes pending headers, + /// such that a non-repeating header won't try to be laid out again + /// anymore, and a repeating header will begin to be part of + /// `repeating_headers`. + pub fn flush_orphans(&mut self) { + self.current.lrows_orphan_snapshot = None; + self.flush_pending_headers(); + } + + /// Indicates all currently pending headers have been successfully placed + /// once, since another row has been placed after them, so they are + /// certainly not orphans. + pub fn flush_pending_headers(&mut self) { + if self.pending_headers.is_empty() { + return; + } + + for header in self.pending_headers { + if header.repeated { + // Vector remains sorted by increasing levels: + // - 'pending_headers' themselves are sorted, since we only + // push non-mutually-conflicting headers at a time. + // - Before pushing new pending headers in + // 'layout_new_pending_headers', we truncate repeating headers + // to remove anything with the same or higher levels as the + // first pending header. + // - Assuming it was sorted before, that truncation only keeps + // elements with a lower level. + // - Therefore, by pushing this header to the end, it will have + // a level larger than all the previous headers, and is thus + // in its 'correct' position. + self.repeating_headers.push(header); + } + } + + self.pending_headers = Default::default(); + } + + /// Lays out the rows of repeating and pending headers at the top of the + /// region. + /// + /// Assumes the footer height for the current region has already been + /// calculated. Skips regions as necessary to fit all headers and all + /// footers. + pub fn layout_active_headers(&mut self, engine: &mut Engine) -> SourceResult<()> { + // Generate different locations for content in headers across its + // repetitions by assigning a unique number for each one. + let disambiguator = self.finished.len(); + + let header_height = self.simulate_header_height( + self.repeating_headers + .iter() + .copied() + .chain(self.pending_headers.iter().map(Repeatable::deref)), + &self.regions, + engine, + disambiguator, + )?; + + // We already take the footer into account below. + // While skipping regions, footer height won't be automatically + // re-calculated until the end. let mut skipped_region = false; while self.unbreakable_rows_left == 0 - && !self.regions.size.y.fits(header_rows.height + self.footer_height) - && self.regions.may_progress() + && !self.regions.size.y.fits(header_height) + && self.may_progress_with_repeats() { // Advance regions without any output until we can place the // header and the footer. - self.finish_region_internal(Frame::soft(Axes::splat(Abs::zero())), vec![]); + self.finish_region_internal( + Frame::soft(Axes::splat(Abs::zero())), + vec![], + Default::default(), + ); + + // TODO(layout model): re-calculate heights of headers and footers + // on each region if 'full' changes? (Assuming height doesn't + // change for now...) + // + // Would remove the footer height update below (move it here). skipped_region = true; - } - // Reset the header height for this region. - // It will be re-calculated when laying out each header row. - self.header_height = Abs::zero(); + self.regions.size.y -= self.current.footer_height; + self.current.initial_after_repeats = self.regions.size.y; + } - if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { - if skipped_region { + if let Some(footer) = &self.grid.footer { + if footer.repeated && skipped_region { // Simulate the footer again; the region's 'full' might have // changed. - self.footer_height = self + self.regions.size.y += self.current.footer_height; + self.current.footer_height = self .simulate_footer(footer, &self.regions, engine, disambiguator)? .height; + self.regions.size.y -= self.current.footer_height; } } - // Header is unbreakable. + let repeating_header_rows = + total_header_row_count(self.repeating_headers.iter().copied()); + + let pending_header_rows = + total_header_row_count(self.pending_headers.iter().map(Repeatable::deref)); + + // Group of headers is unbreakable. // Thus, no risk of 'finish_region' being recursively called from // within 'layout_row'. - self.unbreakable_rows_left += header.end; - for y in 0..header.end { - self.layout_row(y, engine, disambiguator)?; + self.unbreakable_rows_left += repeating_header_rows + pending_header_rows; + + self.current.last_repeated_header_end = + self.repeating_headers.last().map(|h| h.range.end).unwrap_or_default(); + + // Reset the header height for this region. + // It will be re-calculated when laying out each header row. + self.current.repeating_header_height = Abs::zero(); + self.current.repeating_header_heights.clear(); + + debug_assert!(self.current.lrows.is_empty()); + debug_assert!(self.current.lrows_orphan_snapshot.is_none()); + let may_progress = self.may_progress_with_repeats(); + + if may_progress { + // Enable orphan prevention for headers at the top of the region. + // Otherwise, we will flush pending headers below, after laying + // them out. + // + // It is very rare for this to make a difference as we're usually + // at the 'last' region after the first skip, at which the snapshot + // is handled by 'layout_new_headers'. Either way, we keep this + // here for correctness. + self.current.lrows_orphan_snapshot = Some(self.current.lrows.len()); + } + + // Use indices to avoid double borrow. We don't mutate headers in + // 'layout_row' so this is fine. + let mut i = 0; + while let Some(&header) = self.repeating_headers.get(i) { + let header_height = + self.layout_header_rows(header, engine, disambiguator, false)?; + self.current.repeating_header_height += header_height; + + // We assume that this vector will be sorted according + // to increasing levels like 'repeating_headers' and + // 'pending_headers' - and, in particular, their union, as this + // vector is pushed repeating heights from both. + // + // This is guaranteed by: + // 1. We always push pending headers after repeating headers, + // as we assume they don't conflict because we remove + // conflicting repeating headers when pushing a new pending + // header. + // + // 2. We push in the same order as each. + // + // 3. This vector is also modified when pushing a new pending + // header, where we remove heights for conflicting repeating + // headers which have now stopped repeating. They are always at + // the end and new pending headers respect the existing sort, + // so the vector will remain sorted. + self.current.repeating_header_heights.push(header_height); + + i += 1; + } + + self.current.repeated_header_rows = self.current.lrows.len(); + self.current.initial_after_repeats = self.regions.size.y; + + let mut has_non_repeated_pending_header = false; + for header in self.pending_headers { + if !header.repeated { + self.current.initial_after_repeats = self.regions.size.y; + has_non_repeated_pending_header = true; + } + let header_height = + self.layout_header_rows(header, engine, disambiguator, false)?; + if header.repeated { + self.current.repeating_header_height += header_height; + self.current.repeating_header_heights.push(header_height); + } + } + + if !has_non_repeated_pending_header { + self.current.initial_after_repeats = self.regions.size.y; } + + if !may_progress { + // Flush pending headers immediately, as placing them again later + // won't help. + self.flush_orphans(); + } + Ok(()) } + /// Lays out headers found for the first time during row layout. + /// + /// If 'short_lived' is true, these headers are immediately followed by + /// a conflicting header, so it is assumed they will not be pushed to + /// pending headers. + /// + /// Returns whether orphan prevention was successfully setup, or couldn't + /// due to short-lived headers or the region couldn't progress. + pub fn layout_new_headers( + &mut self, + headers: &'a [Repeatable
], + short_lived: bool, + engine: &mut Engine, + ) -> SourceResult { + // At first, only consider the height of the given headers. However, + // for upcoming regions, we will have to consider repeating headers as + // well. + let header_height = self.simulate_header_height( + headers.iter().map(Repeatable::deref), + &self.regions, + engine, + 0, + )?; + + while self.unbreakable_rows_left == 0 + && !self.regions.size.y.fits(header_height) + && self.may_progress_with_repeats() + { + // Note that, after the first region skip, the new headers will go + // at the top of the region, but after the repeating headers that + // remained (which will be automatically placed in 'finish_region'). + self.finish_region(engine, false)?; + } + + // Remove new headers at the end of the region if the upcoming row + // doesn't fit. + // TODO(subfooters): what if there is a footer right after it? + let should_snapshot = !short_lived + && self.current.lrows_orphan_snapshot.is_none() + && self.may_progress_with_repeats(); + + if should_snapshot { + // If we don't enter this branch while laying out non-short lived + // headers, that means we will have to immediately flush pending + // headers and mark them as final, since trying to place them in + // the next page won't help get more space. + self.current.lrows_orphan_snapshot = Some(self.current.lrows.len()); + } + + let mut at_top = self.regions.size.y == self.current.initial_after_repeats; + + self.unbreakable_rows_left += + total_header_row_count(headers.iter().map(Repeatable::deref)); + + for header in headers { + let header_height = self.layout_header_rows(header, engine, 0, false)?; + + // Only store this header height if it is actually going to + // become a pending header. Otherwise, pretend it's not a + // header... This is fine for consumers of 'header_height' as + // it is guaranteed this header won't appear in a future + // region, so multi-page rows and cells can effectively ignore + // this header. + if !short_lived && header.repeated { + self.current.repeating_header_height += header_height; + self.current.repeating_header_heights.push(header_height); + if at_top { + self.current.initial_after_repeats = self.regions.size.y; + } + } else { + at_top = false; + } + } + + Ok(should_snapshot) + } + + /// Calculates the total expected height of several headers. + pub fn simulate_header_height<'h: 'a>( + &self, + headers: impl IntoIterator, + regions: &Regions<'_>, + engine: &mut Engine, + disambiguator: usize, + ) -> SourceResult { + let mut height = Abs::zero(); + for header in headers { + height += + self.simulate_header(header, regions, engine, disambiguator)?.height; + } + Ok(height) + } + /// Simulate the header's group of rows. pub fn simulate_header( &self, @@ -66,8 +455,8 @@ impl GridLayouter<'_> { // assume that the amount of unbreakable rows following the first row // in the header will be precisely the rows in the header. self.simulate_unbreakable_row_group( - 0, - Some(header.end), + header.range.start, + Some(header.range.end - header.range.start), regions, engine, disambiguator, @@ -91,11 +480,22 @@ impl GridLayouter<'_> { { // Advance regions without any output until we can place the // footer. - self.finish_region_internal(Frame::soft(Axes::splat(Abs::zero())), vec![]); + self.finish_region_internal( + Frame::soft(Axes::splat(Abs::zero())), + vec![], + Default::default(), + ); skipped_region = true; } - self.footer_height = if skipped_region { + // TODO(subfooters): Consider resetting header height etc. if we skip + // region. (Maybe move that step to `finish_region_internal`.) + // + // That is unnecessary at the moment as 'prepare_footers' is only + // called at the start of the region, so header height is always zero + // and no headers were placed so far, but what about when we can have + // footers in the middle of the region? Let's think about this then. + self.current.footer_height = if skipped_region { // Simulate the footer again; the region's 'full' might have // changed. self.simulate_footer(footer, &self.regions, engine, disambiguator)? @@ -118,12 +518,22 @@ impl GridLayouter<'_> { // Ensure footer rows have their own height available. // Won't change much as we're creating an unbreakable row group // anyway, so this is mostly for correctness. - self.regions.size.y += self.footer_height; + self.regions.size.y += self.current.footer_height; + let repeats = self.grid.footer.as_ref().is_some_and(|f| f.repeated); let footer_len = self.grid.rows.len() - footer.start; self.unbreakable_rows_left += footer_len; + for y in footer.start..self.grid.rows.len() { - self.layout_row(y, engine, disambiguator)?; + self.layout_row_with_state( + y, + engine, + disambiguator, + RowState { + in_active_repeatable: repeats, + ..Default::default() + }, + )?; } Ok(()) @@ -144,10 +554,18 @@ impl GridLayouter<'_> { // in the footer will be precisely the rows in the footer. self.simulate_unbreakable_row_group( footer.start, - Some(self.grid.rows.len() - footer.start), + Some(footer.end - footer.start), regions, engine, disambiguator, ) } } + +/// The total amount of rows in the given list of headers. +#[inline] +pub fn total_header_row_count<'h>( + headers: impl IntoIterator, +) -> usize { + headers.into_iter().map(|h| h.range.end - h.range.start).sum() +} diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs index 5ab0417d8b..02ea14813d 100644 --- a/crates/typst-layout/src/grid/rowspans.rs +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -4,7 +4,7 @@ use typst_library::foundations::Resolve; use typst_library::layout::grid::resolve::Repeatable; use typst_library::layout::{Abs, Axes, Frame, Point, Region, Regions, Size, Sizing}; -use super::layouter::{in_last_with_offset, points, Row, RowPiece}; +use super::layouter::{points, Row}; use super::{layout_cell, Cell, GridLayouter}; /// All information needed to layout a single rowspan. @@ -90,10 +90,10 @@ pub struct CellMeasurementData<'layouter> { 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 - /// also called once after all regions are finished, in which case - /// `current_region_data` is `None`). + /// region's frame and height of resolved header rows, if it wasn't + /// finished yet (because we're being called from `finish_region`, but note + /// that this function is also called once after all regions are finished, + /// in which case `current_region_data` is `None`). /// /// We need to do this only once we already know the heights of all /// spanned rows, which is only possible after laying out the last row @@ -101,7 +101,7 @@ impl GridLayouter<'_> { pub fn layout_rowspan( &mut self, rowspan_data: Rowspan, - current_region_data: Option<(&mut Frame, &[RowPiece])>, + current_region_data: Option<(&mut Frame, Abs)>, engine: &mut Engine, ) -> SourceResult<()> { let Rowspan { @@ -146,11 +146,31 @@ impl GridLayouter<'_> { // Push the layouted frames directly into the finished frames. let fragment = layout_cell(cell, engine, disambiguator, self.styles, pod)?; - let (current_region, current_rrows) = current_region_data.unzip(); - for ((i, finished), frame) in self + let (current_region, current_header_row_height) = current_region_data.unzip(); + + // Clever trick to process finished header rows: + // - If there are grid headers, the vector will be filled with one + // finished header row height per region, so, chaining with the height + // for the current one, we get the header row height for each region. + // + // - But if there are no grid headers, the vector will be empty, so in + // theory the regions and resolved header row heights wouldn't match. + // But that's fine - 'current_header_row_height' can only be either + // 'Some(zero)' or 'None' in such a case, and for all other rows we + // append infinite zeros. That is, in such a case, the resolved header + // row height is always zero, so that's our fallback. + let finished_header_rows = self + .finished_header_rows + .iter() + .map(|info| info.repeated_height) + .chain(current_header_row_height) + .chain(std::iter::repeat(Abs::zero())); + + for ((i, (finished, header_dy)), frame) in self .finished .iter_mut() .chain(current_region.into_iter()) + .zip(finished_header_rows) .skip(first_region) .enumerate() .zip(fragment) @@ -162,22 +182,9 @@ impl GridLayouter<'_> { } else { // The rowspan continuation starts after the header (thus, // at a position after the sum of the laid out header - // rows). - if let Some(Repeatable::Repeated(header)) = &self.grid.header { - let header_rows = self - .rrows - .get(i) - .map(Vec::as_slice) - .or(current_rrows) - .unwrap_or(&[]) - .iter() - .take_while(|row| row.y < header.end); - - header_rows.map(|row| row.height).sum() - } else { - // Without a header, start at the very top of the region. - Abs::zero() - } + // rows). Without a header, this is zero, so the rowspan can + // start at the very top of the region as usual. + header_dy }; finished.push_frame(Point::new(dx, dy), frame); @@ -231,15 +238,13 @@ impl GridLayouter<'_> { // current row is dynamic and depends on the amount of upcoming // unbreakable cells (with or without a rowspan setting). let mut amount_unbreakable_rows = None; - if let Some(Repeatable::NotRepeated(header)) = &self.grid.header { - if current_row < header.end { - // Non-repeated header, so keep it unbreakable. - amount_unbreakable_rows = Some(header.end); - } - } - if let Some(Repeatable::NotRepeated(footer)) = &self.grid.footer { - if current_row >= footer.start { + if let Some(footer) = &self.grid.footer { + if !footer.repeated && current_row >= footer.start { // Non-repeated footer, so keep it unbreakable. + // + // TODO(subfooters): This will become unnecessary + // once non-repeated footers are treated differently and + // have widow prevention. amount_unbreakable_rows = Some(self.grid.rows.len() - footer.start); } } @@ -254,10 +259,7 @@ impl GridLayouter<'_> { // Skip to fitting region. while !self.regions.size.y.fits(row_group.height) - && !in_last_with_offset( - self.regions, - self.header_height + self.footer_height, - ) + && self.may_progress_with_repeats() { self.finish_region(engine, false)?; } @@ -396,16 +398,29 @@ impl GridLayouter<'_> { // auto rows don't depend on the backlog, as they only span one // region. if breakable - && (matches!(self.grid.header, Some(Repeatable::Repeated(_))) - || matches!(self.grid.footer, Some(Repeatable::Repeated(_)))) + && (!self.repeating_headers.is_empty() + || !self.pending_headers.is_empty() + || matches!(&self.grid.footer, Some(footer) if footer.repeated)) { // Subtract header and footer height from all upcoming regions // when measuring the cell, including the last repeated region. // // This will update the 'custom_backlog' vector with the // updated heights of the upcoming regions. + // + // We predict that header height will only include that of + // repeating headers, as we can assume non-repeating headers in + // the first region have been successfully placed, unless + // something didn't fit on the first region of the auto row, + // but we will only find that out after measurement, and if + // that happens, we discard the measurement and try again. let mapped_regions = self.regions.map(&mut custom_backlog, |size| { - Size::new(size.x, size.y - self.header_height - self.footer_height) + Size::new( + size.x, + size.y + - self.current.repeating_header_height + - self.current.footer_height, + ) }); // Callees must use the custom backlog instead of the current @@ -459,6 +474,7 @@ impl GridLayouter<'_> { // Height of the rowspan covered by spanned rows in the current // region. let laid_out_height: Abs = self + .current .lrows .iter() .filter_map(|row| match row { @@ -506,7 +522,12 @@ impl GridLayouter<'_> { .iter() .copied() .chain(std::iter::once(if breakable { - self.initial.y - self.header_height - self.footer_height + // Here we are calculating the available height for a + // rowspan from the top of the current region, so + // we have to use initial header heights (note that + // header height can change in the middle of the + // region). + self.current.initial_after_repeats } else { // When measuring unbreakable auto rows, infinite // height is available for content to expand. @@ -518,11 +539,13 @@ impl GridLayouter<'_> { // rowspan's already laid out heights with the current // region's height and current backlog to ensure a good // level of accuracy in the measurements. - let backlog = self - .regions - .backlog - .iter() - .map(|&size| size - self.header_height - self.footer_height); + // + // Assume only repeating headers will survive starting at + // the next region. + let backlog = self.regions.backlog.iter().map(|&size| { + size - self.current.repeating_header_height + - self.current.footer_height + }); heights_up_to_current_region.chain(backlog).collect::>() } else { @@ -536,10 +559,10 @@ impl GridLayouter<'_> { height = *rowspan_height; backlog = None; full = rowspan_full; - last = self - .regions - .last - .map(|size| size - self.header_height - self.footer_height); + last = self.regions.last.map(|size| { + size - self.current.repeating_header_height + - self.current.footer_height + }); } else { // The rowspan started in the current region, as its vector // of heights in regions is currently empty. @@ -741,10 +764,11 @@ impl GridLayouter<'_> { simulated_regions.next(); disambiguator += 1; - // Subtract the initial header and footer height, since that's the - // height we used when subtracting from the region backlog's + // Subtract the repeating header and footer height, since that's + // the height we used when subtracting from the region backlog's // heights while measuring cells. - simulated_regions.size.y -= self.header_height + self.footer_height; + simulated_regions.size.y -= + self.current.repeating_header_height + self.current.footer_height; } if let Some(original_last_resolved_size) = last_resolved_size { @@ -876,12 +900,8 @@ impl GridLayouter<'_> { // which, when used and combined with upcoming spanned rows, covers all // of the requested rowspan height, we give up. for _attempt in 0..5 { - let rowspan_simulator = RowspanSimulator::new( - disambiguator, - simulated_regions, - self.header_height, - self.footer_height, - ); + let rowspan_simulator = + RowspanSimulator::new(disambiguator, simulated_regions, &self.current); let total_spanned_height = rowspan_simulator.simulate_rowspan_layout( y, @@ -963,7 +983,8 @@ impl GridLayouter<'_> { { extra_amount_to_grow -= simulated_regions.size.y.max(Abs::zero()); simulated_regions.next(); - simulated_regions.size.y -= self.header_height + self.footer_height; + simulated_regions.size.y -= + self.current.repeating_header_height + self.current.footer_height; disambiguator += 1; } simulated_regions.size.y -= extra_amount_to_grow; @@ -980,10 +1001,17 @@ struct RowspanSimulator<'a> { finished: usize, /// The state of regions during the simulation. regions: Regions<'a>, - /// The height of the header in the currently simulated region. + /// The total height of headers in the currently simulated region. header_height: Abs, - /// The height of the footer in the currently simulated region. + /// The total height of footers in the currently simulated region. footer_height: Abs, + /// Whether `self.regions.may_progress()` was `true` at the top of the + /// region, indicating we can progress anywhere in the current region, + /// even right after a repeated header. + could_progress_at_top: bool, + /// Available height after laying out repeated headers at the top of the + /// currently simulated region. + initial_after_repeats: Abs, /// The total spanned height so far in the simulation. total_spanned_height: Abs, /// Height of the latest spanned gutter row in the simulation. @@ -997,14 +1025,19 @@ impl<'a> RowspanSimulator<'a> { fn new( finished: usize, regions: Regions<'a>, - header_height: Abs, - footer_height: Abs, + current: &super::layouter::Current, ) -> Self { Self { finished, regions, - header_height, - footer_height, + // There can be no new headers or footers within a multi-page + // rowspan, since headers and footers are unbreakable, so + // assuming the repeating header height and footer height + // won't change is safe. + header_height: current.repeating_header_height, + footer_height: current.footer_height, + could_progress_at_top: current.could_progress_at_top, + initial_after_repeats: current.initial_after_repeats, total_spanned_height: Abs::zero(), latest_spanned_gutter_height: Abs::zero(), } @@ -1053,10 +1086,7 @@ impl<'a> RowspanSimulator<'a> { 0, )?; while !self.regions.size.y.fits(row_group.height) - && !in_last_with_offset( - self.regions, - self.header_height + self.footer_height, - ) + && self.may_progress_with_repeats() { self.finish_region(layouter, engine)?; } @@ -1078,10 +1108,7 @@ impl<'a> RowspanSimulator<'a> { let mut skipped_region = false; while unbreakable_rows_left == 0 && !self.regions.size.y.fits(height) - && !in_last_with_offset( - self.regions, - self.header_height + self.footer_height, - ) + && self.may_progress_with_repeats() { self.finish_region(layouter, engine)?; @@ -1127,23 +1154,37 @@ impl<'a> RowspanSimulator<'a> { // our simulation checks what happens AFTER the auto row, so we can // just use the original backlog from `self.regions`. let disambiguator = self.finished; - let header_height = - if let Some(Repeatable::Repeated(header)) = &layouter.grid.header { - layouter - .simulate_header(header, &self.regions, engine, disambiguator)? - .height - } else { - Abs::zero() - }; - let footer_height = - if let Some(Repeatable::Repeated(footer)) = &layouter.grid.footer { - layouter - .simulate_footer(footer, &self.regions, engine, disambiguator)? - .height - } else { - Abs::zero() - }; + let (repeating_headers, header_height) = if !layouter.repeating_headers.is_empty() + || !layouter.pending_headers.is_empty() + { + // Only repeating headers have survived after the first region + // break. + let repeating_headers = layouter.repeating_headers.iter().copied().chain( + layouter.pending_headers.iter().filter_map(Repeatable::as_repeated), + ); + + let header_height = layouter.simulate_header_height( + repeating_headers.clone(), + &self.regions, + engine, + disambiguator, + )?; + + (Some(repeating_headers), header_height) + } else { + (None, Abs::zero()) + }; + + let footer_height = if let Some(footer) = + layouter.grid.footer.as_ref().and_then(Repeatable::as_repeated) + { + layouter + .simulate_footer(footer, &self.regions, engine, disambiguator)? + .height + } else { + Abs::zero() + }; let mut skipped_region = false; @@ -1156,19 +1197,24 @@ impl<'a> RowspanSimulator<'a> { skipped_region = true; } - if let Some(Repeatable::Repeated(header)) = &layouter.grid.header { + if let Some(repeating_headers) = repeating_headers { self.header_height = if skipped_region { // Simulate headers again, at the new region, as // the full region height may change. - layouter - .simulate_header(header, &self.regions, engine, disambiguator)? - .height + layouter.simulate_header_height( + repeating_headers, + &self.regions, + engine, + disambiguator, + )? } else { header_height }; } - if let Some(Repeatable::Repeated(footer)) = &layouter.grid.footer { + if let Some(footer) = + layouter.grid.footer.as_ref().and_then(Repeatable::as_repeated) + { self.footer_height = if skipped_region { // Simulate footers again, at the new region, as // the full region height may change. @@ -1185,6 +1231,7 @@ impl<'a> RowspanSimulator<'a> { // header or footer (as an invariant, any rowspans spanning any header // or footer rows are fully contained within that header's or footer's rows). self.regions.size.y -= self.header_height + self.footer_height; + self.initial_after_repeats = self.regions.size.y; Ok(()) } @@ -1201,8 +1248,18 @@ impl<'a> RowspanSimulator<'a> { self.regions.next(); self.finished += 1; + self.could_progress_at_top = self.regions.may_progress(); self.simulate_header_footer_layout(layouter, engine) } + + /// Similar to [`GridLayouter::may_progress_with_repeats`] but for rowspan + /// simulation. + #[inline] + fn may_progress_with_repeats(&self) -> bool { + self.could_progress_at_top + || self.regions.last.is_some() + && self.regions.size.y != self.initial_after_repeats + } } /// Subtracts some size from the end of a vector of sizes. diff --git a/crates/typst-library/src/foundations/int.rs b/crates/typst-library/src/foundations/int.rs index 83a89bf8a4..f65641ff18 100644 --- a/crates/typst-library/src/foundations/int.rs +++ b/crates/typst-library/src/foundations/int.rs @@ -1,4 +1,6 @@ -use std::num::{NonZeroI64, NonZeroIsize, NonZeroU64, NonZeroUsize, ParseIntError}; +use std::num::{ + NonZeroI64, NonZeroIsize, NonZeroU32, NonZeroU64, NonZeroUsize, ParseIntError, +}; use ecow::{eco_format, EcoString}; use smallvec::SmallVec; @@ -482,3 +484,16 @@ cast! { "number too large" })?, } + +cast! { + NonZeroU32, + self => Value::Int(self.get() as _), + v: i64 => v + .try_into() + .and_then(|v: u32| v.try_into()) + .map_err(|_| if v <= 0 { + "number must be positive" + } else { + "number too large" + })?, +} diff --git a/crates/typst-library/src/layout/grid/mod.rs b/crates/typst-library/src/layout/grid/mod.rs index 369df11ee0..52621c6470 100644 --- a/crates/typst-library/src/layout/grid/mod.rs +++ b/crates/typst-library/src/layout/grid/mod.rs @@ -1,6 +1,6 @@ pub mod resolve; -use std::num::NonZeroUsize; +use std::num::{NonZeroU32, NonZeroUsize}; use std::sync::Arc; use comemo::Track; @@ -468,6 +468,17 @@ pub struct GridHeader { #[default(true)] pub repeat: bool, + /// The level of the header. Must not be zero. + /// + /// This allows repeating multiple headers at once. Headers with different + /// levels can repeat together, as long as they have ascending levels. + /// + /// Notably, when a header with a lower level starts repeating, all higher + /// or equal level headers stop repeating (they are "replaced" by the new + /// header). + #[default(NonZeroU32::ONE)] + pub level: NonZeroU32, + /// The cells and lines within the header. #[variadic] pub children: Vec, diff --git a/crates/typst-library/src/layout/grid/resolve.rs b/crates/typst-library/src/layout/grid/resolve.rs index bad25b4744..baf6b7383f 100644 --- a/crates/typst-library/src/layout/grid/resolve.rs +++ b/crates/typst-library/src/layout/grid/resolve.rs @@ -1,5 +1,5 @@ -use std::num::NonZeroUsize; -use std::ops::Range; +use std::num::{NonZeroU32, NonZeroUsize}; +use std::ops::{Deref, DerefMut, Range}; use std::sync::Arc; use ecow::eco_format; @@ -48,6 +48,7 @@ pub fn grid_to_cellgrid<'a>( let children = elem.children.iter().map(|child| match child { GridChild::Header(header) => ResolvableGridChild::Header { repeat: header.repeat(styles), + level: header.level(styles), span: header.span(), items: header.children.iter().map(resolve_item), }, @@ -101,6 +102,7 @@ pub fn table_to_cellgrid<'a>( let children = elem.children.iter().map(|child| match child { TableChild::Header(header) => ResolvableGridChild::Header { repeat: header.repeat(styles), + level: header.level(styles), span: header.span(), items: header.children.iter().map(resolve_item), }, @@ -426,8 +428,20 @@ pub struct Line { /// A repeatable grid header. Starts at the first row. #[derive(Debug)] pub struct Header { - /// The index after the last row included in this header. - pub end: usize, + /// The range of rows included in this header. + pub range: Range, + /// The header's level. + /// + /// Higher level headers repeat together with lower level headers. If a + /// lower level header stops repeating, all higher level headers do as + /// well. + pub level: u32, + /// Whether this header cannot be repeated nor should have orphan + /// prevention because it would be about to cease repetition, either + /// because it is followed by headers of conflicting levels, or because + /// it is at the end of the table (possibly followed by some footers at the + /// end). + pub short_lived: bool, } /// A repeatable grid footer. Stops at the last row. @@ -435,32 +449,56 @@ pub struct Header { pub struct Footer { /// The first row included in this footer. pub start: usize, + /// The index after the last row included in this footer. + pub end: usize, + /// The footer's level. + /// + /// Used similarly to header level. + pub level: u32, +} + +impl Footer { + /// The footer's range of included rows. + #[inline] + pub fn range(&self) -> Range { + self.start..self.end + } } -/// A possibly repeatable grid object. +/// A possibly repeatable grid child (header or footer). +/// /// It still exists even when not repeatable, but must not have additional /// considerations by grid layout, other than for consistency (such as making /// a certain group of rows unbreakable). -pub enum Repeatable { - Repeated(T), - NotRepeated(T), +pub struct Repeatable { + inner: T, + + /// Whether the user requested the child to repeat. + pub repeated: bool, } -impl Repeatable { - /// Gets the value inside this repeatable, regardless of whether - /// it repeats. - pub fn unwrap(&self) -> &T { - match self { - Self::Repeated(repeated) => repeated, - Self::NotRepeated(not_repeated) => not_repeated, - } +impl Deref for Repeatable { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for Repeatable { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner } +} +impl Repeatable { /// Returns `Some` if the value is repeated, `None` otherwise. + #[inline] pub fn as_repeated(&self) -> Option<&T> { - match self { - Self::Repeated(repeated) => Some(repeated), - Self::NotRepeated(_) => None, + if self.repeated { + Some(&self.inner) + } else { + None } } } @@ -617,7 +655,7 @@ impl<'a> Entry<'a> { /// Any grid child, which can be either a header or an item. pub enum ResolvableGridChild { - Header { repeat: bool, span: Span, items: I }, + Header { repeat: bool, level: NonZeroU32, span: Span, items: I }, Footer { repeat: bool, span: Span, items: I }, Item(ResolvableGridItem), } @@ -638,8 +676,8 @@ pub struct CellGrid<'a> { /// Gutter rows are not included. /// Contains up to 'rows_without_gutter.len() + 1' vectors of lines. pub hlines: Vec>, - /// The repeatable header of this grid. - pub header: Option>, + /// The repeatable headers of this grid. + pub headers: Vec>, /// The repeatable footer of this grid. pub footer: Option>, /// Whether this grid has gutters. @@ -654,7 +692,7 @@ impl<'a> CellGrid<'a> { cells: impl IntoIterator>, ) -> Self { let entries = cells.into_iter().map(Entry::Cell).collect(); - Self::new_internal(tracks, gutter, vec![], vec![], None, None, entries) + Self::new_internal(tracks, gutter, vec![], vec![], vec![], None, entries) } /// Generates the cell grid, given the tracks and resolved entries. @@ -663,7 +701,7 @@ impl<'a> CellGrid<'a> { gutter: Axes<&[Sizing]>, vlines: Vec>, hlines: Vec>, - header: Option>, + headers: Vec>, footer: Option>, entries: Vec>, ) -> Self { @@ -717,7 +755,7 @@ impl<'a> CellGrid<'a> { entries, vlines, hlines, - header, + headers, footer, has_gutter, } @@ -852,6 +890,11 @@ impl<'a> CellGrid<'a> { self.cols.len() } } + + #[inline] + pub fn has_repeated_headers(&self) -> bool { + self.headers.iter().any(|h| h.repeated) + } } /// Resolves and positions all cells in the grid before creating it. @@ -937,6 +980,12 @@ struct RowGroupData { span: Span, kind: RowGroupKind, + /// Whether this header or footer may repeat. + repeat: bool, + + /// Level of this header or footer. + repeatable_level: NonZeroU32, + /// Start of the range of indices of hlines at the top of the row group. /// This is always the first index after the last hline before we started /// building the row group - any upcoming hlines would appear at least at @@ -984,14 +1033,17 @@ impl<'x> CellGridResolver<'_, '_, 'x> { let mut pending_vlines: Vec<(Span, Line)> = vec![]; let has_gutter = self.gutter.any(|tracks| !tracks.is_empty()); - let mut header: Option
= None; - let mut repeat_header = false; + let mut headers: Vec> = vec![]; // Stores where the footer is supposed to end, its span, and the // actual footer structure. let mut footer: Option<(usize, Span, Footer)> = None; let mut repeat_footer = false; + // If true, there has been at least one cell besides headers and + // footers. When false, footers at the end are forced to not repeat. + let mut at_least_one_cell = false; + // We can't just use the cell's index in the 'cells' vector to // determine its automatic position, since cells could have arbitrary // positions, so the position of a cell in 'cells' can differ from its @@ -1008,6 +1060,11 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // automatically-positioned cell. let mut auto_index: usize = 0; + // The next header after the latest auto-positioned cell. This is used + // to avoid checking for collision with headers that were already + // skipped. + let mut next_header = 0; + // We have to rebuild the grid to account for fixed cell positions. // // Create at least 'children.len()' positions, since there could be at @@ -1028,12 +1085,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> { columns, &mut pending_hlines, &mut pending_vlines, - &mut header, - &mut repeat_header, + &mut headers, &mut footer, &mut repeat_footer, &mut auto_index, + &mut next_header, &mut resolved_cells, + &mut at_least_one_cell, child, )?; } @@ -1049,13 +1107,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> { row_amount, )?; - let (header, footer) = self.finalize_headers_and_footers( + let footer = self.finalize_headers_and_footers( has_gutter, - header, - repeat_header, + &mut headers, footer, repeat_footer, row_amount, + at_least_one_cell, )?; Ok(CellGrid::new_internal( @@ -1063,7 +1121,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { self.gutter, vlines, hlines, - header, + headers, footer, resolved_cells, )) @@ -1083,12 +1141,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> { columns: usize, pending_hlines: &mut Vec<(Span, Line, bool)>, pending_vlines: &mut Vec<(Span, Line)>, - header: &mut Option
, - repeat_header: &mut bool, + headers: &mut Vec>, footer: &mut Option<(usize, Span, Footer)>, repeat_footer: &mut bool, auto_index: &mut usize, + next_header: &mut usize, resolved_cells: &mut Vec>>, + at_least_one_cell: &mut bool, child: ResolvableGridChild, ) -> SourceResult<()> where @@ -1112,7 +1171,32 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // position than it would usually be if it would be in a non-empty // row, so we must step a local index inside headers and footers // instead, and use a separate counter outside them. - let mut local_auto_index = *auto_index; + let local_auto_index = if matches!(child, ResolvableGridChild::Item(_)) { + auto_index + } else { + // Although 'usize' is Copy, we need to be explicit here that we + // aren't reborrowing the original auto index but rather making a + // mutable copy of it using 'clone'. + &mut (*auto_index).clone() + }; + + // NOTE: usually, if 'next_header' were to be updated inside a row + // group (indicating a header was skipped by a cell), that would + // indicate a collision between the row group and that header, which + // is an error. However, the exception is for the first auto cell of + // the row group, which may skip headers while searching for a position + // where to begin the row group in the first place. + // + // Therefore, we cannot safely share the counter in the row group with + // the counter used by auto cells outside, as it might update it in a + // valid situation, whereas it must not, since its auto cells use a + // different auto index counter and will have seen different headers, + // so we copy the next header counter while inside a row group. + let local_next_header = if matches!(child, ResolvableGridChild::Item(_)) { + next_header + } else { + &mut (*next_header).clone() + }; // The first row in which this table group can fit. // @@ -1123,23 +1207,19 @@ impl<'x> CellGridResolver<'_, '_, 'x> { let mut first_available_row = 0; let (header_footer_items, simple_item) = match child { - ResolvableGridChild::Header { repeat, span, items, .. } => { - if header.is_some() { - bail!(span, "cannot have more than one header"); - } - + ResolvableGridChild::Header { repeat, level, span, items, .. } => { row_group_data = Some(RowGroupData { range: None, span, kind: RowGroupKind::Header, + repeat, + repeatable_level: level, top_hlines_start: pending_hlines.len(), top_hlines_end: None, }); - *repeat_header = repeat; - first_available_row = - find_next_empty_row(resolved_cells, local_auto_index, columns); + find_next_empty_row(resolved_cells, *local_auto_index, columns); // If any cell in the header is automatically positioned, // have it skip to the next empty row. This is to avoid @@ -1150,7 +1230,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // latest auto-position cell, since each auto-position cell // always occupies the first available position after the // previous one. Therefore, this will be >= auto_index. - local_auto_index = first_available_row * columns; + *local_auto_index = first_available_row * columns; (Some(items), None) } @@ -1162,21 +1242,27 @@ impl<'x> CellGridResolver<'_, '_, 'x> { row_group_data = Some(RowGroupData { range: None, span, + repeat, kind: RowGroupKind::Footer, + repeatable_level: NonZeroU32::ONE, top_hlines_start: pending_hlines.len(), top_hlines_end: None, }); - *repeat_footer = repeat; - first_available_row = - find_next_empty_row(resolved_cells, local_auto_index, columns); + find_next_empty_row(resolved_cells, *local_auto_index, columns); - local_auto_index = first_available_row * columns; + *local_auto_index = first_available_row * columns; (Some(items), None) } - ResolvableGridChild::Item(item) => (None, Some(item)), + ResolvableGridChild::Item(item) => { + if matches!(item, ResolvableGridItem::Cell(_)) { + *at_least_one_cell = true; + } + + (None, Some(item)) + } }; let items = header_footer_items.into_iter().flatten().chain(simple_item); @@ -1191,7 +1277,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // gutter. skip_auto_index_through_fully_merged_rows( resolved_cells, - &mut local_auto_index, + local_auto_index, columns, ); @@ -1266,7 +1352,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // automatically positioned cell. Same for footers. local_auto_index .checked_sub(1) - .filter(|_| local_auto_index > first_available_row * columns) + .filter(|_| *local_auto_index > first_available_row * columns) .map_or(0, |last_auto_index| last_auto_index % columns + 1) }); if end.is_some_and(|end| end.get() < start) { @@ -1295,10 +1381,11 @@ impl<'x> CellGridResolver<'_, '_, 'x> { cell_y, colspan, rowspan, - header.as_ref(), + headers, footer.as_ref(), resolved_cells, - &mut local_auto_index, + local_auto_index, + local_next_header, first_available_row, columns, row_group_data.is_some(), @@ -1350,7 +1437,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { ); if top_hlines_end.is_none() - && local_auto_index > first_available_row * columns + && *local_auto_index > first_available_row * columns { // Auto index was moved, so upcoming auto-pos hlines should // no longer appear at the top. @@ -1437,7 +1524,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { None => { // Empty header/footer: consider the header/footer to be // at the next empty row after the latest auto index. - local_auto_index = first_available_row * columns; + *local_auto_index = first_available_row * columns; let group_start = first_available_row; let group_end = group_start + 1; @@ -1454,8 +1541,8 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // 'find_next_empty_row' will skip through any existing headers // and footers without having to loop through them each time. // Cells themselves, unfortunately, still have to. - assert!(resolved_cells[local_auto_index].is_none()); - resolved_cells[local_auto_index] = + assert!(resolved_cells[*local_auto_index].is_none()); + resolved_cells[*local_auto_index] = Some(Entry::Cell(self.resolve_cell( T::default(), 0, @@ -1483,21 +1570,38 @@ impl<'x> CellGridResolver<'_, '_, 'x> { match row_group.kind { RowGroupKind::Header => { - if group_range.start != 0 { - bail!( - row_group.span, - "header must start at the first row"; - hint: "remove any rows before the header" - ); - } - - *header = Some(Header { - // Later on, we have to correct this number in case there + let data = Header { + // Later on, we have to correct this range in case there // is gutter. But only once all cells have been analyzed // and the header has fully expanded in the fixup loop // below. - end: group_range.end, - }); + range: group_range, + + level: row_group.repeatable_level.get(), + + // This can only change at a later iteration, if we + // find a conflicting header or footer right away. + short_lived: false, + }; + + // Mark consecutive headers right before this one as short + // lived if they would have a higher or equal level, as + // then they would immediately stop repeating during + // layout. + let mut consecutive_header_start = data.range.start; + for conflicting_header in + headers.iter_mut().rev().take_while(move |h| { + let conflicts = h.range.end == consecutive_header_start + && h.level >= data.level; + + consecutive_header_start = h.range.start; + conflicts + }) + { + conflicting_header.short_lived = true; + } + + headers.push(Repeatable { inner: data, repeated: row_group.repeat }); } RowGroupKind::Footer => { @@ -1514,15 +1618,14 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // before the footer might not be included as part of // the footer if it is contained within the header. start: group_range.start, + end: group_range.end, + level: 1, }, )); + + *repeat_footer = row_group.repeat; } } - } else { - // The child was a single cell outside headers or footers. - // Therefore, 'local_auto_index' for this table child was - // simply an alias for 'auto_index', so we update it as needed. - *auto_index = local_auto_index; } Ok(()) @@ -1689,47 +1792,64 @@ impl<'x> CellGridResolver<'_, '_, 'x> { fn finalize_headers_and_footers( &self, has_gutter: bool, - header: Option
, - repeat_header: bool, + headers: &mut [Repeatable
], footer: Option<(usize, Span, Footer)>, repeat_footer: bool, row_amount: usize, - ) -> SourceResult<(Option>, Option>)> { - let header = header - .map(|mut header| { - // Repeat the gutter below a header (hence why we don't - // subtract 1 from the gutter case). - // Don't do this if there are no rows under the header. - if has_gutter { - // - 'header.end' is always 'last y + 1'. The header stops - // before that row. - // - Therefore, '2 * header.end' will be 2 * (last y + 1), - // which is the adjusted index of the row before which the - // header stops, meaning it will still stop right before it - // even with gutter thanks to the multiplication below. - // - This means that it will span all rows up to - // '2 * (last y + 1) - 1 = 2 * last y + 1', which equates - // to the index of the gutter row right below the header, - // which is what we want (that gutter spacing should be - // repeated across pages to maintain uniformity). - header.end *= 2; - - // If the header occupies the entire grid, ensure we don't - // include an extra gutter row when it doesn't exist, since - // the last row of the header is at the very bottom, - // therefore '2 * last y + 1' is not a valid index. - let row_amount = (2 * row_amount).saturating_sub(1); - header.end = header.end.min(row_amount); - } - header - }) - .map(|header| { - if repeat_header { - Repeatable::Repeated(header) - } else { - Repeatable::NotRepeated(header) - } - }); + at_least_one_cell: bool, + ) -> SourceResult>> { + // Mark consecutive headers right before the end of the table, or the + // final footer, as short lived, given that there are no normal rows + // after them, so repeating them is pointless. + // + // It is important to do this BEFORE we update header and footer ranges + // due to gutter below as 'row_amount' doesn't consider gutter. + // + // TODO(subfooters): take the last footer if it is at the end and + // backtrack through consecutive footers until the first one in the + // sequence is found. If there is no footer at the end, there are no + // haeders to turn short-lived. + let mut consecutive_header_start = + footer.as_ref().map(|(_, _, f)| f.start).unwrap_or(row_amount); + for header_at_the_end in headers.iter_mut().rev().take_while(move |h| { + let at_the_end = h.range.end == consecutive_header_start; + + consecutive_header_start = h.range.start; + at_the_end + }) { + header_at_the_end.short_lived = true; + } + + // Repeat the gutter below a header (hence why we don't + // subtract 1 from the gutter case). + // Don't do this if there are no rows under the header. + if has_gutter { + for header in &mut *headers { + // Index of first y is doubled, as each row before it + // receives a gutter row below. + header.range.start *= 2; + + // - 'header.end' is always 'last y + 1'. The header stops + // before that row. + // - Therefore, '2 * header.end' will be 2 * (last y + 1), + // which is the adjusted index of the row before which the + // header stops, meaning it will still stop right before it + // even with gutter thanks to the multiplication below. + // - This means that it will span all rows up to + // '2 * (last y + 1) - 1 = 2 * last y + 1', which equates + // to the index of the gutter row right below the header, + // which is what we want (that gutter spacing should be + // repeated across pages to maintain uniformity). + header.range.end *= 2; + + // If the header occupies the entire grid, ensure we don't + // include an extra gutter row when it doesn't exist, since + // the last row of the header is at the very bottom, + // therefore '2 * last y + 1' is not a valid index. + let row_amount = (2 * row_amount).saturating_sub(1); + header.range.end = header.range.end.min(row_amount); + } + } let footer = footer .map(|(footer_end, footer_span, mut footer)| { @@ -1737,8 +1857,17 @@ impl<'x> CellGridResolver<'_, '_, 'x> { bail!(footer_span, "footer must end at the last row"); } - let header_end = - header.as_ref().map(Repeatable::unwrap).map(|header| header.end); + // TODO(subfooters): will need a global slice of headers and + // footers for when we have multiple footers + // Alternatively, never include the gutter in the footer's + // range and manually add it later on layout. This would allow + // laying out the gutter as part of both the header and footer, + // and, if the page only has headers, the gutter row below the + // header is automatically removed (as it becomes the last), so + // only the gutter above the footer is kept, ensuring the same + // gutter row isn't laid out two times in a row. When laying + // out the footer for real, the mechanism can be disabled. + let last_header_end = headers.last().map(|header| header.range.end); if has_gutter { // Convert the footer's start index to post-gutter coordinates. @@ -1747,23 +1876,38 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // Include the gutter right before the footer, unless there is // none, or the gutter is already included in the header (no // rows between the header and the footer). - if header_end != Some(footer.start) { + if last_header_end != Some(footer.start) { footer.start = footer.start.saturating_sub(1); } + + // Adapt footer end but DO NOT include the gutter below it, + // if it exists. Calculation: + // - Starts as 'last y + 1'. + // - The result will be + // 2 * (last_y + 1) - 1 = 2 * last_y + 1, + // which is the new index of the last footer row plus one, + // meaning we do exclude any gutter below this way. + // + // It also keeps us within the total amount of rows, so we + // don't need to '.min()' later. + footer.end = (2 * footer.end).saturating_sub(1); } Ok(footer) }) .transpose()? .map(|footer| { - if repeat_footer { - Repeatable::Repeated(footer) - } else { - Repeatable::NotRepeated(footer) + // Don't repeat footers when the table only has headers and + // footers. + // TODO(subfooters): Switch this to marking the last N + // consecutive footers as short lived. + Repeatable { + inner: footer, + repeated: repeat_footer && at_least_one_cell, } }); - Ok((header, footer)) + Ok(footer) } /// Resolves the cell's fields based on grid-wide properties. @@ -1934,28 +2078,28 @@ fn expand_row_group( /// Check if a cell's fixed row would conflict with a header or footer. fn check_for_conflicting_cell_row( - header: Option<&Header>, + headers: &[Repeatable
], footer: Option<&(usize, Span, Footer)>, cell_y: usize, rowspan: usize, ) -> HintedStrResult<()> { - if let Some(header) = header { - // TODO: check start (right now zero, always satisfied) - if cell_y < header.end { - bail!( - "cell would conflict with header spanning the same position"; - hint: "try moving the cell or the header" - ); - } + // NOTE: y + rowspan >, not >=, header.start, to check if the rowspan + // enters the header. For example, consider a rowspan of 1: if + // `y + 1 = header.start` holds, that means `y < header.start`, and it + // only occupies one row (`y`), so the cell is actually not in + // conflict. + if headers + .iter() + .any(|header| cell_y < header.range.end && cell_y + rowspan > header.range.start) + { + bail!( + "cell would conflict with header spanning the same position"; + hint: "try moving the cell or the header" + ); } - if let Some((footer_end, _, footer)) = footer { - // NOTE: y + rowspan >, not >=, footer.start, to check if the rowspan - // enters the footer. For example, consider a rowspan of 1: if - // `y + 1 = footer.start` holds, that means `y < footer.start`, and it - // only occupies one row (`y`), so the cell is actually not in - // conflict. - if cell_y < *footer_end && cell_y + rowspan > footer.start { + if let Some((_, _, footer)) = footer { + if cell_y < footer.end && cell_y + rowspan > footer.start { bail!( "cell would conflict with footer spanning the same position"; hint: "try reducing the cell's rowspan or moving the footer" @@ -1981,10 +2125,11 @@ fn resolve_cell_position( cell_y: Smart, colspan: usize, rowspan: usize, - header: Option<&Header>, + headers: &[Repeatable
], footer: Option<&(usize, Span, Footer)>, resolved_cells: &[Option], auto_index: &mut usize, + next_header: &mut usize, first_available_row: usize, columns: usize, in_row_group: bool, @@ -2005,12 +2150,14 @@ fn resolve_cell_position( // Note that the counter ignores any cells with fixed positions, // but automatically-positioned cells will avoid conflicts by // simply skipping existing cells, headers and footers. - let resolved_index = find_next_available_position::( - header, + let resolved_index = find_next_available_position( + headers, footer, resolved_cells, columns, *auto_index, + next_header, + false, )?; // Ensure the next cell with automatic position will be @@ -2046,7 +2193,7 @@ fn resolve_cell_position( // footer (but only if it isn't already in one, otherwise there // will already be a separate check). if !in_row_group { - check_for_conflicting_cell_row(header, footer, cell_y, rowspan)?; + check_for_conflicting_cell_row(headers, footer, cell_y, rowspan)?; } cell_index(cell_x, cell_y) @@ -2063,12 +2210,28 @@ fn resolve_cell_position( // requested column ('Some(None)') or an out of bounds position // ('None'), in which case we'd create a new row to place this // cell in. - find_next_available_position::( - header, + find_next_available_position( + headers, footer, resolved_cells, columns, initial_index, + // Make our own copy of the 'next_header' counter, since it + // should only be updated by auto cells. However, we cannot + // start with the same value as we are searching from the + // start, and not from 'auto_index', so auto cells might + // have skipped some headers already which this cell will + // also need to skip. + // + // We could, in theory, keep a separate 'next_header' + // counter for cells with fixed columns. But then we would + // need one for every column, and much like how there isn't + // an index counter for each column either, the potential + // speed gain seems less relevant for a less used feature. + // Still, it is something to consider for the future if + // this turns out to be a bottleneck in important cases. + &mut 0, + true, ) } } @@ -2078,7 +2241,7 @@ fn resolve_cell_position( // footer (but only if it isn't already in one, otherwise there // will already be a separate check). if !in_row_group { - check_for_conflicting_cell_row(header, footer, cell_y, rowspan)?; + check_for_conflicting_cell_row(headers, footer, cell_y, rowspan)?; } // Let's find the first column which has that row available. @@ -2110,13 +2273,18 @@ fn resolve_cell_position( /// Finds the first available position after the initial index in the resolved /// grid of cells. Skips any non-absent positions (positions which already /// have cells specified by the user) as well as any headers and footers. +/// +/// When `skip_rows` is true, one row is skipped on each iteration, preserving +/// the column. That is used to find a position for a fixed column cell. #[inline] -fn find_next_available_position( - header: Option<&Header>, +fn find_next_available_position( + headers: &[Repeatable
], footer: Option<&(usize, Span, Footer)>, resolved_cells: &[Option>], columns: usize, initial_index: usize, + next_header: &mut usize, + skip_rows: bool, ) -> HintedStrResult { let mut resolved_index = initial_index; @@ -2126,7 +2294,7 @@ fn find_next_available_position( // determine where this cell will be placed. An out of // bounds position (thus `None`) is also a valid new // position (only requires expanding the vector). - if SKIP_ROWS { + if skip_rows { // Skip one row at a time (cell chose its column, so we don't // change it). resolved_index = @@ -2139,24 +2307,33 @@ fn find_next_available_position( // would become impractically large before this overflows. resolved_index += 1; } - } else if let Some(header) = - header.filter(|header| resolved_index < header.end * columns) + } else if let Some(header) = headers + .get(*next_header) + .filter(|header| resolved_index >= header.range.start * columns) { // Skip header (can't place a cell inside it from outside it). - resolved_index = header.end * columns; - - if SKIP_ROWS { - // Ensure the cell's chosen column is kept after the - // header. - resolved_index += initial_index % columns; + // No changes needed if we already passed this header (which + // also triggers this branch) - in that case, we only update the + // counter. + if resolved_index < header.range.end * columns { + resolved_index = header.range.end * columns; + + if skip_rows { + // Ensure the cell's chosen column is kept after the + // header. + resolved_index += initial_index % columns; + } } + + // From now on, only check the headers afterwards. + *next_header += 1; } else if let Some((footer_end, _, _)) = footer.filter(|(end, _, footer)| { resolved_index >= footer.start * columns && resolved_index < *end * columns }) { // Skip footer, for the same reason. resolved_index = *footer_end * columns; - if SKIP_ROWS { + if skip_rows { resolved_index += initial_index % columns; } } else { diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs index 373230897b..dcc77b0dc4 100644 --- a/crates/typst-library/src/model/table.rs +++ b/crates/typst-library/src/model/table.rs @@ -1,4 +1,4 @@ -use std::num::NonZeroUsize; +use std::num::{NonZeroU32, NonZeroUsize}; use std::sync::Arc; use typst_utils::NonZeroExt; @@ -292,16 +292,61 @@ fn show_cellgrid_html(grid: CellGrid, styles: StyleChain) -> Content { elem(tag::tr, Content::sequence(row)) }; + // TODO(subfooters): similarly to headers, take consecutive footers from + // the end for 'tfoot'. let footer = grid.footer.map(|ft| { - let rows = rows.drain(ft.unwrap().start..); + let rows = rows.drain(ft.start..); elem(tag::tfoot, Content::sequence(rows.map(|row| tr(tag::td, row)))) }); - let header = grid.header.map(|hd| { - let rows = rows.drain(..hd.unwrap().end); - elem(tag::thead, Content::sequence(rows.map(|row| tr(tag::th, row)))) - }); - let mut body = Content::sequence(rows.into_iter().map(|row| tr(tag::td, row))); + // Store all consecutive headers at the start in 'thead'. All remaining + // headers are just 'th' rows across the table body. + let mut consecutive_header_end = 0; + let first_mid_table_header = grid + .headers + .iter() + .take_while(|hd| { + let is_consecutive = hd.range.start == consecutive_header_end; + consecutive_header_end = hd.range.end; + + is_consecutive + }) + .count(); + + let (y_offset, header) = if first_mid_table_header > 0 { + let removed_header_rows = + grid.headers.get(first_mid_table_header - 1).unwrap().range.end; + let rows = rows.drain(..removed_header_rows); + + ( + removed_header_rows, + Some(elem(tag::thead, Content::sequence(rows.map(|row| tr(tag::th, row))))), + ) + } else { + (0, None) + }; + + // TODO: Consider improving accessibility properties of multi-level headers + // inside tables in the future, e.g. indicating which columns they are + // relative to and so on. See also: + // https://www.w3.org/WAI/tutorials/tables/multi-level/ + let mut next_header = first_mid_table_header; + let mut body = + Content::sequence(rows.into_iter().enumerate().map(|(relative_y, row)| { + let y = relative_y + y_offset; + if let Some(current_header) = + grid.headers.get(next_header).filter(|h| h.range.contains(&y)) + { + if y + 1 == current_header.range.end { + next_header += 1; + } + + tr(tag::th, row) + } else { + tr(tag::td, row) + } + })); + if header.is_some() || footer.is_some() { body = elem(tag::tbody, body); } @@ -492,6 +537,17 @@ pub struct TableHeader { #[default(true)] pub repeat: bool, + /// The level of the header. Must not be zero. + /// + /// This allows repeating multiple headers at once. Headers with different + /// levels can repeat together, as long as they have ascending levels. + /// + /// Notably, when a header with a lower level starts repeating, all higher + /// or equal level headers stop repeating (they are "replaced" by the new + /// header). + #[default(NonZeroU32::ONE)] + pub level: NonZeroU32, + /// The cells and lines within the header. #[variadic] pub children: Vec, diff --git a/crates/typst-syntax/src/span.rs b/crates/typst-syntax/src/span.rs index 3618b8f2f8..b383ec27f0 100644 --- a/crates/typst-syntax/src/span.rs +++ b/crates/typst-syntax/src/span.rs @@ -71,10 +71,7 @@ impl Span { /// Create a span that does not point into any file. pub const fn detached() -> Self { - match NonZeroU64::new(Self::DETACHED) { - Some(v) => Self(v), - None => unreachable!(), - } + Self(NonZeroU64::new(Self::DETACHED).unwrap()) } /// Create a new span from a file id and a number. @@ -111,11 +108,9 @@ impl Span { /// Pack a file ID and the low bits into a span. const fn pack(id: FileId, low: u64) -> Self { let bits = ((id.into_raw().get() as u64) << Self::FILE_ID_SHIFT) | low; - match NonZeroU64::new(bits) { - Some(v) => Self(v), - // The file ID is non-zero. - None => unreachable!(), - } + + // The file ID is non-zero. + Self(NonZeroU64::new(bits).unwrap()) } /// Whether the span is detached. diff --git a/crates/typst-utils/src/lib.rs b/crates/typst-utils/src/lib.rs index b346a80963..abe6423df3 100644 --- a/crates/typst-utils/src/lib.rs +++ b/crates/typst-utils/src/lib.rs @@ -26,7 +26,7 @@ pub use once_cell; use std::fmt::{Debug, Formatter}; use std::hash::Hash; use std::iter::{Chain, Flatten, Rev}; -use std::num::NonZeroUsize; +use std::num::{NonZeroU32, NonZeroUsize}; use std::ops::{Add, Deref, Div, Mul, Neg, Sub}; use std::sync::Arc; @@ -66,10 +66,11 @@ pub trait NonZeroExt { } impl NonZeroExt for NonZeroUsize { - const ONE: Self = match Self::new(1) { - Some(v) => v, - None => unreachable!(), - }; + const ONE: Self = Self::new(1).unwrap(); +} + +impl NonZeroExt for NonZeroU32 { + const ONE: Self = Self::new(1).unwrap(); } /// Extra methods for [`Arc`]. diff --git a/crates/typst-utils/src/pico.rs b/crates/typst-utils/src/pico.rs index 2c80d37de6..ce43667e95 100644 --- a/crates/typst-utils/src/pico.rs +++ b/crates/typst-utils/src/pico.rs @@ -95,10 +95,7 @@ impl PicoStr { } }; - match NonZeroU64::new(value) { - Some(value) => Ok(Self(value)), - None => unreachable!(), - } + Ok(Self(NonZeroU64::new(value).unwrap())) } /// Resolve to a decoded string. diff --git a/tests/ref/grid-footer-non-repeatable-unbreakable.png b/tests/ref/grid-footer-non-repeatable-unbreakable.png new file mode 100644 index 0000000000000000000000000000000000000000..59d72201f664f253d4ead37d769a26c148473b66 GIT binary patch literal 365 zcmV-z0h0cSP)959>O9UARv13v5=HY8(@bcbh zJ{?svs%BKpsG3nVqiU$Bh6pekhNEq4daL=mwBaHlHD_;JO(CS_&4h*Cgw)jSzUxj% z&6TE|MK}Ksr#5zV>1$+gPIgfEtEY#snnU@7)ttzG_M4y@$MtS62%7VMdOu+`6IKyW zbIj>b>JI{HKE1j0Z*ZiatdJGRV6qq%HDNm8$iSCx#FbJ1H_h8lNX>!v|J{Vt6mKDH zSAWU>mxR>(%&XWlbi&YI&ITEna$`|*e9d8Guy^5TTVhnrkW&KyP3_2YTbJIr00000 LNkvXXu0mjf3g)NX literal 0 HcmV?d00001 diff --git a/tests/ref/grid-footer-repeatable-unbreakable.png b/tests/ref/grid-footer-repeatable-unbreakable.png new file mode 100644 index 0000000000000000000000000000000000000000..0fa30f773390598874caadec81d03a8bfbd76820 GIT binary patch literal 340 zcmV-a0jvIrP)qr%<|VX4`=$T z**EnaAvK5hohT)w=I@5NK7`avSpU|GVEV~h;&^k&gkh*w2r}^E#iAxVbTu-VCO#E8*d$^GB9n6O>*rV~;V|2^pip>ku@ynBNq{Zy5_KnAB} zv8Zw55l051?8MkLywp6|@RX36+j;L|38|^yOxUja9>QuK+chmE?5}TwBn%TA8ju0j mw#0<^dSp-$FxqCLq8b39-U1g;aIg&k0000*Rn_wHat8+oR#w)fOP7B6@`aa|S4c=G zCMM?beXcEiGkXVG$P>&&$hWV`J;@@9*mB3JwnT_V(VgWy_2iGt$!1 zu3o(=CMK4Zm8GJhlAoU+9UZ-M=gyfkXYSasLqFIg) z>{&@kiI%F6BS?NwD( zdU|?HOibUteXFglJ#pg1zJ2@H+1Yt`cwWAISy53@Q&Z#N;lanp$HBn?3<`dJ{tq8M z?8w%d1`Hh&PZ!6Kid%2*oXoo%Akp^lKASqLRMfML0vexf&bMr2nKflu;p5-?MJ8-A zv99tHsJ-`QQ>BXcJ(-_1ik|iH%cI3oBmewcy84yN?pL=z96EaM)4StJvF^JsuG85! zr@O-bhrmm%Zu@^bYs!j0-+l0#>4EX$i^mEC3-psr?QLGpwYX^#-#&j{5epzAT`)$SaMX@^zAD7Lss?(|KDcbL6 zo`3Y|)T=Mt@_w9s@N4V8smsrAI-D zg9!!_JZ#NS>bu;$9P`hc=7(S3TzPKwRZR%+@~f_Pl-Fer>|1>v(>ZGGp}b{H(dYNPa4IT4G_s1D|Wa8(9JfG z$N`z6^4V$iO*t$)1d)*e2%4gcgH)bZ_* sfBSBoywAP8zxT0CRq>nzMqan{StqxdENxr^%v}r&p00i_>zopr0GIBM6951J literal 0 HcmV?d00001 diff --git a/tests/ref/grid-header-and-rowspan-contiguous-1.png b/tests/ref/grid-header-and-rowspan-contiguous-1.png new file mode 100644 index 0000000000000000000000000000000000000000..7cf2cb9ca2f1398eae307c4d89639794495ce82c GIT binary patch literal 815 zcmeAS@N?(olHy`uVBq!ia0vp^6$}iFM>v>)tfNQeqJSKx0G|-o|NsBj*Vi{RH2ilo zQ&(4aaBy&TcGlC=Q&v_kEiH|Wjg5$iC@U)q4Gk3+7thPfD=#lsRaITQcyVK6U#Y6F)uIgmoHzIE?wH*-X0SZBP1lm$;o;7@?|C_rf=WAef|2?)zuZ~PDVyX9v&V( zK0bDKb~ZLPX=&-dfB$aTvSr4M8EI*0SFc`;j*jNy;z~+NI&c)oRUAs4ZT;g$kV%-+)?#Jp%=X&Ja zcb!T)uOIHWZrwat+bD*bRo8FJ=`p=?Tl2hq`Q6{&XFYrOA#w7$yO~>VN3MQ-wlLOy z#?saII+?GWm-qdoXL9k~_3vv>R&2AB_E&qH(*G-Ci}v>YwY|~X-yhi-_ip~>=lX@k zxwgM1O8+yd`ugeE*X;1khvn_})P1aZ{b}l(f<5(@ZK|s@KOKI5&rL*JoZ&ok^T7lI zi9i1jUcQ?a_wDw|d1s5QRrk;3OU*ccTa!Je0VK=A23CZV`pp(@`9SLy?=RO+t3JoA zoW6xWS4YD?Zc?S}XRBwWXI{^^Zn!=`tkZg~Iz literal 0 HcmV?d00001 diff --git a/tests/ref/grid-header-and-rowspan-contiguous-2.png b/tests/ref/grid-header-and-rowspan-contiguous-2.png new file mode 100644 index 0000000000000000000000000000000000000000..29bc411d1cbf611682cb463daed918d201d3b788 GIT binary patch literal 815 zcmeAS@N?(olHy`uVBq!ia0vp^6$}iFM>v>)tfNQeqJY%@0G|-o|NsAg`0$~=zP_QM z;lHEV?Af#V`1oGFeCh1$oRE+(VZwy?_;_}9_I>;I)z;RYIB~+k!C~prrBzi`OiWDQ zzI|(NZ`ae)`|{%4_Vn~Td-m+kojcy% z-oe4aSFc`8OG}$EV}^{3OmuW~e}6w07gu3nVNz1knKNg$Y}ry$QsU+1B`q!e_wQd7 z6_xz_{H&}jF)^{Ot}Zqlq7s;Vw7E+0RBeDUH14-Zd8MMX_bjfaN^2L}f*0QmX&uh%jg0>kCC zr;B4q#jUq@j^_ynO0+$!Pdo5HO6D@}!teJKzsRwB%;|jpKg!`#(I&@>mcp9nU(Yq~ zPdV8gKkKK{q;K)-uck=0&wbA9>i=5tBe7bSVDPS(A8d~r=w?O&sPHMYWw6W{H>yjuKYTC&XF z6TI>B?(Hdk{A;WB&4=FQ@9x#@`S&EN%tG#t{yh6?sh!%IgAj#%~U^VzCd7FxP%-?o7Nx$7TB~-xkS?QVA zGp-x1PmVjjr>|1>vsD=j#GcvL3$15fXT)a|Ozt~ldGUd}8P{GC;Al3Q%+@jgci60mKVf4DH z{%QVSIf-uB>$~?#NXY-6f6v}Q%s;|ZKuo^aYRa!<1)YCio?Y5MG1T+V-8-LC9CYl; zUe(-di1?HI&}Kg4#vQf4wa+tdtf+r^>cQ;Ax$EFgn8U!3yk*+FZN_PRAP0N8`njxg HN@xNA-aTI_ literal 0 HcmV?d00001 diff --git a/tests/ref/grid-header-non-repeating-orphan-prevention.png b/tests/ref/grid-header-non-repeating-orphan-prevention.png new file mode 100644 index 0000000000000000000000000000000000000000..d0dbc59748ce0e8462187568367c72b122387107 GIT binary patch literal 453 zcmeAS@N?(olHy`uVBq!ia0vp^6+k?L14uA%c^CCCFfjIbx;TbZ+y*RqZXeQh$X>hg>89Mje8Hk~*2HhJV$}T*Br<=6ar@>~ z4-S5wvi*x}6oaau``TSA=D#^mdr;<-JHPh5wC=B8{arsa*>~p7|F5`7s)pr|a_9g3 z4tb02D}DG_bVi#=y7Q-)poP)YJdsBBIo(skg+8RP+bJ;4|H@!gaQMs)MW*eCg~hw7 zj;b@2A1u{nRBu~W{`t++%zHDHJ`_K%+syIiwu>9fx0V*GeJlF%Iu3ZuPx~3I0`{N{ z?}lWccmIo>d$XkF!7^q^iFe9^7E8iA4JNHQEdv@jsG0~9}=u6{1-oD!MmDLvn{Rnn3D?czT5o{NOk@`w`ewV;>U%57`?8l zf13YSPNG}(`tH3F67v7&-?MiR^N%nU5R)&qn(`}ILFeC>XP5S0yp;(xpGl0F;ZSmDLvn{Rnn3D?czT5o{NOk@`w`ewV;>U%57`?8l zf13YSPNG}(`tH3F67v7&-?MiR^N%nU5R)&qn(`}ILFeC>XP5S0yp;(xpGl0F;ZS&ZD-1Lkf5%VV8QpMfvfojI4J8JW2Ml8^|Gccd!?Qs2 z|0~90w#ly^yt(j?zhT;}{ni&sSG{%c5PjoYTWcnGp`_T~HH?cXfc1Vv^Yv%#%iNQq z^W~22(u(kxSh;>OfBK81U78L|KV_^` zuvVJi^}!pfg<;d5M`YToCTxDtdwT2UM{7-0nf25Dm3JJF|Gesw52wX_U6aS}F7JyH zl(_7hnAZ&nkL8T#K!G#6eX+y(&7QV7^*lPVTA_UZHy@wfuuQwXm_0Yn!Fy+dx$^eU zmvlovWL&!bgU8|W^W^&JF&nv*S3O9Vy`2Ajt<&X=*C*En#;#76J#b{*d5eFW{vGkE zj7jy45R55&e)q2Z(WR5;Wj*Kluy}=hSAs>ti92G#Qi@Fc^XE@$be^ur%&yOQ@T&W* m)CUC?rxYBJV(VcpZ$5)_#*EtAZ*=B^(v7F9pUXO@geCx~>kM50 literal 0 HcmV?d00001 diff --git a/tests/ref/grid-header-replace-doesnt-fit.png b/tests/ref/grid-header-replace-doesnt-fit.png new file mode 100644 index 0000000000000000000000000000000000000000..a06ce0e965d7a7984fd2985dc2af298d7eb4e287 GIT binary patch literal 559 zcmV+~0?_@5P)W-(+^l8q*YA-5)S&7GO~TG;pl^giPGuHN0Jp0~a|PY{R_#4y7QGt4l<48Jz` z9$4vdiic(8!1*t|aM&&j@d1)SIIP+DUcdQ6FSrk(Ewf=4y`P82Zw0}F`fb>>`hByh=Kz>!cD?-q zS)Y0!f#*jpZ6vVYHIP99n>$ZaN#I)5Q1}`J+);=tP{0di01ced-R>uW3s5;tG-{)k z1TIC1n*w$zI7_n5nBVX@V^{q@SOKTC>;2q^7sE) zJ^Lv)8WC9_4z}rh8bpjp9X6Jn_~fUk4Ze0C^vY&v$ya4?GWidEO8ZC4^yy84fo19$4vd ziic(8!1)ioaM&&j^8u1UIIP)tX9XlhjLdhJU!@F)g13`L!c>4{IDS8^OA0jJiGu5v zyltN^AsO(=tc({o>cdQ6FSHNgEwleFdOr@2-wJ{U_1my%_4{U1&jB#e>$ZaN#I)5(El|GxT6qPpnw<302(-@yWLL$7oc*QXw*h630#U2 zHwEl!=$hg4mSKh&W|(1y8D==BV5;zr8D^N_mw*F!9-D4e6m0tt*^+O^Q%p%CO`>4@ zt%B#P_35op3*50(4WwnmqL$kif5%OVg|nh>zH};2v^7sE* zJ^L;<8WC9_4z}r0_&fjr literal 0 HcmV?d00001 diff --git a/tests/ref/grid-header-replace.png b/tests/ref/grid-header-replace.png new file mode 100644 index 0000000000000000000000000000000000000000..dafecd7f48f6e81d378d703d92d3ec8f7d558f09 GIT binary patch literal 692 zcmV;l0!#ggP)000003QKZ~0007eNklFkL?*3O2qTwXFILl>^zbcE7k+JzffYRkUEM$-gbuyz<_IxFERQ9)Z_t z|0Z`fjR6lE1~Pwuyzlx;0{6SREhKPipzSybEITKTlE6;g-5o;|Fk+bEMSx+38D@Af z!c>QQ%rL_Ya|Xg02xlORDg)6-L>b77YN7!Ws($%{20rjc`bhJ{Xum-OTY4zq{)28q z>VNgzIV=k9%2t~y19QbAsr9NT7`YelPWf#1UZMkp!K#Zuu@)Zv3ID6j`yPvhPwtAZ zX(=tn-b)Y*=avN(eV-=8!t2h!wK4otSt8$ux=I}EF&owOQ$YBNGafQGj)>g3Y62u% zX7uT45;&s!UO)n0>xG^KHa6qDp8|fn=gyHz3K)sW7=JA?%rL_YGt9pRxW^1L%<#hC zH0B?#ZBP{KU6E(1@XZySg&o($r9K7>D?PJ!O|9tErwrY-MLvqu=g(@V=9u`@XUnOe zA}|^g3a8hWr&>1+POdLul%Wa|_f8KfSCJBtXu$cxv43NM*+hrPfx&TWIj_xLc<*FdBw+u7PFvAQp%rL_l a1^)wiJV+UqAlozm0000*0w!K~a1rE=Xd_Jeo?N!f@o}Q-yOs_(~0v51<1^f$mN%g5aSyjG8hV!&= zS-E$JTzx@GP>1@VQU<2BogjwG*035#SB0rz|N4tA1gMy*`rStlXRm;iGEWXit;;Jc zeVk3Nb+f?X>8DN>*kanOz2$%>`ij>j4){z5fCcVx1bEU+W)cVtcz1$K&aDzdvWqp^BMfln`UJ1W7cPKXMKRzqU8T0L*-1P|cgoFg) ziZ6xH(+}KC%8Y0F{)f?5Wl#NEQ_h^&nzsU+KxyR)p}qN@54MC_OgeBlAt9LQ{!y!0 z9)ITV%NGVo8(aL#@qQ3;$ZFz&$NqsTa{J$wa{hVDsSlO>du#fElM%0CrX1a_%lSv> zWtNg)Vf(ZW76Su=4>pHZJ?w96Y)of-t}FP)UgV%;KUdt>*nZUy^NPhpZA)J`GRa%K oYhzk>@5iJVJ)m8$dxbmpplQm-Q84I7?mo>NAxj@vkZr&Oz~6Sx2tT}Kkd!L1I0h5^KsUQ+s>CaZ2iKQnE==`mIld~3)bnG7Zp^+buOb9=OXGw4Z$7PN(W^3lEcC-V z^SjrBqjz?z2`vxa&FsP?Az>jgC;q!?!NW7NryK}%7wNp}yg!;rKJsOm5+e_f43A!3 zt!ILH_zc0+!rlH%`6+fL6Hl}R=ejJ?`*44f?8F1svyV+ZFikmX>*e^JQw}^+_~tRU bA=~`F-L1AqubdV?0R^BM;3C~2?nKBx(AtD}@E?hz4f|DFox2AN< z@O3R(VAuDK|Izt}pMAF1K9H+BU;ndAWZ`mV9=7I#st;P-uDh`O`}Ns9oh9%7`}Hml zq?POW+#Z~~wcC6D8BQLy>5bFlOZ;6QoEN@--sQng_pe?o>-PSy*I?SdZokb_kA{N} zftt(WgSu)RIq$o#uQ~&AiCY06vuHlHFq%6G%r?j3By8)h!Q0%*hnHGFQJqtN}_~?P~s^`C=x3+da@uz5%=&< z{WiaI1D`sb(^vHXjfyZ~!h{J6z~{Vc0)a=1xds9&!R!wNcDB}^5P0Tkn3sGf!h{Kn zz}KR81%Zc?sSW}kV`)qzJk8w~1eP7;e@22ZVZuW2z3SgW;J!RIK;TO@{R@G8^*tsM zZL9RhNDwAWSPX836$sqx$QB5ED}f>rs3_kMxMs}1GZKUe6BdK}$rA+b)_ZnP7E@{n z94#C$k+7+AZ;(hx2GCUxj41dcV!A?L{lhJRR-Fk!VC_w_MZlXxe8xHve%(9)Jo+!- Tc1)(d00000NkvXXu0mjf6li}x literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-alone-no-orphan-prevention.png b/tests/ref/grid-subheaders-alone-no-orphan-prevention.png new file mode 100644 index 0000000000000000000000000000000000000000..17bf3fe4f5ff3f740c0251c94b008db961dde494 GIT binary patch literal 254 zcmeAS@N?(olHy`uVBq!ia0vp^6+oQL0VEh^`bt^@siU4Qjv*Dd-rg|eI^-bYdQsTjvu%utTLj)*j)>gF?s!4Xdc&n-PB|t^Z)m(?YVnLS6MQ!L$v)mWNe>R+@o?*I zXhZ_pjQLg8p1bOt4!`mWy0xFRoqf`hANRC=2(kw?^KOQ;hXz};- z+4B#S&dY!KE&cA;`GPwi3*z0UJY!&H&KI2H`QzBTeYyuai_hPPyv6jUI!Wg%>z>+z zZ$I?+nI7AgXxXK|bMm&*A0dxat*u$ln?86Epj57TUG(;c{8yJhFZuIl;}d44J%&G$ z{vWt;Z!N3EoBrOf%VfSaG{V7I%c#ea%*@Qei9Zgq?OAq+=YO}BsG8R)-vvU>Zl5B8 z8dW`Bhs-#*dqv2MqqPyIGec%{{(kc9X9GiUqQx6VphgG?PF!Jd;J|?p2`wIj4Yn22 zzP{Ef*m}7tpz-jT^HaJ0M7@Yxb>QswKi&skv-q?=+`!HQ1ACG`aIGn}4SZr|y9X4g Mp00i_>zopr0A*vRQ~&?~ literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-alone-with-footer.png b/tests/ref/grid-subheaders-alone-with-footer.png new file mode 100644 index 0000000000000000000000000000000000000000..41bf88bc4050ab6cf3efb38110bbfca682df2040 GIT binary patch literal 319 zcmeAS@N?(olHy`uVBq!ia0vp^6+rC90VEid>iI7-Ffg)tx;TbZ+)Dmo@6eEVDc1KY*VFH;MfoNbK^v=>f4%KKGA$Z|4Jc5B6qh(D#7hmSID{PCuDwI*}Y z$Jw0Rd)bdX+I6@5E|AjpweHUAOIse;y)t{F_H+)T2R)0XAHBMC{X|Ev`x!F)!QItW zY$qN$|71@2*!d@YKl`zy<6rai*^V9mzx+G@grmY?ye&tM&*5A8PlBV{e(%mN`CFby ztrJUcUE{#^uJp?$3mzhY<29|TbYb1Z)(Ex>v Mr>mdKI;Vst0B7=#3IG5A literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-alone-with-gutter-and-footer-no-orphan-prevention.png b/tests/ref/grid-subheaders-alone-with-gutter-and-footer-no-orphan-prevention.png new file mode 100644 index 0000000000000000000000000000000000000000..7d6cc45e08422ec2e97decd1c56ca13a6738b0d7 GIT binary patch literal 382 zcmeAS@N?(olHy`uVBq!ia0vp^6+pb414uCZPe1;Jfq~K0)5S5Q;?~=nhTcqx5(hp; zXXj}|3|Jfzv#slZI+++9Im-ynGfsg?y|$1^fdxjMRDlx8{V`tis~M~hmQ z>*WO(zc+sV&-1=_|HB>A+kaaxGT}2YDCl|MbIAPJtGi5dEWY(vdPO(X`uBM5WjxM* z?p^&^`KkMIBfdRzf43k{)-Gnw$zyH@lX)#}O+7L9ROLalKQpVo+MK+<-)E;oLpa;~ zoc9u&OEVeyZr3J9IZv}qKmvm9*%ikR95_&-UZ=^V|I+X7d;6*HL{y(D1)Q|E-T61X zQPtyh$?|iXSClM2SsHOFvt)T=_492&V_*8|R0H+FK#BS;VFLq$yPfY?c=p&;O!<0R zt6*c`?!d;wXO7S0`t#$qO=#n4e|vt$`NBX&sr(XfVDan&&*?s{bs0Za_JIP{)78&q Iol`;+0M_TLTmS$7 literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-alone-with-gutter-no-orphan-prevention.png b/tests/ref/grid-subheaders-alone-with-gutter-no-orphan-prevention.png new file mode 100644 index 0000000000000000000000000000000000000000..17bf3fe4f5ff3f740c0251c94b008db961dde494 GIT binary patch literal 254 zcmeAS@N?(olHy`uVBq!ia0vp^6+oQL0VEh^`bt^@siU4Qjv*Dd-rg|eI^-bYdQsTjvu%utTLj)*j)>gF?s!4Xdc&n-PB|t^Z)m(?YVnLS6MQ!L$v)mWNe>R+@o?*I zXhZ_pjQLg8p1bOt4!`mWy0xFRoqf`hANRC=g*KIEGZ*O8#N*(2#iLQDC>b zuAG~gzek1x+r`f>Qwy7%ZH)}H7fwIQ`&C28axzeMYsHL+Kc$(6k1}rj@uqjRCUerq z*__;a*^fNhb+`O3kka~|sU-R_YjvfEM{5$`IqrzdlEk}>f;amDof}`7h@6IpzTb@X* z6H9Mh_kNXKe4~+t(9ill5`M(dH{j43l5lALX)`G6Cc@Pgg&ebxsLQ E0Q2l`5C8xG literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-basic-non-consecutive-with-footer.png b/tests/ref/grid-subheaders-basic-non-consecutive-with-footer.png new file mode 100644 index 0000000000000000000000000000000000000000..6f2a57beb3810ea4a91f861eee59c1ddd837d9b0 GIT binary patch literal 279 zcmeAS@N?(olHy`uVBq!ia0vp^6+mpt0VEjKpDfq|r0#jTIEGZ*O8#N*(2#iLQDC>b zuH4p&D@jH=|8{M8_^2VG=G(EWQy4e?INQ>Ck2$IE?z`RZn3FzET{hR%+&yGfUDfyV zd#^Jmew11}|0A>T74F0HczjNlo_hJFA!3jD=hkam8#h>(zdOFw9jK-V)2!xokcfC zRH}Y@_s_l817YzRt)KA&6VwaCo(xRMb;{}NfLOdY^M%&6fv`9??#H!917Y!rd%i^a_bKQ_ky8vu*{8Gl*!Wk7Tnl9y5j29i=DFC~jXl4OvQ@>Yo9p+qSRilUyL z;~ospFZrJv_|*5D|2ap(Kuba}!2}ab@E7p25?Vmu$!2ARz}Hk!4S|DQP6dIN;f`zj zSEu^K@w*EGZphwdOzNWH$RO}oIf1}E+gY|ozz)-EeL%n?Cup2Tx=bAd8<}MQN?neF zV9yi*&+O?k{kcSeBl1_%EC66Z@u)r^sgs!kW{>aE0|sV(hJo8d47>}JRuFjM;T;gz z*8*K2(HMI};G4$VphrR!xE4*gA@E$#?lGyeIZSF!&$1A>pD*NToCqeE;2(fL8PQ`V Tbe%SA00000NkvXXu0mjfM4^Wm literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-basic-with-footer.png b/tests/ref/grid-subheaders-basic-with-footer.png new file mode 100644 index 0000000000000000000000000000000000000000..5216561464d16b3123ef947a8c3bb6017e1176c7 GIT binary patch literal 256 zcmeAS@N?(olHy`uVBq!ia0vp^6+o=V0VEiX-Dr9Oq>g*KIEGZ*O8#N*(2#iLQDC>b zuH4p&D@jH=|8{M8_^2VG=G(EWQy4e?INQ>Ck2$IE?z`RZfK=!2bBks(Cw^S`htcb* z`ltDSN{ylpKG5-it0WtYvt0}*d6?Fc6d3I^PV3>98tnIZ9I(8TS z?XPyw(Yt5l;CMIw)BmRp5r2%=E4;eJu=LfL!_3Ja*G~f47kxBnF5|`>^>_b20#X$} z|2J~Jl8t*l_Y}KI;oLiAPyc0zr$OEOM~s=_W=GUd;ai98Kwk57^>bP0l+XkK)WLR} literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-basic.png b/tests/ref/grid-subheaders-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..5a64680757e4120bc89ebd153bf23a5593c53c8c GIT binary patch literal 210 zcmeAS@N?(olHy`uVBq!ia0vp^6+o=P0VEg%8WVp4shOTGjv*Ddl7HAcG$dYm6xi*q zE4Q`cN|KSzzg=4%K5B@l`F8B;6vmA|&bGAPV@@i(`)>C;Al3Q%+@jgci60mKVf4DH z{%QVSIf-uB>$~?#NXY-6f6v}Q%s;|ZKuo^aYRa!<1)YCio?Y567-pS2YkRGOj@^ZS z`>P#v^zIosINpu_^#5r?#2@4JN#`?WXTuyIVa~u{{%wZoLVd6IAjf*T`njxgN@xNA Dt|(dA literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-colorful.png b/tests/ref/grid-subheaders-colorful.png new file mode 100644 index 0000000000000000000000000000000000000000..38bb4ad8b88a2f3735b6f7eac0863b2e9102df58 GIT binary patch literal 11005 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 JY8kVz{{uIvZ$tn9 literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-demo.png b/tests/ref/grid-subheaders-demo.png new file mode 100644 index 0000000000000000000000000000000000000000..ec1cd998c9aa4e74c7ecc16758a7c172d998cebd GIT binary patch literal 5064 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 literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-multi-page-row-right-after-with-footer.png b/tests/ref/grid-subheaders-multi-page-row-right-after-with-footer.png new file mode 100644 index 0000000000000000000000000000000000000000..119a2c22b0e9484962bc258edd4a7fb488f092b5 GIT binary patch literal 1207 zcmV;o1W5adP)FMd#*498kKp7bsczAegYirNX&+F^!$;rvSzP>LnFH%xc zPEJnZ;^I0wI&E!jhlhu>w6vL-ne_DZ;Nalf+uMPGfni}`_xJZ9At5$4Hqg+}jg5`_ z`}^kR=0-+FudlCJSy{os!L_xue0+R4IXQWGdCbhrn3$McTwLep=dG=+*VosMj*i^i z+}hgOS65eqgM+xZxNK}}u&}U7N=k-?hU7Xhuq-7}QBlgu%Duh4X=!Qm^Yg2#tIp2O zh=_>l>gq#7Ln$dK^*%H4@$r(9k`WOR;o;$;qN3m5-!wEdUteG4<>e?SC}w76V`F0` zCMGd4F(4oyH#ax#?(SV(T`VjtJUl!}NlB5Bk!WaWv9Yo6@bIjxta^HSq@<*jl$4*J zpMrvdg@uKqqoZ+gao*nE{QUftm6g=g)KgPaDk>^gR#vB{r#m}4etv$tyStZ{mu_xu zv$M0+)zyiKiE?ssxw*OB-QD^5`RM5A`1tr`Wo1W4M@vgfp`oFVkB_RVs*sS7*x12L?Ck8#&CNkULH73crKP3W+1d5=_5S|;00000_NVdy00MVOL_t(|+U?p^ zS6e|Ch2h;O5~RTzS^~64i%Y%K-QA74QN!J(1q#FejAScQbip_SzXQpr2V_*f|f^iDh9CLqB(V}gh~ zMNvq&d(Sm(gMjLMx2+Nabb83pO-=Y^!r$;w5xVOBM?zuiLkO^!4D=(#j6+1yI+70w z6LFXzVRQsCj6c%SAz|D97-pDZh8bpj{B9(oE#a>a-aHWzhh!X*ZK@$zcvWafW)@q%=no!{39lbMkz4#_wq(<1!r z)=h~p;Jj^yjIaU;1B0Ix_$)aFW8-{&E^L-17qk$6g#~4C2{jG)5(;|oq@+%HnuEgXRjusSNcK>VKl86?OFvXrObbY8b{q9J$u)vwg1< z)|%S#qtJw2frMmPmV%~}2E>9*Q56`AKoe*5=c<%Yqy31ztoeM~W$a%GFG33q#}HOt zSdjh!Z%}zjZHX_$TN4|fUyf5Fikm|p^?_(4aJ_v*RPe>>@VAUzn4~*_$Ox{ zKm%=8uByPrBud+#yXtCjA%uea(7+mGEWY=PedDHBxMvPBW@ifd*hfS}L_|cik$;(T VJ^Ny!_+9`2002ovPDHLkV1fl)U(5gi literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-multi-page-row-right-after.png b/tests/ref/grid-subheaders-multi-page-row-right-after.png new file mode 100644 index 0000000000000000000000000000000000000000..c9e30869c4111d653e4a9045c70bd03108b62d61 GIT binary patch literal 1127 zcmV-t1ep7YP)StYpW@=;US3|&(b0f_fU>f(o}Qkgqoaq1hu`1dy}i9@X=$mcsc~^} zQ&Ur2U0qsQT5xc1=H}+x+uO>@%0WRvudlDRwzkjD&&kQju&}T^JUo_xJbx{ryc%O(Y~F zsHmu1TwIKdjE;_u@bK^(8ylRQob>ec`#&|xGb-HN+?$)5>pL+X9v)y|V5_UE$H&K@ zprA}lOlD?gjg5_hf`T+OG?|&1XlQ7LhKA?o=T=r$`uh63yu9k_>ZhltuCA_XYHE^_ zlEJ~j_V)HSH#aOSEJj8~Zf*(m{>FMd0mzQ&MbB~XYbaZsX!^8Rc`K6_$&CSjA z_4WMx{Mp&r|3NnM%CzeM00KWrL_t(|+U?rMQ(93R#qkrGf=U-qBm%Z*VlU~v_uhLq zz1oc?LShIO1SyZ2KQ%M%;y&+ZhVxmx?RVbY-MI%uMD#x?!WFNVcJdWSK>wAz6jk@j!N~0d-#oJ(l1jA-jK|*Q{GSV5WLPGXyXoQm+h8bpcev_W?*ZPGC(n)Cxtl%Ed-d8-yL0apRM6e&Y(zYG79Hay tWuLnc^rJn>`vwsa5fKp)5fKr|!yiHa)bcA|uw4KE002ovPDHLkV1i+!I$i(( literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-multi-page-row-with-footer.png b/tests/ref/grid-subheaders-multi-page-row-with-footer.png new file mode 100644 index 0000000000000000000000000000000000000000..a440eac40523ab7dc093fe540dee131366f606b1 GIT binary patch literal 1345 zcmV-H1-|-;P)8SpI={J?d|Qbu&~q9)1soH zm6esfy}fO1ZR_jndwY9`h=^ljW6H|PprD|Ff`X=|raL=3<>loW85yjstYTtft*xy< zKtSs1>RMV_tE;QJy1I#piC$h_X=!PwsHiF`Dr#zKzP`RMFE8HS-qzOE*VorsSy_#Z zjq~&KaBy(q;^LN;mU?=6wzjr1GBTW;oEsY(adB}pG&DXwK2A9R(PfScq zl9H0e#l_Cf&QnuUii(O>R#sC@3hDl$3*mgP53@+uPfH zeSKYBU14Ei#Kgo+O-)KlN;Ne#q@<*qo13+@wRU!P;NakfhK6fvYq+?$zrVlZINJvQJfPg|mLYJ49 zrKP2di;K6nx6RGX*x1FM?L_4fAmK|w*n z!otSJ#&2(L$jHb5002(Q6gmI^0~Sd{K~#9!?b=maR8bfO;N`$Dz%UX+t8_Qmq=Jgw z-GzY?N=r*9-5oRjQ!mf3f57(vv);RPwVrb>_Ql={L_~CuY)()7g@(ELg(YZMUJO49 z4UrKpCp1JA9zPZUe^TNKtp^R- zQgPdJD0ti1&C`#;lm9n3q@nF#CJl+u1Buwc0DZK z^+ziZu`?Te!?*a!Sd&n5L`>7(qdUD160TQ`HSaB+=!S%mJeKjO=IF*KE zJ09rpdScx=#oNC}Ms>azQ^88bi$PGZJGrxNhk`%LD>nF-{()I&=kzBI1yYLoy;F4#_wq zk*|Va}C?m{aMj2~P`*|~pI9HeyZ^pwdB-9pYMiEoF ziR)$*F>ZIRnNdb!#HtxZeE-(&*b}oI5Has*Yxa$^gU=x2F?0-m3{un zM$XSbGm6O1+ln`%h_q8dnPxoPqGF0t%_t%+_DZ@LWn8*!Mj4?;H=@iaqM-4$(>LZN zB|}E%W)d{mMxf&zAFicrv_MB{2OqA9h=_=Yh)CCeRs*%|C#X8f00000NkvXXu0mjf D`7EVb literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-multi-page-row.png b/tests/ref/grid-subheaders-multi-page-row.png new file mode 100644 index 0000000000000000000000000000000000000000..637ca3fb10f78af61372f9fdc88a74a7b3a19d0a GIT binary patch literal 1173 zcmV;G1Zw+U3^`T5=5-TeIg!otFrmzSBDnMX%Qxw*NCiHUM@a#U1Q`1ttJ($fC^ z{+7AJoyy9} z)6>(Yrl$7x_NS+(goK3SuUGVVmYinzil$2>{X zDk>^!YHF30l}JcPq@<*Fc6Qg-*I8Lvjg5`7v$Jk)Zg_Zjyu7^c@9$SvSG2UWNl8h> z#KgmX?;}FMc9OG}H3i-(7Y)YR1b`}?D#qvq!3+1c6s{r&$zHit9?(EtDfi%CR5RCwC$+C^7e zVHAbo-56eiyIZMHv`XFGixg^5>PV5|9vqSz>F@3ECf}TM+GIboJ$hF%xGQo%L`46S ziSeo5&@lO{a2p!d^9%N&VJp{@1`UCd^rK-Uk_Voo&MJF0dLmUGNO;)oUI^Q>__kGA z2@wU)XAtF1Rk;NbHS?2*QcxL$h|<=T?k?EeSLPt%W}CO`6{2K7hQo_f~7RLfHG*8rg$F7+b^DPzi^g)#`-N1D$Z^4V|#(8o&n# z!wfUbFvAQpEF0!OfJ8*5BTD$r6A|%{jE7|Z)FIhon$jUzvJ*NS&XA=O{yF=7MduYG zWl1TVp9cl5`Q#!m6#Q7v%0^<@EY5+3RX+}(Vfiz3EG=RW8r&ZT24Uk*iVQQ%FvAQp z%rL_YGt4l<3^U9y!@O=HA|f7=@sNy&h=*i6B;z3&56O5)#zQh5lJSs?hh*_CVooXN z&JS#xQ$*v1H$HR9sJs|4r-+)0`Z9Bhs6C!nYR>o;sID6^r-)$a{ir!*ynSa*8AyO7=9ICyWlkA$U#yen n6frxtTVhOpIwB$>qQmePlc&co57D5?00000NkvXXu0mjfH6mT? literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-multi-page-rowspan-gutter.png b/tests/ref/grid-subheaders-multi-page-rowspan-gutter.png new file mode 100644 index 0000000000000000000000000000000000000000..53beeb02e4183db6499534946ce75b5180d79e52 GIT binary patch literal 1560 zcmV+z2Iu*SP)wlL_|b3Ha6DQ)<{T5aBy(N#l_p(+s@9; zK|w($CnrEaK!SpT#KgqCy}gW#jP331@bK_7G&Fg6d2MZNTU%TD`uduhnj#`17#J9t znVGS%v1w^(?(Xh|g@x+kRH^z`&SJw4*$;x#ojr>CbQBO^02GtA7)O-)T185uD# zF-l5G!NI|-tgJXVIBIHYVq#*Ro}QMLmXni{;NakVe0*A3TBD<*=jZ49`}=iub==(C zj*gDW$;syC=AE6LqN1XEdwa03u-Dhu;o;%3va)k?b98icrlzLx@$rw3kByCuSy@@j z%gY7^231v6>FMd{=;(@yit_UE)6>(bsi}j5gIHKt-{0SagoL!TwD|b={QUgn_3U)YQ~yXlR$0m(bAAFE1|#2M4IAs8?54|3Nl-+>KQL00XE=L_t(|+U?qDQ&VRc z#__Ahh)D<`2|GwC$|8shwHBo;uDEOWec$(eb!(Mc!78GtAjlS3k{nBlCHV0=sWXX> zhj;QkH__`i!<+lfeV@XFwe8A<-KC+H}}82Hb1~;C~Hs z9_V4+QCWcHIo#upwS;DFH6A|nO2mQ|6SJy`|4&1+$)CGieB}@k5z)eUOb9=DA|g&; zIe}eN6WGuip$TmLI)MqS?4E0VZEb}}xD0hgQhTm3q435PFfU%RbxT9y{t0YCXacJk z=kGHjT6C`bUJMBZStx;ot5L|fehniK;3=-09OKOS(Pj0czf)o(Q040VL1diZk6>=f zP&l=U?HDgKK2AukVmk+|Gv`f*yK;bIM-zP)Oox-J*p3r`b;*U{bB4mHRqWoTK=90} zz7t(%4TbF!SVLhuh8bSK@S*M7M8f&S!8VaFin@Om37<-QepDo!IC=5AYCcX3GrSPt zz(%3)3tpjcc4+4gnedSpTla~Cy~x`y6#k-EAZ!)3p6ZFESjNH=L$gD(X)eCGGCb^9 zrgGR$JE27|OBJ-8^7%(7BH{#=6Idc5;sllxSWaL$f#n326Idc5PGC8KZNxJG0000< KMNUMnLSTZPN*i4O literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-multi-page-rowspan-right-after.png b/tests/ref/grid-subheaders-multi-page-rowspan-right-after.png new file mode 100644 index 0000000000000000000000000000000000000000..5fe1eacf2221bbddcc5933baeb8cf9ad3ab4767c GIT binary patch literal 1421 zcmV;81#+7khsnpce*x1;Mi;K6nx1OG!va+&hXlVWY{o>-{qobpEczAbrcW!QO ze}8{*adB&FYw79f+S=MyR#u0Hhu`1d%*@P*iHSTsJeir9yu7^d@bImzt;)*Ewzjs< z&(C9HW3H~QK|w)aU|_$$zley4q@<*Fc6OAMlpzLXJ=&tW@b!GOgT9@ z#KgoiGc(}e;Cp*}_xJZCBqZ(a?JX@WJv}}9KQ;99^p1{>oSdA5goL`fy2>*uQBhH* zrl#vVF|4et<>lo~O-=9b?>;_0jEsyXCMHi$Pgz-6(9qCIN=hjyDdXegFE1}HE-uBz z#jC5UprD|$v$MXwz9J$bH#aw2TwGUISA~Uz$H&Lp+uNO;oisEwEG#U7f`YHFuW)d1 zT3TALu&{=PhPb%6o12??d3nLX!A3?#l9G~|nwqDlr)6bjYHDiL)zxHVWWBw;fPjF} z(b1}^s`&W$US3`*Dk@!FT~kw2_V)JZ=;)W1myeH+b8~Y`OG}}lp^%V}=H}+Kw6w{| z$!Te6xw*M=a&mrten&?~ySuyH-QD{7`uX|!pP!#}bacbR!`a!{`}_O+{QUnxHZdQB zasU7W3rR#lRCwC$+E-H(VE~2UV-g_-goHq%lu!h0pkOZu_JY0l-h1zr4$`YAy>}ud zBv}%Fn={-vk{R#z`*1kV#ooTN^9TC-Aj9amV+=A_ zi++wwVm5i;@S4Lg-Jrv{!|a8c?zcG)Ok%pB&z;t&38<=nX$XAvEwT7RU8_Tb;H%v3 zg1Q>)U2S^!+UkJ1bgeD=X!uHo$~0QQmpA{N>fsJkGkm0egzDjTtWzL?MvJt~lEPbd zLxQ!5>1mi{ypSSzOH3OWQ2!x&}l@5 zm&61ib(J6H>Yx=F-Wz30L{^J-Ty%IK0?53Qc2F-mT(Jlfmo5Vsu7}P~V7PN>!X8w% z*S^kv8~DmD-~=Y4B|1pf040hnDPBf=p= z;4&c6tBM`ZABnTq(tVgfnO2>OG3Sn-?X=B_jh|pUHx01XMxf`c`4P3YgLCRmQ z4UJn6dhUEm>LF*+3fsf{JnN?_2r#_Hx*TDa6Ii`|Qdt!pY-vEAgYO$eL_|bHL_|bH bBp-eQltzz?4j@AQ00000NkvXXu0mjf^hMJa literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-multi-page-rowspan-with-footer.png b/tests/ref/grid-subheaders-multi-page-rowspan-with-footer.png new file mode 100644 index 0000000000000000000000000000000000000000..b91046675c4e7b691ee56ce05c30bed9dd747eb5 GIT binary patch literal 1190 zcmV;X1X=ruP)+1bLv!tUFMdaySsjVespwnw6wH@goKZekNNres;a7Ta&o!3xr2j) z+S=M*Utg7#l`1MKtgNiOyu6>EpOTW2-rnAGb92DJz=wy2ZfKPvGcz;b;NT)6B2-jVA0HpJwY9}EDa*^t@bK{Q@$vNZ^oWRv&d$!1l$2|0Yb7Nm z!^6XyCm?ej80zZk#KgpAW@b4#Ia5LnFYoW~c6N5-*J3Bk=?d|gN^8Nk&V`F2WprE9rq{ha^LPA2Orlxy)dl?xS`}_NXf`aDe=J@#d z($dnmx3`Oni<_I9{QUf~va;*z>p(z2o}QlM$Y8y4#?L_6!U6AVAZ>c~dAnehUId z?%Z}F&jcYNIc^+ptQGCGf~tU^S&+km`M^xbXBK9w-~KOM|I)tLF5eX0?x zs4B`nl~`QZ)AX-%^3lWR2K-0A{)Ru=eSudU65-mN_!(CiPOU}E7=CXKs16&~QK*07 zcD+^|*0loFk1-F`;now$t2gdi^r7LC80c3RhRM+g&1^!rtM)>j#8Yi@;9^{5*f2eY zvL%D^|M>jS;2RvDm^!UG>^K693^>jGs>2=|kmv$%%Aq!F@3A=%_C-QNM`v1^6@~!| z0DlUGvDmfiO2e5LW;iFq*jO)xh$)mog7)%C zh5WJ+S%rkoeOQBpy|s{0SA!}@sNa8}4QYHs&oILbGt4l<3^U9y!wfUbFvA>@aY)7? z8HZ#Xk`WPyWE_$a5phVyAsL5c9FlQJ#vvJpWE_%lNX9qx3^UB|mJeUK|3D;+Ug#kq z-qQ#XiF3tbAH4D`eECu%M05}8`C5dCh=_=Yh$uh&22a+>M5^?6$^ZZW07*qoM6N<$ Eg8dy_>;M1& literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-multi-page-rowspan.png b/tests/ref/grid-subheaders-multi-page-rowspan.png new file mode 100644 index 0000000000000000000000000000000000000000..342b05695b9a8dd07766cad52ab259497b9c76da GIT binary patch literal 1048 zcmV+z1n2vSP)&HKtN4RO-xKosHmvplp8R#uyvo8sc)ZEbC0 zVq*IG`p(YImzS4DMn;sBlxu5ih=_>8!^7Iz+9Dz%R8&;U%geH|vWJI<^z`)H+}u)9 zQY9rN?d|Ou85w3~W;r=IKR-X5oSgCT@$m5QiHV8o>gvSA#NpxLRaI3}Q&U}CU1@1) z%F4>Iv9aLb;Ip%{YHDgoNJw~icy4ZPJ3BivGBR;-aX~>rJUl#eb92DJz~|@ZH#awB zWo56gue`jxl9H14_V(}Z@1mljjg5_2Sy|7|&(YD*WMpKyxw&$3a&&ZbpP!%U>FHx* zV@pd*M@L6~etx^VyV%&+p`oGG)zzk^rb0qO{r&xrkdVE-y?c9m`}_OC!otnX&Ft*# zprD}L-QDZ!>)F}aSXfx!-{1WF{BLh>$jHb500175wWk080uf0>K~#9!?b=sck^vaT z@jEsM1SUfi_ipdK_ukufS-C13=ExYb1rzA|cGLxNb=vco+`qH?=zlSe-uD3!5&cgb z4VI74ko_M01P!@FWDXj>E#&8+!5I4dWf}EkTfW#-sLa9qVj&L+{(!G>xiYQK(#aG= zM7DRL%G1J98Y1q+v#1hDnIIy0*BR(Wh41`1h}hNT>fVVe*C3-@LbZwUs8slNuoV@r z#~>BHgW1DSP%goKNGaUB7Ye2h99BvZ$3-? SQMQf%0000NFDhq1R)ee zK7tJh1r3S_ZBY!Rfnr0Yv{l+>`ZdS3OI`4i%$Yy%WX`!a_ujd0CW$~#62lBL%rL|M z0f)NpEkoeNZ}vP0?A5ksLg4C2hXMlIY9{Wi$!b*paKtxu5paKUM2RVN`_*lNzzb=M z5ZGL>le~(6i*v-)Ed>0bgoq&UWnCWzzDc>Wpi*}|0Kw&-5%ALOCtY&ebHy=msJt@= z0_(bI8v>Vhf_glCZeHY!0{9N7?`vKfqm6Z4E*StXx2!GH%_I=?~ooBO@J1OwPXthrYyq1zg}VB zrL0JB4*@r)M8{PKn9le+A`rN>zMUX&_^1a1QyoFzsIY{AU4wj^$}qzW{}YUQxW^1L z%rL_oB;z32p$d|fDL_H88CdeA#2v%H=LgewKmine6A0G!A>jG6nOh0RQa6>62LeY_nzy{?IuN)vMI2H}09Z;58iSBhH|Ye>Wyp~&3`~`T zfyd@Ba7#7^$v8;HK{5`KagdCIWE>=uagglz^ezOZhw1M?L9()01njR9PC;N(D-}ZE z(h3VvFv$%DYIXoe30Q{|fk1YXDu z--W=|fj+amrjyNJG(q4_cVh?8wHVFJOS5Wf7{6OHIx&}1_{vus+LSWKA zBM9ucLV5^1)h4v@bYhrch8bp-+gE2>2D~)H{$2{2VR`dbKsoAc`6L53c-Q}3l{tj@UQ;U zuMv3iZRP+1FLuUvAaFF1tw-RgGvlo@>uOhj$*$z`1UwKZH8WAi_kK16eo;4xz{&8} zz%&6L-RDiu67aL*W`@AKyYDe@lkI;3McsD}f=|98;Pm0keY$h43VgQG8*X+0?1F{c z1tjW@?p{OS zOSC!w{!!&dKO#||cn~uL&TW0pz}938oF8Q1)E+;dCE)AZ%E<@;TW$VE8G+-GTZX`; zO-l@H=M902T^RvHhf&~i}ELgB$!GZ+~7Q9B_KRD@F&Bi%_rvLx|07*qoM6N<$f+}+q ARR910 literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-non-repeating-header-before-multi-page-row.png b/tests/ref/grid-subheaders-non-repeating-header-before-multi-page-row.png new file mode 100644 index 0000000000000000000000000000000000000000..3db97f782ed52b337bcbaf7a0ddc7354d76feee8 GIT binary patch literal 410 zcmeAS@N?(olHy`uVBq!ia0vp^6+m3c0VEi%2k%EaktaqI0hTW_aCiDQNP zoxOvd9a97ZMe_7`9bKF~{KQ(I7rnmHf z?b46{E}Nb+TX-z2J8akUROD{k@aFQXmklBBN|?{{SA=)7etrGXO=3L^J_i>0FS5tlc=`#}>EAxj`_Xnh<|}veYTmTU z0~>aAYj;n5mgOYOc9?NX#0QQ1bIqM8EQcQS^BtJ#w!YKze(>{7i;C=50_(XS1}n^V k?0M~iVg=N$hw&`+%$ENz`o#vX`vnSgPgg&ebxsLQ0F=zDPyhe` literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-non-repeating-orphan-prevention.png b/tests/ref/grid-subheaders-non-repeating-orphan-prevention.png new file mode 100644 index 0000000000000000000000000000000000000000..0f37c5d1714e88a400f8e326333124a79d7daec6 GIT binary patch literal 347 zcmeAS@N?(olHy`uVBq!ia0vp^6+pa@14uAv+Nr!`U|>}8ba4!+xb^nhelMp&iDQMw z7f5I5axBW`SY)_j<-(TB-1T>3!D zZqky4@4VGEG^I-(DE=DvRI2a0NOkw@74yn?eoyz`Xa7-d&jrIfw`DzoO0;J`|7Eqg zF?b>G9bZ5HWab@Gb#*e&WDifBd*^&-h@G5uoAueS&AMBqFK)Z?Na^!E^G5Hhw-$5S xc-O_xIFNty;!e((il2MAB4!@I3Z!}U8CPDIc=*0T)i+S6dAjqjx4+B z7``jhEjUP8kN(688Xk0Mvb?eRzo7U2&N7yLa~FLP z&0vVQ+`2~bM9_8f8LUj~<+_OpI~64=3V4{!pJ}c4m0~D+JH4EF!cx0ud14$n_P4ql zUB!Jk-+49wqCUH4lE zeCQ6zeIv&-?Mv!pw#MDwRxC`btLu{z3^`ctt%*5uV5?=<-(77#j#zE1ZF}>vHSxlP z8UAGn2`7bkWNNw^=SEJvz{a$1zL4y$jt14U_oW&S_?qf6HEu8aZLlGPo275oT#1UE z?f;hvsrmfqPq=c}R?TOIL1J49&&hwQC%joDCo-w`(5dN0@6FikZWL#TiY$TYa1vUv6>kF9JCdsiC0YSHBesF{dgKa(3A_glz3vBBqr@d!9 z%DK(pyN9kIHq-S<(G zS}e!PA2> zEc1SDn!(jrt|gtMSa5JdBy*#0tMm5ormR)_Xa3E$7bpl{p)R@k3h(r7EKL5cXB`r1 zf0exuVmh~Dkxs7CgOfUfjR(Y6Ss4~gT2jNHTl(2qV2*KjL$CRKv(GbM2RvA0u+%(~ z^+48+)mK>~Uv+8Cb$GB>dA3BvCl==Y(#H>6?L2k7N|a%?->EMQDJ#$GUgKoBHvjFx z148eP9%zheJRqHEEwMwzP0}ZvaYNCe>&eY;3LUHWIwZW_$jYN*&ceK&H{roOi}$M- z8TWSv9G6#4=-hN=wZIPTy}k+?{{OIJo95Mcps%TvCuY`y7sa6rZ#bquTtC5fpDBx# zN|+O`M8qQ&=J$J#95`mtf4Z+>eoH00vET-mcV<-z1-YKh4hi1_rP-z>H6DnYE65zZ z>!;~dgTzDiEQ|Ht8G=I4SS8uxn}2IcxygbFIXq$eQX*?5euR{NG1G9k7}oIMlpl+Y zplO+};{p5fXICFssm!|{?$#a3kkdQCP&sAgdMStA2E!r==59BG4VRi4&vNFqrSP1T zm}Cev_Sl3TMM-BPGyyFYWA&<^Kih$>0LEXM$1z(qpBV;;AXPuyH#s-|Jzd90daCn-^RIrEa_HQWYCQ0)dr|8F<NV})yL!~XZw?`iG+8n9)Q!g+|n>~9`m`ux76ZV+4AhR$PZF{~edSb4< z>-YU{`c%1e-uwCof$#m_+f7ez)}F54@M&xQNAWzus@vf>O=5lMh_{6Wh*Y z$Nl@wwTzQCbKbt+ocr5jLzcRN)$+*;xZe4w>{H!vU-n#g*xq*?4<>G6*wfWwT0GmQ@xW`{?(3EnCj859@45M`&-cLb zl}#*mbBokfKU9QmdG)j7_o{~Z-1`t`629ftjKz(wr%o&Q_4u@nO>SiG zw5d<}8X@4nTo`XY4-oixxFNw~^)QiRvci)Dfw1s+OA0bP00Av2dkhVLKv5DI{s)Rd k_~j6DkI6ElfY*uN*$+Mp`BbOox(XDdp00i_>zopr0QZO5LI3~& literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-repeat-non-consecutive.png b/tests/ref/grid-subheaders-repeat-non-consecutive.png new file mode 100644 index 0000000000000000000000000000000000000000..2e0fe2364549f3c44f6b9758a32e010c55566fcf GIT binary patch literal 599 zcmV-d0;v6oP)c=3GC1Lxs6?;3+vjbOoo1q)sV{I~1U zTLd2YFm@P$OUd+31WsfMQ3M{nJalGaUA@)c3X7B93AiUzX=Fy7-uu-M_*G;CfwS?M z&^Q5~JmAKs2>4}_nIZ6=wr&RAZ-<^iqwc&4!ROu(aB%!)r@pyX1-?@2`Wrm}`*8Vw z85wnJTZbX=+=eLzwhP9eq85=uM@CErc)k) zd*TlaffqObVPH!d0{={WX5ecLE_>b+PaL+)M`Vvr3_*v)L@3X|7BdE(d&Iy&tWy3# zz&C2EgQp1C>VMs+6uXbJ0C*_5fV0 zvs{3T`d}Vv1cLeu16$M>*mW^*pV8Lq({txa)NE%S9;f4SJ002ovPDHLkV1j*%2t@z@ literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-repeat-replace-didnt-fit-once.png b/tests/ref/grid-subheaders-repeat-replace-didnt-fit-once.png new file mode 100644 index 0000000000000000000000000000000000000000..df984bd60952a61f1d96d34020c6b270b4d94416 GIT binary patch literal 877 zcmeAS@N?(olHy`uVBq!ia0vp^6$}iF&Ky7n!ivHFc{^#b(azRflv+zkgid@L+rE zW`P@V!sq6?AJ{te)E^_Jc`JnlIcm1laxjN`eAT`X^!*;|LEd7H8?_G;_4gO&a@MH( zORl`cuwj|^7j*^iyVvrBIArc`>}h=3mhj-CCeNM1pvv4U3>(VV?~!iS`6?Jc-67$3 zqBfgYtQ2#)6)RJ{t@P0YXW6HAO7SviSDURBJkcXPUy7Bfy{z`gfucBr5050+N!Q68E4w#K_lS00gKs+%x<-j0@rr3%+3HyxOJ_AXPSd6}ER1`|#ezu9*r zDvlni4+e#ryTPl6JwS%f3`I%jP5-r2c89TWot(4b(^9=HosHk4`C1$qyB6}Yylxlx zP_poIPg6s^i)~a{tWbZxR-YO#L;I(rFWYp!YKpHAED*n8ut9{AE$o*;!GfA*pm|1Z zDLf}7CK;+EdmNk4qj=N4jd}Xd({hR62=kd?0Ad&eCEY+wkjjs}DNXi%ubQ|Tm7k~a zIX;;7d{WZ^UbYK8w!02Ga_DewKXtp);Ne@o^+^vR-ez#JT;unYVY)8uXj~VZu)X0C zOZ24YGeGjIB`j_xtZO;&wpOU+K=_#jlGfiUk11~W@$35L*A|t%f5SL(`0l=LJg}+2 zNnt~j%txmO>Q|R(-SdlP;y2lP&H2IisFOl0VuzIjn5unv1b(DWU#Y#jtn`AZEqkN2 z%-XX87Q%Yx91=7?w<~PenX`q7X?ari{Yq;=U?>_m8|CSN{FesyGBgZ0gw@Uf0|3Z_ o`WO@(U^!6Ig@%Oizq6mYe_r3Eap7~te^5U1boFyt=akR{0GgD2aR2}S literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-repeat-replace-double-orphan.png b/tests/ref/grid-subheaders-repeat-replace-double-orphan.png new file mode 100644 index 0000000000000000000000000000000000000000..e340e6817a8f7fc875ed35f3cc6241b0980b44c3 GIT binary patch literal 950 zcmeAS@N?(olHy`uVBq!ia0vp^6$}iF&Ky7n!?ODXU(>hx(ZP{ovSFKC0 zG^>r>#ze;QdE(^ze*5$59mOjjF2C{oJO7>=-z$%8POsjqz-a5(!YQogQ^CJs^~ZA- zEOLMCrZP2N*Um~-END!$lVXbIs$O@V?aI1;mhYo~v^GRH-}Rgkczs^9D2I)VC0Ao_ zX7<6$92Sok8SU+9Xy4?*!Zd$n&cOqnlkV_Kl^(Vgm}A}6aQ6B*X0m~H8LN!-B!qs`QW53aHA?ii1~Er!2{f}5<8U78K*lP zcz^gxqdP~0Qh13LOP+7G7{?pyJ+(+b?@N;8E|zrw|G&o<-mfgl}$8;i}8A51m- zH_K~=!NdJcf&ABJ7$hEQk(^|x@_V_{ra2h_9@p5l<2S8|(}g+be$~SHs%GOX+Przb0{B;h1rlS0Z8&E3DlM+CKlRb`2=z$6Rn?B)8;xqB5;CO@DUDykX#2FwZ|CDduOT6F1&vbho&)kLs zOY@KLHWqJPU^79}i`g`9EtYfdn0kLXE?iRQa$wz* z-R50PuO@t0{9w6jrKUi|QQ^&ujjH=E^Eb9nF3)#*aPqqDu>-q2BT9VV3hcP|YXcwC z=`)p$2lhqGW@WPXzoz~%X`g;Uabctx%fDsw;#v+YTFWH*G~JcE(f<02Y^Ln+^|RQH zD{NS{eGLoK@9Rl_1tK1$uNBx~_b*n7rOf)x?8dKUuXe__-n=@0kEXIf#62+%mTmIi z%N!nT3#v^_c>79rcd{^3_Ll8NiXYmvPq8+t-fd~*^(>YYsK~rva!#8E7!#bvDvzHj v`2a%(Yw%%&Csg2aCny@h!D#T%-ex~zL|E^$9WSRbf-ZS_X}6cPwu~Q`45<{pMC$x?#Hv4FP<muvFlIYr&tcs(eUE3}9l< zO8cBHQt)Q6v2c8Fanui`rfmPN2M$ZQ?p-%q(a0ZpGx%cY_c)=0rt3uuRxH{&U7qp5 zRaWzyf;R71#y4V1@|LQE-@Wt9kn>M=Yz*^l$;$`$=Vv~Mu;xF(bd<#EiHQ~r2 zwtH3ex7V!b%E%WxsN2l-qjKT@6^k3hCk5--GaayOw9cNZE*`@;=SIrE2~Sq>`c-2<2QD&a^5*F2i-QdY84iUA5s7;En)Q7d%Rb}!bLx${5otmen``TWn5tLX zJUzo%QF`#Y^eb(Zf|ShX<(xUa+kKhZ`ER*$%6$07W2pXM&H?$F!;gh_DA#NaWxBn5 z@3O}3$AO%8_WpO{1epzTCCIH%$3p#-T(+ z@z?3)6ehoOR>8&6S#!mnyB0jQ6qH#TdXU}Lk+MYK@0~J4xL*HO;i(;XKa=`Rc|}7XK%o-s<(Cckj8?m)9|rZClrJX;Ps1 z*Pp&j%gy{V60DP@EG$-E&;SS&C4unfv6$u8!USbjPAyefDayO& z^UHOtfuav{#Sh9lvi{j;Q*GF&Z79E(|dv3N^IK%R7kt%AEUq1@Y_k3{U zn<8(H=(5JSa=ddaCS5-8tY|04NmRHarX>Xe!43d1K@5n{8eydUz>W%ih z+0#@vRKB0Z;wNLYo<;5(Yt0&AQ0N+Lc<`6`$%l)I+#PcLpakvd>gTe~DWM4f(wSPh literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-repeat-replace-gutter.png b/tests/ref/grid-subheaders-repeat-replace-gutter.png new file mode 100644 index 0000000000000000000000000000000000000000..e87e3b5bf5efeaf190d41a7a883f2ef7cd8fb0f0 GIT binary patch literal 782 zcmeAS@N?(olHy`uVBq!ia0vp^6$}iF&Ky7n!(gIh3llhEO1 zC0An3cw{wCV>xND=hd0&a=wEX8hlyvUtDfhtGGeF3M!h@jd4Q^<#E35niDj)9aZkD=n%9r_j-Gu{z^4uxS57x0g4_>U@aCrBd z;{GSA_GRpPFKd^^O=N&?uUTf1KN%8>Zg9G zZeZ1)&C(~X!lqL2`>ccHW0pB$7E7Dt!(#O(9w^TK?NS>2bM_P#IZeMi6AnC%o%{EU fp#cggusUylxa_j{+5DBaK?&W{)z4*}Q$iB}o+4uK literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-repeat-replace-multiple-levels.png b/tests/ref/grid-subheaders-repeat-replace-multiple-levels.png new file mode 100644 index 0000000000000000000000000000000000000000..d6f691e43d734335c994184c2bbf699a79f74ee7 GIT binary patch literal 877 zcmV-z1CsoSP)0E zc1N>{fNy8{S2q#x`+O0Bz?V#&7&udMra(j8@(2VME+F8=o6lO*cIFCW;KM>|76dl6 z$~FYPV+GCe#F?dG2rMgY`|4Uw-7oy_%nAT@B*@$lWT-E!GzbK?o$tlK5*Nh4k$Qo^ z->!Qwurb9yqE!H}|5T{g4Vhy@hgl%7>%=k!mK%bB*M~50Us~|19RUx^$K4oM$^-%jEsGdmR~|2@@tvm?0VGm@r|& zgc*`CB*T4?tV`1kdaHF)5IC@`g}}S{^YIY4Y)}dy@V!P5JeSasl!t)D52<%PvjYHs$2Nu1$jy@3-{JgX%7XohuQ}!XS6u*u@;MpACeF(hV z-Dy+Ta&nm4dLgjYRoM)I<C$wH2S#C2!Y>* za_1nh^cw^M@0|ndYl7SAln00000NkvXXu0mjf Du*`r4 literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-repeat-replace-orphan.png b/tests/ref/grid-subheaders-repeat-replace-orphan.png new file mode 100644 index 0000000000000000000000000000000000000000..c28e9d4ff5664d8c7384bdffc74f091d7fdd2265 GIT binary patch literal 939 zcmeAS@N?(olHy`uVBq!ia0vp^6$}iF&Ky7n!Sh*_Ws2yNNPb`<;Hmkg`($j5!C(wmI*Q z9$3Vg@StF?!-K7}Z%gdZ36wk&#JHhI>&EN0HyigI))2T+n#9L^I``lKZDxrb`j#7V zoDaNDzIt&vN5muZ9xax4=d#2(WUMM18ka^XeCVqaWuCrVYZ{a2$8OW4e|$F|?{;3` zoTPcHtx+}7;KRRco*e&g)wUczB7G7J*crX=Yq12?Hu*M`)YparV#3jkmqEKPh|&|N2OaMa+4wL&Eb^Lx%?s*@B%@e7`Qs&5>fd-WM{( z@xh0%APFY#QG1(fY8V^{vbGA3VG|H8@u_pRl+kx!lZIX6}1VHiNY{VxH3KC;HK?e#; n4qJ-QM*xC>MIV`njxgN@xNAjUttt literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-repeat-replace-short-lived.png b/tests/ref/grid-subheaders-repeat-replace-short-lived.png new file mode 100644 index 0000000000000000000000000000000000000000..d041888c8010e6fd37717a34b3404045ea65e90d GIT binary patch literal 795 zcmeAS@N?(olHy`uVBq!ia0vp^6+rxn14u9|R*Bodz`*p#)5S5Q;?~<+x!#i#C5{#D zcNM5@i1Tn%5fxd|5+>vrp{&|+MSSY6g2g7mhm_U|ZWg-k6uqltgGTA3d!- zcEu^dQqbJ-L5bdrz}wfO4GWZ_McCRmITXBi-G1ra&Lrk}b}TX>zrP<9+_2WsV7r}t z*V@GbKTaN;&CJOXv-j=UxmGMO`;=c6C?&kVwn<5z>EDjbd-qrx`8#)WF`j=jec@w{ z9e-a;pVno-;1}g6-m&l|}L6fU5B?WA@^@R(rW;t%&{9tvqLBWAJosEm{w%TXwFhu-uoh-}LxIMj9 zaYOjiJ6oF?cvr3WeYIs$-LEA9A9_DuT`KV7@~3wiEYohaYjXTqr4q>8sQxZ;zSe7% zhner+wH|o(V5%R7&gFJiru9$SZ3KRlb*`Mj{yDT!SG+`u1`1QCum`U1=> za#_Y(HM3XQ@4k~Pu%k5MI!9x0@lQ<-9j$l)rrDw2lm#j@#UewlU0?ZslA!U`b15&g zIdryFH6EB~d(w{MPVvnv3)ZasC)cU#^dRi;O)n0a-si^@H%!@W@FC%2I~Rw|>$2X@ z9L~THIK*?(BBq5?SnbS&9z{uKBT$G)OfpnS_Be(|V28CJIQ$NQbpC1oD*fz6vRS4n R>k3e+_jL7hS?83{1OS<_WHSH& literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-repeat-replace-with-footer-orphan.png b/tests/ref/grid-subheaders-repeat-replace-with-footer-orphan.png new file mode 100644 index 0000000000000000000000000000000000000000..b50fae716c252da72a6a60daa32ac8eb18113a49 GIT binary patch literal 961 zcmeAS@N?(olHy`uVBq!ia0vp^6$}iF&Ky7n!h6T9hoLBLtq^Kg($^A(*`qx0=Sn{_68Zv5Er ztnk=jm&HrWES4;@Cai2zO(Z= zB{<({de6eNuQG7v4A%p-AC;II&x>41eBjaflSS%P7rTN*CI=&be0|Kpa=rBjB-y7t z(-37?ZRI|d+2~3oYgOZc)wk0OH!R^`tGnLY$gQujcc~VGnf$cIuoD6ErSt_kax8AP zG`eo@Y_#=fWvbu3siSdo)70;US`1-)yJo$aVClD7QGjtHjbUZEU!1j2O%ZtP9xW%l0Z@ZjkdNuD{f3neCn9?`$%`CI&i%>4{z zTNRysZag`c?jApo_*IOf#T0W zj26jBhAO|;J8in{xM0E>?y!AFZpTPiEckeXNy9<&6!XEKr5rodH@x1|)KIV6xAIn% zht2NQC26Y|ZahDlM+DF zlfeQv?;kqFC(P#O>DzwbbhV>muVYaM&wjwwNflg{mnUj17$Vmff zQ=jzL_=fGvwgcZ**QzTNeAB+!aKO`bfu-E#tJjH3ma!dthlveo$E=~kq}GVeA|99!dh1a_`5^EsNIPUimiBFfFvc^_5vcfjLd##}ac7Fn#2!dI6!wAA@C?kLLk&y*;QlIEJVKrBsj<%+~!kF#Db%~y0vf9fpzF(b`Pbgonv z|ISR^S(9$vDu|lacQ*I?{_+cs?EhMRZ#Vytc%AwB(RKP~+#TfQ54CU#tNoGx5O()( zzd*&O&oMGA+r&d#9TR-#Te2`o%YRumBV_BU{j+VkS99#Rd0n(w^3^JCZKlTK&1#Ap z^bWqV?r+F-3-Lnk}rX1mGZrk-uki&1j(}ByK=d-SsJ^z&OVC|P# z85}V&>e>Q7%6K#S*!$0ab4ZxEIzIe*SytssnbiRd8w?%Pb}2g7maU6X*znFrnoVqf zF3+1R)>VscFb(kq?;PWcNrE`ANdw;%k=J3 zxZ6CX1kR{+0SX1hPR$Mp=l|JDM0Bw*t z-^g3HQ^MlWR!*k#Z&>f{ENQ=&nBcx(1&^F{!r=qQ42|!L0Yk~>N4&wS!{qA7GB5C) zu2X`ht}sUq-}E33n}Bt?;n!EiUF`RBd@$+Y9~+K0TiYrXH@M8w5!j)&$(W02ciptdN1OfL)(}>D$$+IyQb^%L)~6-*EazS_*=D7MG(G5SIQF1|MW^gD|MpdUoVAVH zB$&#rf=UG<+9t6u<+oX|DHJ@8&0V!?on4&B9~*09J|_MA_I1t)C#xQ}9(cU5&syNd zg-c6v%pdDC*X vZL+KfbNStWT8jf+3w9~U*_^^{hw2$)R<{)Guzvj=lyyB_{an^LB{Ts5mQ$mD literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-repeat-replace.png b/tests/ref/grid-subheaders-repeat-replace.png new file mode 100644 index 0000000000000000000000000000000000000000..9fe729401af3dd9e8c9fb298a5f7b7baadd159e9 GIT binary patch literal 953 zcmeAS@N?(olHy`uVBq!ia0vp^6$}iF&Ky7n!T@gj{Qkbx3x4Y(`~;q$q6Tgc=lLzH2xN@)mCHq zqvs`C-PE9(wmGo%fbC3KrbcGU@`KB+YxnD>~EM<CQ7u1hR}(l0Ck~x2Al5e)s;X+Ztd*7G5b$Sq$8_36U&$7c-z-M~qp##6JOwHlg zW0yDU>1J1;AB599jy+WJnPHF!vh+uPO8tSxr3@@(DL!+W4~XU;;cooAb%F7`?l-TR z4m>hrcX~a^AoOLH!iNU+^9+qgXDAEYkbN%McwnyUu00oKy;9(+i;J4T*Jxh*(OMva z>pr{Uhm={*?O5j3-_7M%yM6M~jeGf-w%=GaOL2ovtSEFRFXa@=%zRy&&rIF3t548L#QxNC-Cy9x!~MUx@9W{ejX-M~!wfUbFvEWU z3&z{S5cvJLGZ6wWmDbB4u%^ou4S^kbo$3imrFzEYom%ottpy#fIkXVk^(}58=rA4h zr4~+_0ef6t+8S94*kg}#k6%*(fN!QOlJ_S7?n?(9r@mNiGX{<)RSyJCr^X@>oMlJA zgUJ<*QhRgxF|d$jxCDWC?N3l z4$6eU`6ZOrjezG*+)-xoU>+2>tVs)jpQsvl_z`esOF$$9+#cf}gTRz?CJqBDXbl28 zLI(%}k4H>nVBZmv82mJ37z2CVg48YvQ&p)E0b2^`Fd^Vk#l%$z{7DZ=wRZOvl??$W z$w|0{fa`V{A1or^&$4`F3j~g#H`x&Q9NE+e_@!*U?$ibV9_n@5d%ZluFw8K+4F48J zE#6~>8D{vOh69Blm_!i#>M{rpgo9v;0KuDkL2#R@9|Kc0DqJ5yussBpYN^3O7zi%g zj)A8Q`!MiI(}ndM696XHMQ1%h;K=Yr2t2Orj`Aa5o$^OfdO%8#JrM<0MkRGET1)g=2;An&0M#+xrV#{Q zi}slz@Z(&41`!v@xJbrDGA@#Fk&KIETqNTn85ha8NX9V33^U9y!wfUbFvAQp%<%sV Z{{rJ)W=rgkmcRf2002ovPDHLkV1g23iL3ws literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-repeat-with-footer.png b/tests/ref/grid-subheaders-repeat-with-footer.png new file mode 100644 index 0000000000000000000000000000000000000000..39f8465e72743d1cd02666360d20a045f5c8518f GIT binary patch literal 584 zcmV-O0=NB%P)d4jWsoXrOhbLkk*-hhdAIjlSsu+Eecc~su6K9{~pDBl@ z{{$_iPqA+0a7|(8(Eog>{))XCXghgx23#1pNJ=&!uqI<&AlSN#fcGl<$ISN4^^SqV zR-+gK4>;u-0@oR!d%T|HTZF*k9}k*rJNZWa5P0AEUJZep7C@tJ?~n)te#mNc^T%8X z9PPH-Ah0nk9tf*Oofdn1(Cvht# z+fEL<0fFar8G*pHv!GEAmB|?dj%pd@5LgQ27y^fqLjr-<{x4(TmlQD>y!H1E10QS! zc(zEGFk!-9!KlT1OqeiX!VJk6lKrZX%#scY$vm*~MZ0;!z$sH{x1a!JuondDE(E-p z>M46gz)$zSsL9?=v-U*@yxu9@5V&C;H0oZ9+(F<-P2(yEaktaqI1^y3Z>(pqI-8r>T6bvt3=Lc=VQDc;LQr%Oa-}0WFU%I5@N}YMLUJ z#iE+A@yS=N7@3+Do9AqGPi&tH@cmI{iV{53!YQowhx>;9l2-yho?V;Ua6neqo~d!V zzEmK`8kh6W-wSM*%P{xVYnRX5%=~iv+YRD%q%8&3$SOQ|H9_{j%Yl4Zb&i|&SFV_@ zxMAu<9hNwaMz^`aOL!Z18^20D;kWgR!G`%J#}q$QInGo3Fo*G0a`pjD=Y+!Vdnd%I zr(bV9@HHi0ib;Q7qubt1TCp5|go1tT&irTL+f>EHBpsdP{Gf$XYOCbuY=H>BOWzfD z``x>$%~Cg~MymP1%is$urke=dd2#)l!}49KK)K4h4F^*a6?i47>JYD@<);T3K0RTMC B$)*4R literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-repeating-orphan-prevention.png b/tests/ref/grid-subheaders-repeating-orphan-prevention.png new file mode 100644 index 0000000000000000000000000000000000000000..0f37c5d1714e88a400f8e326333124a79d7daec6 GIT binary patch literal 347 zcmeAS@N?(olHy`uVBq!ia0vp^6+pa@14uAv+Nr!`U|>}8ba4!+xb^nhelMp&iDQMw z7f5I5axBW`SY)_j<-(TB-1T>3!D zZqky4@4VGEG^I-(DE=DvRI2a0NOkw@74yn?eoyz`Xa7-d&jrIfw`DzoO0;J`|7Eqg zF?b>G9bZ5HWab@Gb#*e&WDifBd*^&-h@G5uoAueS&AMBqFK)Z?Na^!E^G5Hhw-$5S xc-O_xIFNty;!e((il2MAB4!@I3Z!}U8CPDIc=*0T)i+S6dAjW|_HDUg$ zrM6O4E!P$-GE!QxSL$}q9G+QHSshhPF-PjVyPMDP{bE}5>9e9t-=Xrj2hGL$-^08# zL=7Z(*qRR}pi_HfZCnq2fzhV27KD_l`@#_2KuNq9#f8F)e-*VM_Eyo^-IF|>JTk8cH z=l?%d%Tkxo_Tb&a=NBWH^gf>~d1>8<=?0*o-?{EFxJu3spK@qX8p!9Ku6{1-oD!M< D=nr>z literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-auto.png b/tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..c7d632adeabf46ee397803c8d0de13f6f6204999 GIT binary patch literal 460 zcmeAS@N?(olHy`uVBq!ia0vp^6+pa@14uAv+Nr!`U|^i=>EaktaqI0Ze{GgR3HA@Q z+NUI+uDWyOT~XrG4Nnx7-z~a0#Ur>ob<_5XTu;yEXYeOi&ilk-t4 z9eZci?&18o?eXtDjsE-V?>{fEU}9Ruka(zt^FP;|;|K25FlQajZTZi!&a^2(dEeLb z_crDxJ_yp<%hC9D`P4j)IsSWE6hCa)cCDnhA@=m%4>$Tbh1ccZs6KGu`K=zt#&~Tv zeHJ~xdu@svc1_9M>waLq^|OQQOm#kUSXmkGPg#2OAd5$#lfs9ma~vL&yMD`M*(2Fi zw!vCZ&1Z%I$PL0U#&2df(eofN8I{Khl8JniIy-i}UaiSdvq|?X-?O4nXQoEkr;^O8 zr|#BgnUg)A>zcraj4d^-lE)fEs`+W}mYualr zu;z$k0pm55U(?GC3y!?BW=q{Vk>%iHR+bu*70YMFwA8ZIH$T`Y!C{u@n`Dul_~2aD ubkj^xg-EdLqYP8NN;aKudx6iuG4elF{r5}E)(Vai(o literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-relative.png b/tests/ref/grid-subheaders-too-large-non-repeating-orphan-before-relative.png new file mode 100644 index 0000000000000000000000000000000000000000..324787b2506342de1630d9dc2c013971d0d701d8 GIT binary patch literal 542 zcmV+(0^$9MP)qoX7wB>epR($dmwY;5=U_t)3gg@uKNhK7oYib+XH?Ck7-fPl!z z$cc%G+1c6c?d{gq*5u^mRaI3>OG_>;E-)}Ke0+R_goL1=po4>h*x1(SBC z>FMd{=;-$L_Pe{g#>U3N!ov0S_2uQ|Gcz-soSc`JmzbECQc_ZElL;+mS8etv#O zM@O`@w9U=UwY9aOp`o9jpGHPT!NI}m>gunrukrEmc6N5|?(SP#TS`hwFE1~Rjg8LE z&fVSJ=H}+Iva%r|A&iWS^z`(-y}kYY{r^EW{n;FJ0002#Nkldd5Jb_o zg)E#%jyU0*bI!)bWFhW<8+IIlF2R2n_*Ju+O$z|httX45+mXpM;StHq$RnI>YqG>y zSv2a`H*$y%uWsegtL&HMu#nr?Xv!eUM)L`u5=ghCC5zSEVkn1|Lbxu6(&K$u4#kJN z%L^GCpPZhZ%iySTke9>Wt~LW%l(uBCyd;aCsy<^GeAoUy_!|HK00000<6+8#%L8CY z#*mCKLo!K<-rnnSczM--AdBCcEQ0EnEQ*D-IT<`ZZRayG=noMlOqeiX!h{JECQO(x gVZs0a0058{1Ky);tIJX_@c;k-07*qoM6N<$f*kc6MgRZ+ literal 0 HcmV?d00001 diff --git a/tests/ref/grid-subheaders-too-large-repeating-orphan-before-auto.png b/tests/ref/grid-subheaders-too-large-repeating-orphan-before-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..de77beb29ac5d995e907af95d71ace020ada7054 GIT binary patch literal 525 zcmeAS@N?(olHy`uVBq!ia0vp^6+rxngBeI}ocJ*gNSz7r332@o1de83zI@rcckiW3 zm;U_u^Xu2IyLazizkXd$Pw(8hb0(}@8_D-KZ{pr)EU%!4eHa0$V=+Loa$CfW&K5N#jhK7c3-@fhMz1!2% z^Ww#ej~+d$tE+qY^5vX4b6&i7@$lip8#ivee*L<+xj85(XvT~gZ{ECl`}Xa%Yu6MN z6?=MmT3cJYy1Lrh+BR+4l#`R=?d=^J8v5+nGZht;fB*h1UcA`G#^&$ezd#RZNt}oP zQhz*M978H@y}jigD?y+a_?0%5P=PL^%sl{U+{u_5s=gkf+&`oGfWgk>{sC*=b*GkCiC KxvX zN_KX3@$vDmudh;4Qka;S>+9>{;^KaOewUY*oSdBL>FJG)jpgO#Gcz;w_4UHS!p6qN zr>Cd8yStj2nt*_S_V)JZ=;-0$;n>*NgM)+7(a}jsNiZ-le0+S_+1ZJSiR|p`?d|Q> z*4B!OiiL%R$jHdf&d%4@*WKOS=H}-5`uhC*{L<3Wy}iBk^z{EhHWgZ%)&Kwir%6OX zRCwC$)x{CQ002bM4Z+>r-Ccr3=)Wj#RKT#4VP6CP_#q)-s3I?lQOM+oC z9(Fq*n2rYh9tdX3)fx=*1vXDmSX)r+_Mj*hknuoJ%IC6Rczr0Wu)+!}tgylgE3B}> f3M;Izh + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FirstHeader
SecondHeader
Level 2Header
Level 3Header
BodyCells
YetMore
Level 2Header Inside
Level 3
EvenMore
BodyCells
One Last HeaderFor Good Measure
FooterRow
EndingTable
+ + diff --git a/tests/ref/html/multi-header-table.html b/tests/ref/html/multi-header-table.html new file mode 100644 index 0000000000..8a34ac1709 --- /dev/null +++ b/tests/ref/html/multi-header-table.html @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FirstHeader
SecondHeader
Level 2Header
Level 3Header
BodyCells
YetMore
FooterRow
EndingTable
+ + diff --git a/tests/ref/issue-5359-column-override-stays-inside-header.png b/tests/ref/issue-5359-column-override-stays-inside-header.png new file mode 100644 index 0000000000000000000000000000000000000000..8339a4090d6cc71b8eff6890a9f9d37453dd5fdc GIT binary patch literal 674 zcmV;T0$u%yP)JGgviH_kPLjn0MN)2TI4$-6e70 z>7#r6!I#YdY!&|F=x#<{5{GBKPh8?RU|WOV=CnXL)@Xvk zVZQp^pX3dWNIqccSSXSMeKih+W7cE=8*ZY*>Ye(}qEW)Owbf@^^B(}XZ~#~9&^1wfE1?2<*{-}#yy{#8}*&a7nw zgo;~69{9MzQimqm?C86sH28+;fU>o24v4 zETLyi;WXeMRr2sa6^6nTrZA8D4VwpGPtrTzGXMYp07*qo IM6N<$g0lNP@&Et; literal 0 HcmV?d00001 diff --git a/tests/suite/layout/grid/footers.typ b/tests/suite/layout/grid/footers.typ index f7f1deb0ac..c0b03f50a4 100644 --- a/tests/suite/layout/grid/footers.typ +++ b/tests/suite/layout/grid/footers.typ @@ -389,6 +389,29 @@ table.footer[a][b][c] ) +--- grid-footer-repeatable-unbreakable --- +#set page(height: 8em, width: auto) +#table( + [h], + table.footer( + [a], + [b], + [c], + ) +) + +--- grid-footer-non-repeatable-unbreakable --- +#set page(height: 8em, width: auto) +#table( + [h], + table.footer( + [a], + [b], + [c], + repeat: false, + ) +) + --- grid-footer-stroke-edge-cases --- // Test footer stroke priority edge case #set page(height: 10em) diff --git a/tests/suite/layout/grid/headers.typ b/tests/suite/layout/grid/headers.typ index 229bce6144..ea222ee886 100644 --- a/tests/suite/layout/grid/headers.typ +++ b/tests/suite/layout/grid/headers.typ @@ -118,30 +118,81 @@ ) --- grid-header-not-at-first-row --- -// Error: 3:3-3:19 header must start at the first row -// Hint: 3:3-3:19 remove any rows before the header #grid( [a], grid.header([b]) ) --- grid-header-not-at-first-row-two-columns --- -// Error: 4:3-4:19 header must start at the first row -// Hint: 4:3-4:19 remove any rows before the header #grid( columns: 2, [a], grid.header([b]) ) ---- grow-header-multiple --- -// Error: 3:3-3:19 cannot have more than one header +--- grid-header-multiple --- #grid( grid.header([a]), grid.header([b]), [a], ) +--- grid-header-skip --- +#grid( + columns: 2, + [x], [y], + grid.header([a]), + grid.header([b]), + grid.cell(x: 1)[c], [d], + grid.header([e]), + [f], grid.cell(x: 1)[g] +) + +--- grid-header-too-large-non-repeating-orphan --- +#set page(height: 8em) +#grid( + grid.header( + [a\ ] * 5, + repeat: false, + ), + [b] +) + +--- grid-header-too-large-repeating-orphan --- +#set page(height: 8em) +#grid( + grid.header( + [a\ ] * 5, + repeat: true, + ), + [b] +) + +--- grid-header-too-large-repeating-orphan-with-footer --- +#set page(height: 8em) +#grid( + grid.header( + [a\ ] * 5, + repeat: true, + ), + [b], + grid.footer( + [c], + repeat: true, + ) +) + +--- grid-header-too-large-repeating-orphan-not-at-first-row --- +#set page(height: 8em) +#grid( + [b], + grid.header( + [a\ ] * 5, + repeat: true, + ), + [c], +) + --- table-header-in-grid --- // Error: 2:3-2:20 cannot use `table.header` as a grid header // Hint: 2:3-2:20 use `grid.header` instead @@ -228,6 +279,51 @@ table.cell(rowspan: 3, lines(15)) ) +--- grid-header-and-rowspan-contiguous-1 --- +// Block should occupy all space +#set page(height: 15em) + +#table( + rows: (auto, 2.5em, 2em, auto), + gutter: 3pt, + inset: 0pt, + table.header( + [*H*], + [*W*] + ), + table.cell(rowspan: 3, block(height: 2.5em + 2em + 20em, width: 100%, fill: red)) +) + +--- grid-header-and-rowspan-contiguous-2 --- +// Block should occupy all space +#set page(height: 15em) + +#table( + rows: (auto, 2.5em, 10em, 5em, auto), + gutter: 3pt, + inset: 0pt, + table.header( + [*H*], + [*W*] + ), + table.cell(rowspan: 3, block(height: 2.5em + 2em + 20em, width: 100%, fill: red)) +) + +--- grid-header-and-large-auto-contiguous --- +// Block should occupy all space +#set page(height: 15em) + +#table( + rows: (auto, 4.5em, auto), + gutter: 3pt, + inset: 0pt, + table.header( + [*H*], + [*W*] + ), + block(height: 2.5em + 2em + 20em, width: 100%, fill: red) +) + --- grid-header-lack-of-space --- // Test lack of space for header + text. #set page(height: 8em) @@ -255,6 +351,17 @@ ..([Test], [Test], [Test]) * 20 ) +--- grid-header-non-repeating-orphan-prevention --- +#set page(height: 5em) +#v(2em) +#grid( + grid.header(repeat: false)[*Abc*], + [a], + [b], + [c], + [d] +) + --- grid-header-empty --- // Empty header should just be a repeated blank row #set page(height: 12em) @@ -339,6 +446,56 @@ [a\ b] ) +--- grid-header-not-at-the-top --- +#set page(height: 5em) +#v(2em) +#grid( + [a], + [b], + grid.header[*Abc*], + [d], + [e], + [f], +) + +--- grid-header-replace --- +#set page(height: 5em) +#v(1.5em) +#grid( + grid.header[*Abc*], + [a], + [b], + grid.header[*Def*], + [d], + [e], + [f], +) + +--- grid-header-replace-orphan --- +#set page(height: 5em) +#grid( + grid.header[*Abc*], + [a], + [b], + grid.header[*Def*], + [d], + [e], + [f], +) + +--- grid-header-replace-doesnt-fit --- +#set page(height: 5em) +#v(0.8em) +#grid( + grid.header[*Abc*], + [a], + [b], + grid.header[*Def*], + [d], + [e], + [f], +) + --- grid-header-stroke-edge-cases --- // Test header stroke priority edge case (last header row removed) #set page(height: 8em) @@ -463,8 +620,6 @@ #table( columns: 3, [Outside], - // Error: 1:3-4:4 header must start at the first row - // Hint: 1:3-4:4 remove any rows before the header table.header( [A], table.cell(x: 1)[B], [C], table.cell(x: 1)[D], diff --git a/tests/suite/layout/grid/html.typ b/tests/suite/layout/grid/html.typ index 10345cb067..cf98d4bc5a 100644 --- a/tests/suite/layout/grid/html.typ +++ b/tests/suite/layout/grid/html.typ @@ -57,3 +57,78 @@ [d], [e], [f], [g], [h], [i] ) + +--- multi-header-table html --- +#table( + columns: 2, + + table.header( + [First], [Header] + ), + table.header( + [Second], [Header] + ), + table.header( + [Level 2], [Header], + level: 2, + ), + table.header( + [Level 3], [Header], + level: 3, + ), + + [Body], [Cells], + [Yet], [More], + + table.footer( + [Footer], [Row], + [Ending], [Table], + ), +) + +--- multi-header-inside-table html --- +#table( + columns: 2, + + table.header( + [First], [Header] + ), + table.header( + [Second], [Header] + ), + table.header( + [Level 2], [Header], + level: 2, + ), + table.header( + [Level 3], [Header], + level: 3, + ), + + [Body], [Cells], + [Yet], [More], + + table.header( + [Level 2], [Header Inside], + level: 2, + ), + table.header( + [Level 3], + level: 3, + ), + + [Even], [More], + [Body], [Cells], + + table.header( + [One Last Header], + [For Good Measure], + repeat: false, + level: 4, + ), + + table.footer( + [Footer], [Row], + [Ending], [Table], + ), +) diff --git a/tests/suite/layout/grid/subheaders.typ b/tests/suite/layout/grid/subheaders.typ new file mode 100644 index 0000000000..56bed6a57a --- /dev/null +++ b/tests/suite/layout/grid/subheaders.typ @@ -0,0 +1,602 @@ +--- grid-subheaders-demo --- +#set page(height: 15.2em) +#table( + columns: 2, + align: center, + table.header( + table.cell(colspan: 2)[*Regional User Data*], + ), + table.header( + level: 2, + table.cell(colspan: 2)[*Germany*], + [*Username*], [*Joined*] + ), + [john123], [2024], + [rob8], [2025], + [joe1], [2025], + [joe2], [2025], + [martha], [2025], + [pear], [2025], + table.header( + level: 2, + table.cell(colspan: 2)[*United States*], + [*Username*], [*Joined*] + ), + [cool4], [2023], + [roger], [2023], + [bigfan55], [2022] +) + +--- grid-subheaders-colorful --- +#set page(width: auto, height: 12em) +#let rows(n) = { + range(n).map(i => ([John \##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789], [?], table.hline(start: 4, end: 5, stroke: red))).flatten() +} +#table( + columns: 5, + align: center + horizon, + table.header( + table.cell(colspan: 5)[*Cool Zone*], + ), + table.header( + level: 2, + table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*], + table.hline(start: 2, end: 3, stroke: yellow) + ), + ..rows(2), + table.header( + level: 2, + table.cell(stroke: red)[*New Name*], table.cell(stroke: aqua, colspan: 4)[*Other Data*], + table.hline(start: 2, end: 3, stroke: yellow) + ), + ..rows(3) +) + +--- grid-subheaders-basic --- +#grid( + grid.header([a]), + grid.header(level: 2, [b]), + [c] +) + +--- grid-subheaders-basic-non-consecutive --- +#grid( + grid.header([a]), + [x], + grid.header(level: 2, [b]), + [y], +) + +--- grid-subheaders-basic-replace --- +#grid( + grid.header([a]), + [x], + grid.header(level: 2, [b]), + [y], + grid.header(level: 2, [c]), + [z], +) + +--- grid-subheaders-basic-with-footer --- +#grid( + grid.header([a]), + grid.header(level: 2, [b]), + [c], + grid.footer([d]) +) + +--- grid-subheaders-basic-non-consecutive-with-footer --- +#grid( + grid.header([a]), + [x], + grid.header(level: 2, [b]), + [y], + grid.footer([f]) +) + +--- grid-subheaders-repeat --- +#set page(height: 8em) +#grid( + grid.header([a]), + grid.header(level: 2, [b]), + ..([c],) * 10, +) + +--- grid-subheaders-repeat-non-consecutive --- +#set page(height: 8em) +#grid( + grid.header([a]), + [x], + grid.header(level: 2, [b]), + ..([y],) * 10, +) + +--- grid-subheaders-repeat-with-footer --- +#set page(height: 8em) +#grid( + grid.header([a]), + [m], + grid.header(level: 2, [b]), + ..([c],) * 10, + grid.footer([f]) +) + +--- grid-subheaders-repeat-gutter --- +// Gutter below the header is also repeated +#set page(height: 8em) +#grid( + inset: (bottom: 0.5pt), + stroke: (bottom: 1pt), + gutter: (1pt, 6pt, 1pt), + grid.header([a]), + grid.header(level: 2, [b]), + ..([c],) * 10, +) + +--- grid-subheaders-repeat-replace --- +#set page(height: 8em) +#grid( + grid.header([a]), + [x], + grid.header(level: 2, [b]), + ..([y],) * 10, + grid.header(level: 2, [c]), + ..([z],) * 10, +) + +--- grid-subheaders-repeat-replace-multiple-levels --- +#set page(height: 8em) +#grid( + grid.header([a]), + [x], + grid.header(level: 2, [b]), + grid.header(level: 3, [c]), + ..([y],) * 10, + grid.header(level: 2, [d]), + ..([z],) * 6, +) + +--- grid-subheaders-repeat-replace-gutter --- +#set page(height: 8em) +#grid( + gutter: 3pt, + grid.header([a]), + [x], + grid.header(level: 2, [b]), + ..([y],) * 8, + grid.header(level: 2, [c]), + ..([z],) * 4, +) + +--- grid-subheaders-repeat-replace-orphan --- +#set page(height: 8em) +#grid( + grid.header([a]), + [x], + grid.header(level: 2, [b]), + ..([y],) * 12, + grid.header(level: 2, [c]), + ..([z],) * 10, +) + +--- grid-subheaders-repeat-replace-double-orphan --- +#set page(height: 8em) +#grid( + grid.header([a]), + [x], + grid.header(level: 2, [b]), + ..([y],) * 11, + grid.header(level: 2, [c]), + grid.header(level: 3, [d]), + ..([z],) * 10, +) + +--- grid-subheaders-repeat-replace-gutter-orphan-at-child --- +#set page(height: 8em) +#grid( + gutter: 3pt, + grid.header([a]), + [x], + grid.header(level: 2, [b]), + ..([y],) * 9, + grid.header(level: 2, [c]), + [z \ z], + ..([z],) * 3, +) + +--- grid-subheaders-repeat-replace-gutter-orphan-at-gutter --- +#set page(height: 8em) +#grid( + gutter: 3pt, + grid.header([a]), + [x], + grid.header(level: 2, [b]), + ..([y],) * 9, + box(height: 3pt), + grid.header(level: 2, [c]), + ..([z],) * 4, +) + +--- grid-subheaders-repeat-replace-didnt-fit-once --- +#set page(height: 8em) +#grid( + grid.header([a]), + [x], + grid.header(level: 2, [b]), + ..([y],) * 10, + grid.header(level: 2, [c\ c\ c]), + ..([z],) * 4, +) + +--- grid-subheaders-repeat-replace-with-footer --- +#set page(height: 8em) +#grid( + grid.header([a]), + [x], + grid.header(level: 2, [b]), + grid.header(level: 3, [c]), + ..([y],) * 10, + grid.header(level: 2, [d]), + ..([z],) * 6, + grid.footer([f]) +) + +--- grid-subheaders-repeat-replace-with-footer-orphan --- +#set page(height: 8em) +#grid( + grid.header([a]), + [x], + grid.header(level: 2, [b]), + ..([y],) * 10, + grid.header(level: 2, [c]), + ..([z],) * 10, + grid.footer([f]) +) + +--- grid-subheaders-repeat-replace-short-lived --- +// No orphan prevention for short-lived headers +// (followed by replacing headers). +#set page(height: 8em) +#grid( + grid.header([a]), + grid.header(level: 2, [b]), + grid.header(level: 2, [c]), + grid.header(level: 2, [d]), + grid.header(level: 2, [e]), + grid.header(level: 2, [f]), + grid.header(level: 2, [g]), + grid.header(level: 2, [h]), + grid.header(level: 2, [i]), + grid.header(level: 2, [j]), + grid.header(level: 3, [k]), + ..([z],) * 10, +) + +--- grid-subheaders-repeat-short-lived-also-replaces --- +// Short-lived subheaders must still replace their conflicting predecessors. +#set page(height: 8em) +#grid( + // This has to go + grid.header(level: 3, [a]), + [w], + grid.header(level: 2, [b]), + grid.header(level: 2, [c]), + grid.header(level: 2, [d]), + grid.header(level: 2, [e]), + grid.header(level: 2, [f]), + grid.header(level: 2, [g]), + grid.header(level: 2, [h]), + grid.header(level: 2, [i]), + grid.header(level: 2, [j]), + grid.header(level: 3, [k]), + ..([z],) * 10, +) + +--- grid-subheaders-multi-page-row --- +#set page(height: 8em) +#grid( + columns: 2, + grid.header([a]), + [x], + grid.header(level: 2, [b]), + [y], + grid.header(level: 3, [c]), + [a], [b], + grid.cell( + block(fill: red, width: 1.5em, height: 6.4em) + ), + [y], + ..([z],) * 10, +) + +--- grid-subheaders-non-repeat --- +#set page(height: 8em) +#grid( + grid.header(repeat: false, [a]), + [x], + grid.header(level: 2, repeat: false, [b]), + ..([y],) * 10, +) + +--- grid-subheaders-non-repeat-replace --- +#set page(height: 8em) +#grid( + grid.header([a]), + [x], + grid.header(level: 2, [b]), + grid.header(level: 3, [c]), + ..([y],) * 9, + grid.header(level: 2, repeat: false, [d]), + ..([z],) * 6, +) + +--- grid-subheaders-non-repeating-replace-orphan --- +#set page(height: 8em) +#grid( + grid.header([a]), + [x], + grid.header(level: 2, [b]), + ..([y],) * 12, + grid.header(level: 2, repeat: false, [c]), + ..([z],) * 10, +) + +--- grid-subheaders-non-repeating-replace-didnt-fit-once --- +#set page(height: 8em) +#grid( + grid.header([a]), + [x], + grid.header(level: 2, [b]), + ..([y],) * 10, + grid.header(level: 2, repeat: false, [c\ c\ c]), + ..([z],) * 4, +) + +--- grid-subheaders-multi-page-rowspan --- +#set page(height: 8em) +#grid( + columns: 2, + grid.header([a]), + [x], + grid.header(level: 2, [b]), + [y], + grid.header(level: 3, [c]), + [z], [z], + grid.cell( + rowspan: 5, + block(fill: red, width: 1.5em, height: 6.4em) + ), + [cell], + [cell] +) + +--- grid-subheaders-multi-page-row-right-after --- +#set page(height: 8em) +#grid( + columns: 1, + grid.header([a]), + [x], + grid.header(level: 2, [b]), + grid.header(level: 3, [c]), + grid.cell( + block(fill: red, width: 1.5em, height: 6.4em) + ), + [done.], + [done.] +) + +--- grid-subheaders-multi-page-rowspan-right-after --- +#set page(height: 8em) +#grid( + columns: 2, + grid.header([a]), + [x], [y], + grid.header(level: 2, [b]), + grid.header(level: 3, [c]), + grid.cell( + rowspan: 5, + block(fill: red, width: 1.5em, height: 6.4em) + ), + [cell], + [cell], + grid.cell(x: 0)[done.], + grid.cell(x: 0)[done.] +) + +--- grid-subheaders-multi-page-row-with-footer --- +#set page(height: 8em) +#grid( + columns: 2, + grid.header([a]), + [x], + grid.header(level: 2, [b]), + [y], + grid.header(level: 3, [c]), + [a], [b], + grid.cell( + block(fill: red, width: 1.5em, height: 6.4em) + ), + [y], + ..([z],) * 10, + grid.footer([f]) +) + +--- grid-subheaders-multi-page-rowspan-with-footer --- +#set page(height: 8em) +#grid( + columns: 2, + grid.header([a]), + [x], + grid.header(level: 2, [b]), + [y], + grid.header(level: 3, [c]), + [z], [z], + grid.cell( + rowspan: 5, + block(fill: red, width: 1.5em, height: 6.4em) + ), + [cell], + [cell], + grid.footer([f]) +) + +--- grid-subheaders-multi-page-row-right-after-with-footer --- +#set page(height: 8em) +#grid( + columns: 1, + grid.header([a]), + [x], + grid.header(level: 2, [b]), + grid.header(level: 3, [c]), + grid.cell( + block(fill: red, width: 1.5em, height: 6.4em) + ), + [done.], + [done.], + grid.footer([f]) +) + +--- grid-subheaders-multi-page-rowspan-gutter --- +#set page(height: 9em) +#grid( + columns: 2, + column-gutter: 4pt, + row-gutter: (0pt, 4pt, 8pt, 4pt), + inset: (bottom: 0.5pt), + stroke: (bottom: 1pt), + grid.header([a]), + [x], + grid.header(level: 2, [b]), + [y], + grid.header(level: 3, [c]), + [z], [z], + grid.cell( + rowspan: 5, + block(fill: red, width: 1.5em, height: 6.4em) + ), + [cell], + [cell], + [a\ b], + grid.cell(x: 0)[end], +) + +--- grid-subheaders-non-repeating-header-before-multi-page-row --- +#set page(height: 6em) +#grid( + grid.header(repeat: false, [h]), + [row #colbreak() row] +) + + +--- grid-subheaders-short-lived-no-orphan-prevention --- +// No orphan prevention for short-lived headers. +#set page(height: 8em) +#v(5em) +#grid( + grid.header(level: 2, [b]), + grid.header(level: 2, [c]), + [d] +) + +--- grid-subheaders-repeating-orphan-prevention --- +#set page(height: 8em) +#v(4.5em) +#grid( + grid.header(repeat: true, level: 2, [L2]), + grid.header(repeat: true, level: 4, [L4]), + [a] +) + +--- grid-subheaders-non-repeating-orphan-prevention --- +#set page(height: 8em) +#v(4.5em) +#grid( + grid.header(repeat: false, level: 2, [L2]), + grid.header(repeat: false, level: 4, [L4]), + [a] +) + +--- grid-subheaders-alone --- +#table( + table.header([a]), + table.header(level: 2, [b]), +) + +--- grid-subheaders-alone-no-orphan-prevention --- +#set page(height: 5.3em) +#v(2em) +#grid( + grid.header([L1]), + grid.header(level: 2, [L2]), +) + +--- grid-subheaders-alone-with-gutter-no-orphan-prevention --- +#set page(height: 5.3em) +#v(2em) +#grid( + gutter: 3pt, + grid.header([L1]), + grid.header(level: 2, [L2]), +) + +--- grid-subheaders-alone-with-footer --- +#table( + table.header([a]), + table.header(level: 2, [b]), + table.footer([c]) +) + +--- grid-subheaders-alone-with-footer-no-orphan-prevention --- +#set page(height: 5.3em) +#table( + table.header([L1]), + table.header(level: 2, [L2]), + table.footer([a]) +) + +--- grid-subheaders-alone-with-gutter-and-footer-no-orphan-prevention --- +#set page(height: 5.5em) +#table( + gutter: 4pt, + table.header([L1]), + table.header(level: 2, [L2]), + table.footer([a]) +) + +--- grid-subheaders-too-large-non-repeating-orphan-before-auto --- +#set page(height: 8em) +#grid( + grid.header([1]), + grid.header([a\ ] * 2, level: 2, repeat: false), + grid.header([2], level: 3), + [b\ b\ b], +) + +--- grid-subheaders-too-large-repeating-orphan-before-auto --- +#set page(height: 8em) +#grid( + grid.header([1]), + grid.header([a\ ] * 2, level: 2, repeat: true), + grid.header([2], level: 3), + rect(width: 10pt, height: 3em, fill: red), +) + +--- grid-subheaders-too-large-repeating-orphan-before-relative --- +#set page(height: 8em) +#grid( + rows: (auto, auto, auto, 3em), + grid.header([1]), + grid.header([a\ ] * 2, level: 2, repeat: true), + grid.header([2], level: 3), + rect(width: 10pt, height: 3em, fill: red), +) + +--- grid-subheaders-too-large-non-repeating-orphan-before-relative --- +#set page(height: 8em) +#grid( + rows: (auto, auto, auto, 3em), + grid.header([1]), + grid.header([a\ ] * 2, level: 2, repeat: false), + grid.header([2], level: 3), + rect(width: 10pt, height: 3em, fill: red), +) From 44d410dd007569227e8eca41e39fde9a932f0d02 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 10 Jun 2025 14:44:38 +0000 Subject: [PATCH 139/558] Use the shaper in math (#6336) --- crates/typst-layout/src/inline/mod.rs | 1 + crates/typst-layout/src/inline/shaping.rs | 26 +- crates/typst-layout/src/math/accent.rs | 51 +- crates/typst-layout/src/math/attach.rs | 25 +- crates/typst-layout/src/math/frac.rs | 12 +- crates/typst-layout/src/math/fragment.rs | 778 +++++++++++++--------- crates/typst-layout/src/math/lr.rs | 33 +- crates/typst-layout/src/math/mat.rs | 32 +- crates/typst-layout/src/math/mod.rs | 56 +- crates/typst-layout/src/math/root.rs | 6 +- crates/typst-layout/src/math/shared.rs | 23 +- crates/typst-layout/src/math/stretch.rs | 298 +-------- crates/typst-layout/src/math/text.rs | 73 +- crates/typst-layout/src/math/underover.rs | 8 +- crates/typst-library/src/text/font/mod.rs | 15 +- crates/typst-library/src/text/item.rs | 18 + crates/typst-library/src/text/mod.rs | 18 + crates/typst-pdf/src/text.rs | 10 +- crates/typst-render/src/text.rs | 9 +- crates/typst-svg/src/text.rs | 23 +- tests/ref/math-accent-dotless-greedy.png | Bin 0 -> 710 bytes tests/suite/math/accent.typ | 6 + 22 files changed, 721 insertions(+), 800 deletions(-) create mode 100644 tests/ref/math-accent-dotless-greedy.png diff --git a/crates/typst-layout/src/inline/mod.rs b/crates/typst-layout/src/inline/mod.rs index 5ef820d076..6cafb9b000 100644 --- a/crates/typst-layout/src/inline/mod.rs +++ b/crates/typst-layout/src/inline/mod.rs @@ -9,6 +9,7 @@ mod prepare; mod shaping; pub use self::box_::layout_box; +pub use self::shaping::create_shape_plan; use comemo::{Track, Tracked, TrackedMut}; use typst_library::diag::SourceResult; diff --git a/crates/typst-layout/src/inline/shaping.rs b/crates/typst-layout/src/inline/shaping.rs index ca723c0a58..935a86b385 100644 --- a/crates/typst-layout/src/inline/shaping.rs +++ b/crates/typst-layout/src/inline/shaping.rs @@ -1,18 +1,16 @@ use std::borrow::Cow; use std::fmt::{self, Debug, Formatter}; -use std::str::FromStr; use std::sync::Arc; use az::SaturatingAs; -use ecow::EcoString; use rustybuzz::{BufferFlags, ShapePlan, UnicodeBuffer}; use ttf_parser::Tag; 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, FontFamily, FontVariant, - Glyph, Lang, Region, TextEdgeBounds, TextElem, TextItem, + families, features, is_default_ignorable, language, variant, Font, FontFamily, + FontVariant, Glyph, Lang, Region, TextEdgeBounds, TextElem, TextItem, }; use typst_library::World; use typst_utils::SliceExt; @@ -295,6 +293,8 @@ impl<'a> ShapedText<'a> { + justification_left + justification_right, x_offset: shaped.x_offset + justification_left, + y_advance: Em::zero(), + y_offset: Em::zero(), range: (shaped.range.start - range.start).saturating_as() ..(shaped.range.end - range.start).saturating_as(), span, @@ -934,7 +934,7 @@ fn shape_segment<'a>( /// Create a shape plan. #[comemo::memoize] -fn create_shape_plan( +pub fn create_shape_plan( font: &Font, direction: rustybuzz::Direction, script: rustybuzz::Script, @@ -952,7 +952,7 @@ fn create_shape_plan( /// Shape the text with tofus from the given font. fn shape_tofus(ctx: &mut ShapingContext, base: usize, text: &str, font: Font) { - let x_advance = font.advance(0).unwrap_or_default(); + let x_advance = font.x_advance(0).unwrap_or_default(); let add_glyph = |(cluster, c): (usize, char)| { let start = base + cluster; let end = start + c.len_utf8(); @@ -1044,20 +1044,8 @@ fn calculate_adjustability(ctx: &mut ShapingContext, lang: Lang, region: Option< /// Difference between non-breaking and normal space. fn nbsp_delta(font: &Font) -> Option { - let space = font.ttf().glyph_index(' ')?.0; let nbsp = font.ttf().glyph_index('\u{00A0}')?.0; - Some(font.advance(nbsp)? - font.advance(space)?) -} - -/// Process the language and region of a style chain into a -/// rustybuzz-compatible BCP 47 language. -fn language(styles: StyleChain) -> rustybuzz::Language { - let mut bcp: EcoString = TextElem::lang_in(styles).as_str().into(); - if let Some(region) = TextElem::region_in(styles) { - bcp.push('-'); - bcp.push_str(region.as_str()); - } - rustybuzz::Language::from_str(&bcp).unwrap() + Some(font.x_advance(nbsp)? - font.space_width()?) } /// Returns true if all glyphs in `glyphs` have ranges within the range `range`. diff --git a/crates/typst-layout/src/math/accent.rs b/crates/typst-layout/src/math/accent.rs index 3016064662..159703b8e8 100644 --- a/crates/typst-layout/src/math/accent.rs +++ b/crates/typst-layout/src/math/accent.rs @@ -3,7 +3,10 @@ use typst_library::foundations::{Packed, StyleChain}; use typst_library::layout::{Em, Frame, Point, Size}; use typst_library::math::AccentElem; -use super::{style_cramped, FrameFragment, GlyphFragment, MathContext, MathFragment}; +use super::{ + style_cramped, style_dtls, style_flac, FrameFragment, GlyphFragment, MathContext, + MathFragment, +}; /// How much the accent can be shorter than the base. const ACCENT_SHORT_FALL: Em = Em::new(0.5); @@ -15,40 +18,40 @@ pub fn layout_accent( ctx: &mut MathContext, styles: StyleChain, ) -> SourceResult<()> { - let cramped = style_cramped(); - let mut base = ctx.layout_into_fragment(&elem.base, styles.chain(&cramped))?; - let accent = elem.accent; let top_accent = !accent.is_bottom(); - // Try to replace base glyph with its dotless variant. - if top_accent && elem.dotless(styles) { - if let MathFragment::Glyph(glyph) = &mut base { - glyph.make_dotless_form(ctx); - } - } + // Try to replace the base glyph with its dotless variant. + let dtls = style_dtls(); + let base_styles = + if top_accent && elem.dotless(styles) { styles.chain(&dtls) } else { styles }; + + let cramped = style_cramped(); + let base = ctx.layout_into_fragment(&elem.base, base_styles.chain(&cramped))?; // Preserve class to preserve automatic spacing. let base_class = base.class(); let base_attach = base.accent_attach(); - let mut glyph = GlyphFragment::new(ctx, styles, accent.0, elem.span()); + // Try to replace the accent glyph with its flattened variant. + let flattened_base_height = scaled!(ctx, styles, flattened_accent_base_height); + let flac = style_flac(); + let accent_styles = if top_accent && base.ascent() > flattened_base_height { + styles.chain(&flac) + } else { + styles + }; - // Try to replace accent glyph with its flattened variant. - if top_accent { - let flattened_base_height = scaled!(ctx, styles, flattened_accent_base_height); - if base.ascent() > flattened_base_height { - glyph.make_flattened_accent_form(ctx); - } - } + let mut glyph = + GlyphFragment::new_char(ctx.font, accent_styles, accent.0, elem.span())?; - // Forcing the accent to be at least as large as the base makes it too - // wide in many case. + // Forcing the accent to be at least as large as the base makes it too wide + // in many cases. let width = elem.size(styles).relative_to(base.width()); - let short_fall = ACCENT_SHORT_FALL.at(glyph.font_size); - let variant = glyph.stretch_horizontal(ctx, width - short_fall); - let accent = variant.frame; - let accent_attach = variant.accent_attach.0; + let short_fall = ACCENT_SHORT_FALL.at(glyph.item.size); + glyph.stretch_horizontal(ctx, width - short_fall); + let accent_attach = glyph.accent_attach.0; + let accent = glyph.into_frame(); let (gap, accent_pos, base_pos) = if top_accent { // Descent is negative because the accent's ink bottom is above the diff --git a/crates/typst-layout/src/math/attach.rs b/crates/typst-layout/src/math/attach.rs index 90aad941ed..a7f3cad5f7 100644 --- a/crates/typst-layout/src/math/attach.rs +++ b/crates/typst-layout/src/math/attach.rs @@ -66,7 +66,6 @@ pub fn layout_attach( let relative_to_width = measure!(t, width).max(measure!(b, width)); stretch_fragment( ctx, - styles, &mut base, Some(Axis::X), Some(relative_to_width), @@ -220,7 +219,6 @@ fn layout_attachments( // Calculate the distance each pre-script extends to the left of the base's // width. let (tl_pre_width, bl_pre_width) = compute_pre_script_widths( - ctx, &base, [tl.as_ref(), bl.as_ref()], (tx_shift, bx_shift), @@ -231,7 +229,6 @@ fn layout_attachments( // base's width. Also calculate each post-script's kerning (we need this for // its position later). let ((tr_post_width, tr_kern), (br_post_width, br_kern)) = compute_post_script_widths( - ctx, &base, [tr.as_ref(), br.as_ref()], (tx_shift, bx_shift), @@ -287,14 +284,13 @@ fn layout_attachments( /// post-script's kerning value. The first tuple is for the post-superscript, /// and the second is for the post-subscript. fn compute_post_script_widths( - ctx: &MathContext, base: &MathFragment, [tr, br]: [Option<&MathFragment>; 2], (tr_shift, br_shift): (Abs, Abs), space_after_post_script: Abs, ) -> ((Abs, Abs), (Abs, Abs)) { let tr_values = tr.map_or_default(|tr| { - let kern = math_kern(ctx, base, tr, tr_shift, Corner::TopRight); + let kern = math_kern(base, tr, tr_shift, Corner::TopRight); (space_after_post_script + tr.width() + kern, kern) }); @@ -302,7 +298,7 @@ fn compute_post_script_widths( // need to shift the post-subscript left by the base's italic correction // (see the kerning algorithm as described in the OpenType MATH spec). let br_values = br.map_or_default(|br| { - let kern = math_kern(ctx, base, br, br_shift, Corner::BottomRight) + let kern = math_kern(base, br, br_shift, Corner::BottomRight) - base.italics_correction(); (space_after_post_script + br.width() + kern, kern) }); @@ -317,19 +313,18 @@ fn compute_post_script_widths( /// extends left of the base's width and the second being the distance the /// pre-subscript extends left of the base's width. fn compute_pre_script_widths( - ctx: &MathContext, base: &MathFragment, [tl, bl]: [Option<&MathFragment>; 2], (tl_shift, bl_shift): (Abs, Abs), space_before_pre_script: Abs, ) -> (Abs, Abs) { let tl_pre_width = tl.map_or_default(|tl| { - let kern = math_kern(ctx, base, tl, tl_shift, Corner::TopLeft); + let kern = math_kern(base, tl, tl_shift, Corner::TopLeft); space_before_pre_script + tl.width() + kern }); let bl_pre_width = bl.map_or_default(|bl| { - let kern = math_kern(ctx, base, bl, bl_shift, Corner::BottomLeft); + let kern = math_kern(base, bl, bl_shift, Corner::BottomLeft); space_before_pre_script + bl.width() + kern }); @@ -471,13 +466,7 @@ fn compute_script_shifts( /// a negative value means shifting the script closer to the base. Requires the /// distance from the base's baseline to the script's baseline, as well as the /// script's corner (tl, tr, bl, br). -fn math_kern( - ctx: &MathContext, - base: &MathFragment, - script: &MathFragment, - shift: Abs, - pos: Corner, -) -> Abs { +fn math_kern(base: &MathFragment, script: &MathFragment, shift: Abs, pos: Corner) -> Abs { // This process is described under the MathKernInfo table in the OpenType // MATH spec. @@ -502,8 +491,8 @@ fn math_kern( // Calculate the sum of kerning values for each correction height. let summed_kern = |height| { - let base_kern = base.kern_at_height(ctx, pos, height); - let attach_kern = script.kern_at_height(ctx, pos.inv(), height); + let base_kern = base.kern_at_height(pos, height); + let attach_kern = script.kern_at_height(pos.inv(), height); base_kern + attach_kern }; diff --git a/crates/typst-layout/src/math/frac.rs b/crates/typst-layout/src/math/frac.rs index 2567349d06..091f328f62 100644 --- a/crates/typst-layout/src/math/frac.rs +++ b/crates/typst-layout/src/math/frac.rs @@ -109,14 +109,14 @@ fn layout_frac_like( frame.push_frame(denom_pos, denom); if binom { - let mut left = GlyphFragment::new(ctx, styles, '(', span) - .stretch_vertical(ctx, height - short_fall); - left.center_on_axis(ctx); + let mut left = GlyphFragment::new_char(ctx.font, styles, '(', span)?; + left.stretch_vertical(ctx, height - short_fall); + left.center_on_axis(); ctx.push(left); ctx.push(FrameFragment::new(styles, frame)); - let mut right = GlyphFragment::new(ctx, styles, ')', span) - .stretch_vertical(ctx, height - short_fall); - right.center_on_axis(ctx); + let mut right = GlyphFragment::new_char(ctx.font, styles, ')', span)?; + right.stretch_vertical(ctx, height - short_fall); + right.center_on_axis(); ctx.push(right); } else { frame.push( diff --git a/crates/typst-layout/src/math/fragment.rs b/crates/typst-layout/src/math/fragment.rs index 01fa6be4be..eb85eeb5d8 100644 --- a/crates/typst-layout/src/math/fragment.rs +++ b/crates/typst-layout/src/math/fragment.rs @@ -1,28 +1,32 @@ use std::fmt::{self, Debug, Formatter}; -use rustybuzz::Feature; -use ttf_parser::gsub::{AlternateSubstitution, SingleSubstitution, SubstitutionSubtable}; -use ttf_parser::opentype_layout::LayoutTable; -use ttf_parser::{GlyphId, Rect}; +use az::SaturatingAs; +use rustybuzz::{BufferFlags, UnicodeBuffer}; +use ttf_parser::math::{GlyphAssembly, GlyphConstruction, GlyphPart}; +use ttf_parser::GlyphId; +use typst_library::diag::{bail, warning, SourceResult}; use typst_library::foundations::StyleChain; use typst_library::introspection::Tag; use typst_library::layout::{ - Abs, Axis, Corner, Em, Frame, FrameItem, Point, Size, VAlignment, + Abs, Axes, Axis, Corner, Em, Frame, FrameItem, Point, Size, VAlignment, }; use typst_library::math::{EquationElem, MathSize}; -use typst_library::text::{Font, Glyph, Lang, Region, TextElem, TextItem}; -use typst_library::visualize::{FixedStroke, Paint}; +use typst_library::text::{features, language, Font, Glyph, TextElem, TextItem}; use typst_syntax::Span; -use typst_utils::default_math_class; +use typst_utils::{default_math_class, Get}; use unicode_math_class::MathClass; -use super::{stretch_glyph, MathContext, Scaled}; +use super::MathContext; +use crate::inline::create_shape_plan; use crate::modifiers::{FrameModifiers, FrameModify}; +/// Maximum number of times extenders can be repeated. +const MAX_REPEATS: usize = 1024; + +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone)] pub enum MathFragment { Glyph(GlyphFragment), - Variant(VariantFragment), Frame(FrameFragment), Spacing(Abs, bool), Space(Abs), @@ -33,13 +37,18 @@ pub enum MathFragment { impl MathFragment { pub fn size(&self) -> Size { - Size::new(self.width(), self.height()) + match self { + Self::Glyph(glyph) => glyph.size, + Self::Frame(fragment) => fragment.frame.size(), + Self::Spacing(amount, _) => Size::with_x(*amount), + Self::Space(amount) => Size::with_x(*amount), + _ => Size::zero(), + } } pub fn width(&self) -> Abs { match self { - Self::Glyph(glyph) => glyph.width, - Self::Variant(variant) => variant.frame.width(), + Self::Glyph(glyph) => glyph.size.x, Self::Frame(fragment) => fragment.frame.width(), Self::Spacing(amount, _) => *amount, Self::Space(amount) => *amount, @@ -49,8 +58,7 @@ impl MathFragment { pub fn height(&self) -> Abs { match self { - Self::Glyph(glyph) => glyph.height(), - Self::Variant(variant) => variant.frame.height(), + Self::Glyph(glyph) => glyph.size.y, Self::Frame(fragment) => fragment.frame.height(), _ => Abs::zero(), } @@ -58,17 +66,15 @@ impl MathFragment { pub fn ascent(&self) -> Abs { match self { - Self::Glyph(glyph) => glyph.ascent, - Self::Variant(variant) => variant.frame.ascent(), - Self::Frame(fragment) => fragment.frame.baseline(), + Self::Glyph(glyph) => glyph.ascent(), + Self::Frame(fragment) => fragment.frame.ascent(), _ => Abs::zero(), } } pub fn descent(&self) -> Abs { match self { - Self::Glyph(glyph) => glyph.descent, - Self::Variant(variant) => variant.frame.descent(), + Self::Glyph(glyph) => glyph.descent(), Self::Frame(fragment) => fragment.frame.descent(), _ => Abs::zero(), } @@ -85,7 +91,6 @@ impl MathFragment { pub fn class(&self) -> MathClass { match self { Self::Glyph(glyph) => glyph.class, - Self::Variant(variant) => variant.class, Self::Frame(fragment) => fragment.class, Self::Spacing(_, _) => MathClass::Space, Self::Space(_) => MathClass::Space, @@ -98,7 +103,6 @@ impl MathFragment { pub fn math_size(&self) -> Option { match self { Self::Glyph(glyph) => Some(glyph.math_size), - Self::Variant(variant) => Some(variant.math_size), Self::Frame(fragment) => Some(fragment.math_size), _ => None, } @@ -106,8 +110,7 @@ impl MathFragment { pub fn font_size(&self) -> Option { match self { - Self::Glyph(glyph) => Some(glyph.font_size), - Self::Variant(variant) => Some(variant.font_size), + Self::Glyph(glyph) => Some(glyph.item.size), Self::Frame(fragment) => Some(fragment.font_size), _ => None, } @@ -116,7 +119,6 @@ impl MathFragment { pub fn set_class(&mut self, class: MathClass) { match self { Self::Glyph(glyph) => glyph.class = class, - Self::Variant(variant) => variant.class = class, Self::Frame(fragment) => fragment.class = class, _ => {} } @@ -125,7 +127,6 @@ impl MathFragment { pub fn set_limits(&mut self, limits: Limits) { match self { Self::Glyph(glyph) => glyph.limits = limits, - Self::Variant(variant) => variant.limits = limits, Self::Frame(fragment) => fragment.limits = limits, _ => {} } @@ -149,7 +150,6 @@ impl MathFragment { pub fn is_text_like(&self) -> bool { match self { Self::Glyph(glyph) => !glyph.extended_shape, - Self::Variant(variant) => !variant.extended_shape, MathFragment::Frame(frame) => frame.text_like, _ => false, } @@ -158,7 +158,6 @@ impl MathFragment { pub fn italics_correction(&self) -> Abs { match self { Self::Glyph(glyph) => glyph.italics_correction, - Self::Variant(variant) => variant.italics_correction, Self::Frame(fragment) => fragment.italics_correction, _ => Abs::zero(), } @@ -167,7 +166,6 @@ impl MathFragment { pub fn accent_attach(&self) -> (Abs, Abs) { match self { Self::Glyph(glyph) => glyph.accent_attach, - Self::Variant(variant) => variant.accent_attach, Self::Frame(fragment) => fragment.accent_attach, _ => (self.width() / 2.0, self.width() / 2.0), } @@ -176,7 +174,6 @@ impl MathFragment { pub fn into_frame(self) -> Frame { match self { Self::Glyph(glyph) => glyph.into_frame(), - Self::Variant(variant) => variant.frame, Self::Frame(fragment) => fragment.frame, Self::Tag(tag) => { let mut frame = Frame::soft(Size::zero()); @@ -190,7 +187,6 @@ impl MathFragment { pub fn limits(&self) -> Limits { match self { MathFragment::Glyph(glyph) => glyph.limits, - MathFragment::Variant(variant) => variant.limits, MathFragment::Frame(fragment) => fragment.limits, _ => Limits::Never, } @@ -198,11 +194,31 @@ impl MathFragment { /// If no kern table is provided for a corner, a kerning amount of zero is /// assumed. - pub fn kern_at_height(&self, ctx: &MathContext, corner: Corner, height: Abs) -> Abs { + pub fn kern_at_height(&self, corner: Corner, height: Abs) -> Abs { match self { Self::Glyph(glyph) => { - kern_at_height(ctx, glyph.font_size, glyph.id, corner, height) - .unwrap_or_default() + // For glyph assemblies we pick either the start or end glyph + // depending on the corner. + let is_vertical = + glyph.item.glyphs.iter().all(|glyph| glyph.y_advance != Em::zero()); + let glyph_index = match (is_vertical, corner) { + (true, Corner::TopLeft | Corner::TopRight) => { + glyph.item.glyphs.len() - 1 + } + (false, Corner::TopRight | Corner::BottomRight) => { + glyph.item.glyphs.len() - 1 + } + _ => 0, + }; + + kern_at_height( + &glyph.item.font, + GlyphId(glyph.item.glyphs[glyph_index].id), + corner, + Em::from_length(height, glyph.item.size), + ) + .unwrap_or_default() + .at(glyph.item.size) } _ => Abs::zero(), } @@ -215,12 +231,6 @@ impl From for MathFragment { } } -impl From for MathFragment { - fn from(variant: VariantFragment) -> Self { - Self::Variant(variant) - } -} - impl From for MathFragment { fn from(fragment: FrameFragment) -> Self { Self::Frame(fragment) @@ -229,266 +239,282 @@ impl From for MathFragment { #[derive(Clone)] pub struct GlyphFragment { - pub id: GlyphId, - pub c: char, - pub font: Font, - pub lang: Lang, - pub region: Option, - pub fill: Paint, - pub stroke: Option, - pub shift: Abs, - pub width: Abs, - pub ascent: Abs, - pub descent: Abs, + // Text stuff. + pub item: TextItem, + pub base_glyph: Glyph, + // Math stuff. + pub size: Size, + pub baseline: Option, pub italics_correction: Abs, pub accent_attach: (Abs, Abs), - pub font_size: Abs, - pub class: MathClass, pub math_size: MathSize, - pub span: Span, - pub modifiers: FrameModifiers, + pub class: MathClass, pub limits: Limits, pub extended_shape: bool, + pub mid_stretched: Option, + // External frame stuff. + pub modifiers: FrameModifiers, + pub shift: Abs, + pub align: Abs, } impl GlyphFragment { - pub fn new(ctx: &MathContext, styles: StyleChain, c: char, span: Span) -> Self { - let id = ctx.ttf.glyph_index(c).unwrap_or_default(); - let id = Self::adjust_glyph_index(ctx, id); - Self::with_id(ctx, styles, c, id, span) - } - - pub fn try_new( - ctx: &MathContext, + /// Calls `new` with the given character. + pub fn new_char( + font: &Font, styles: StyleChain, c: char, span: Span, - ) -> Option { - let id = ctx.ttf.glyph_index(c)?; - let id = Self::adjust_glyph_index(ctx, id); - Some(Self::with_id(ctx, styles, c, id, span)) + ) -> SourceResult { + Self::new(font, styles, c.encode_utf8(&mut [0; 4]), span) } - pub fn with_id( - ctx: &MathContext, + /// Try to create a new glyph out of the given string. Will bail if the + /// result from shaping the string is not a single glyph or is a tofu. + #[comemo::memoize] + pub fn new( + font: &Font, styles: StyleChain, - c: char, - id: GlyphId, + text: &str, span: Span, - ) -> Self { + ) -> SourceResult { + let mut buffer = UnicodeBuffer::new(); + buffer.push_str(text); + buffer.set_language(language(styles)); + // TODO: Use `rustybuzz::script::MATH` once + // https://github.com/harfbuzz/rustybuzz/pull/165 is released. + buffer.set_script( + rustybuzz::Script::from_iso15924_tag(ttf_parser::Tag::from_bytes(b"math")) + .unwrap(), + ); + buffer.set_direction(rustybuzz::Direction::LeftToRight); + buffer.set_flags(BufferFlags::REMOVE_DEFAULT_IGNORABLES); + + let features = features(styles); + let plan = create_shape_plan( + font, + buffer.direction(), + buffer.script(), + buffer.language().as_ref(), + &features, + ); + + let buffer = rustybuzz::shape_with_plan(font.rusty(), &plan, buffer); + if buffer.len() != 1 { + bail!(span, "did not get a single glyph after shaping {}", text); + } + + let info = buffer.glyph_infos()[0]; + let pos = buffer.glyph_positions()[0]; + + // TODO: add support for coverage and fallback, like in normal text shaping. + if info.glyph_id == 0 { + bail!(span, "current font is missing a glyph for {}", text); + } + + let cluster = info.cluster as usize; + let c = text[cluster..].chars().next().unwrap(); + let limits = Limits::for_char(c); let class = EquationElem::class_in(styles) .or_else(|| default_math_class(c)) .unwrap_or(MathClass::Normal); - let mut fragment = Self { - id, - c, - font: ctx.font.clone(), - lang: TextElem::lang_in(styles), - region: TextElem::region_in(styles), + let glyph = Glyph { + id: info.glyph_id as u16, + x_advance: font.to_em(pos.x_advance), + x_offset: font.to_em(pos.x_offset), + y_advance: font.to_em(pos.y_advance), + y_offset: font.to_em(pos.y_offset), + range: 0..text.len().saturating_as(), + span: (span, 0), + }; + + let item = TextItem { + font: font.clone(), + size: TextElem::size_in(styles), fill: TextElem::fill_in(styles).as_decoration(), stroke: TextElem::stroke_in(styles).map(|s| s.unwrap_or_default()), - shift: TextElem::baseline_in(styles), - font_size: TextElem::size_in(styles), + lang: TextElem::lang_in(styles), + region: TextElem::region_in(styles), + text: text.into(), + glyphs: vec![glyph.clone()], + }; + + let mut fragment = Self { + item, + base_glyph: glyph, + // Math math_size: EquationElem::size_in(styles), - width: Abs::zero(), - ascent: Abs::zero(), - descent: Abs::zero(), - limits: Limits::for_char(c), + class, + limits, + mid_stretched: None, + // Math in need of updating. + extended_shape: false, italics_correction: Abs::zero(), accent_attach: (Abs::zero(), Abs::zero()), - class, - span, + size: Size::zero(), + baseline: None, + // Misc + align: Abs::zero(), + shift: TextElem::baseline_in(styles), modifiers: FrameModifiers::get_in(styles), - extended_shape: false, }; - fragment.set_id(ctx, id); - fragment - } - - /// Apply GSUB substitutions. - fn adjust_glyph_index(ctx: &MathContext, id: GlyphId) -> GlyphId { - if let Some(glyphwise_tables) = &ctx.glyphwise_tables { - glyphwise_tables.iter().fold(id, |id, table| table.apply(id)) - } else { - id - } + fragment.update_glyph(); + Ok(fragment) } /// Sets element id and boxes in appropriate way without changing other /// styles. This is used to replace the glyph with a stretch variant. - pub fn set_id(&mut self, ctx: &MathContext, id: GlyphId) { - let advance = ctx.ttf.glyph_hor_advance(id).unwrap_or_default(); - let italics = italics_correction(ctx, id, self.font_size).unwrap_or_default(); - let bbox = ctx.ttf.glyph_bounding_box(id).unwrap_or(Rect { - x_min: 0, - y_min: 0, - x_max: 0, - y_max: 0, - }); + pub fn update_glyph(&mut self) { + let id = GlyphId(self.item.glyphs[0].id); - let mut width = advance.scaled(ctx, self.font_size); + let extended_shape = is_extended_shape(&self.item.font, id); + let italics = italics_correction(&self.item.font, id).unwrap_or_default(); + let width = self.item.width(); + if !extended_shape { + self.item.glyphs[0].x_advance += italics; + } + let italics = italics.at(self.item.size); + + let (ascent, descent) = + ascent_descent(&self.item.font, id).unwrap_or((Em::zero(), Em::zero())); // The fallback for accents is half the width plus or minus the italics // correction. This is similar to how top and bottom attachments are // shifted. For bottom accents we do not use the accent attach of the // base as it is meant for top acccents. - let top_accent_attach = - accent_attach(ctx, id, self.font_size).unwrap_or((width + italics) / 2.0); + let top_accent_attach = accent_attach(&self.item.font, id) + .map(|x| x.at(self.item.size)) + .unwrap_or((width + italics) / 2.0); let bottom_accent_attach = (width - italics) / 2.0; - let extended_shape = is_extended_shape(ctx, id); - if !extended_shape { - width += italics; - } - - self.id = id; - self.width = width; - self.ascent = bbox.y_max.scaled(ctx, self.font_size); - self.descent = -bbox.y_min.scaled(ctx, self.font_size); + self.baseline = Some(ascent.at(self.item.size)); + self.size = Size::new( + self.item.width(), + ascent.at(self.item.size) + descent.at(self.item.size), + ); self.italics_correction = italics; self.accent_attach = (top_accent_attach, bottom_accent_attach); self.extended_shape = extended_shape; } - pub fn height(&self) -> Abs { - self.ascent + self.descent - } - - pub fn into_variant(self) -> VariantFragment { - VariantFragment { - c: self.c, - font_size: self.font_size, - italics_correction: self.italics_correction, - accent_attach: self.accent_attach, - class: self.class, - math_size: self.math_size, - span: self.span, - limits: self.limits, - extended_shape: self.extended_shape, - frame: self.into_frame(), - mid_stretched: None, - } + // Reset a GlyphFragment's text field and math properties back to its + // base_id's. This is used to return a glyph to its unstretched state. + pub fn reset_glyph(&mut self) { + self.align = Abs::zero(); + self.item.glyphs = vec![self.base_glyph.clone()]; + self.update_glyph(); } - pub fn into_frame(self) -> Frame { - let item = TextItem { - font: self.font.clone(), - size: self.font_size, - fill: self.fill, - stroke: self.stroke, - lang: self.lang, - region: self.region, - text: self.c.into(), - glyphs: vec![Glyph { - id: self.id.0, - x_advance: Em::from_length(self.width, self.font_size), - x_offset: Em::zero(), - range: 0..self.c.len_utf8() as u16, - span: (self.span, 0), - }], - }; - let size = Size::new(self.width, self.ascent + self.descent); - let mut frame = Frame::soft(size); - frame.set_baseline(self.ascent); - frame.push(Point::with_y(self.ascent + self.shift), FrameItem::Text(item)); - frame.modify(&self.modifiers); - frame + pub fn baseline(&self) -> Abs { + self.ascent() } - pub fn make_script_size(&mut self, ctx: &MathContext) { - let alt_id = - ctx.ssty_table.as_ref().and_then(|ssty| ssty.try_apply(self.id, None)); - if let Some(alt_id) = alt_id { - self.set_id(ctx, alt_id); - } - } - - pub fn make_script_script_size(&mut self, ctx: &MathContext) { - let alt_id = ctx.ssty_table.as_ref().and_then(|ssty| { - // We explicitly request to apply the alternate set with value 1, - // as opposed to the default value in ssty, as the former - // corresponds to second level scripts and the latter corresponds - // to first level scripts. - ssty.try_apply(self.id, Some(1)) - .or_else(|| ssty.try_apply(self.id, None)) - }); - if let Some(alt_id) = alt_id { - self.set_id(ctx, alt_id); - } + /// The distance from the baseline to the top of the frame. + pub fn ascent(&self) -> Abs { + self.baseline.unwrap_or(self.size.y) } - pub fn make_dotless_form(&mut self, ctx: &MathContext) { - let alt_id = - ctx.dtls_table.as_ref().and_then(|dtls| dtls.try_apply(self.id, None)); - if let Some(alt_id) = alt_id { - self.set_id(ctx, alt_id); - } + /// The distance from the baseline to the bottom of the frame. + pub fn descent(&self) -> Abs { + self.size.y - self.ascent() } - pub fn make_flattened_accent_form(&mut self, ctx: &MathContext) { - let alt_id = - ctx.flac_table.as_ref().and_then(|flac| flac.try_apply(self.id, None)); - if let Some(alt_id) = alt_id { - self.set_id(ctx, alt_id); - } + pub fn into_frame(self) -> Frame { + let mut frame = Frame::soft(self.size); + frame.set_baseline(self.baseline()); + frame.push( + Point::with_y(self.ascent() + self.shift + self.align), + FrameItem::Text(self.item), + ); + frame.modify(&self.modifiers); + frame } /// Try to stretch a glyph to a desired height. - pub fn stretch_vertical(self, ctx: &mut MathContext, height: Abs) -> VariantFragment { - stretch_glyph(ctx, self, height, Axis::Y) + pub fn stretch_vertical(&mut self, ctx: &mut MathContext, height: Abs) { + self.stretch(ctx, height, Axis::Y) } /// Try to stretch a glyph to a desired width. - pub fn stretch_horizontal( - self, - ctx: &mut MathContext, - width: Abs, - ) -> VariantFragment { - stretch_glyph(ctx, self, width, Axis::X) - } -} + pub fn stretch_horizontal(&mut self, ctx: &mut MathContext, width: Abs) { + self.stretch(ctx, width, Axis::X) + } + + /// Try to stretch a glyph to a desired width or height. + /// + /// The resulting frame may not have the exact desired width or height. + pub fn stretch(&mut self, ctx: &mut MathContext, target: Abs, axis: Axis) { + self.reset_glyph(); + + // If the base glyph is good enough, use it. + let mut advance = self.size.get(axis); + if axis == Axis::X && !self.extended_shape { + // For consistency, we subtract the italics correction from the + // glyph's width if it was added in `update_glyph`. + advance -= self.italics_correction; + } + if target <= advance { + return; + } -impl Debug for GlyphFragment { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "GlyphFragment({:?})", self.c) - } -} + let id = GlyphId(self.item.glyphs[0].id); + let font = self.item.font.clone(); + let Some(construction) = glyph_construction(&font, id, axis) else { return }; + + // Search for a pre-made variant with a good advance. + let mut best_id = id; + let mut best_advance = advance; + for variant in construction.variants { + best_id = variant.variant_glyph; + best_advance = + self.item.font.to_em(variant.advance_measurement).at(self.item.size); + if target <= best_advance { + break; + } + } -#[derive(Clone)] -pub struct VariantFragment { - pub c: char, - pub italics_correction: Abs, - pub accent_attach: (Abs, Abs), - pub frame: Frame, - pub font_size: Abs, - pub class: MathClass, - pub math_size: MathSize, - pub span: Span, - pub limits: Limits, - pub mid_stretched: Option, - pub extended_shape: bool, -} + // This is either good or the best we've got. + if target <= best_advance || construction.assembly.is_none() { + self.item.glyphs[0].id = best_id.0; + self.item.glyphs[0].x_advance = + self.item.font.x_advance(best_id.0).unwrap_or_default(); + self.item.glyphs[0].x_offset = Em::zero(); + self.item.glyphs[0].y_advance = + self.item.font.y_advance(best_id.0).unwrap_or_default(); + self.item.glyphs[0].y_offset = Em::zero(); + self.update_glyph(); + return; + } + + // Assemble from parts. + let assembly = construction.assembly.unwrap(); + let min_overlap = min_connector_overlap(&self.item.font) + .unwrap_or_default() + .at(self.item.size); + assemble(ctx, self, assembly, min_overlap, target, axis); + } -impl VariantFragment { /// Vertically adjust the fragment's frame so that it is centered /// on the axis. - pub fn center_on_axis(&mut self, ctx: &MathContext) { - self.align_on_axis(ctx, VAlignment::Horizon) + pub fn center_on_axis(&mut self) { + self.align_on_axis(VAlignment::Horizon); } /// Vertically adjust the fragment's frame so that it is aligned /// to the given alignment on the axis. - pub fn align_on_axis(&mut self, ctx: &MathContext, align: VAlignment) { - let h = self.frame.height(); - let axis = ctx.constants.axis_height().scaled(ctx, self.font_size); - self.frame.set_baseline(align.inv().position(h + axis * 2.0)); + pub fn align_on_axis(&mut self, align: VAlignment) { + let h = self.size.y; + let axis = axis_height(&self.item.font).unwrap().at(self.item.size); + self.align += self.baseline(); + self.baseline = Some(align.inv().position(h + axis * 2.0)); + self.align -= self.baseline(); } } -impl Debug for VariantFragment { +impl Debug for GlyphFragment { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "VariantFragment({:?})", self.c) + write!(f, "GlyphFragment({:?})", self.item.text) } } @@ -566,46 +592,47 @@ impl FrameFragment { } } +fn ascent_descent(font: &Font, id: GlyphId) -> Option<(Em, Em)> { + let bbox = font.ttf().glyph_bounding_box(id)?; + Some((font.to_em(bbox.y_max), -font.to_em(bbox.y_min))) +} + /// Look up the italics correction for a glyph. -fn italics_correction(ctx: &MathContext, id: GlyphId, font_size: Abs) -> Option { - Some( - ctx.table - .glyph_info? - .italic_corrections? - .get(id)? - .scaled(ctx, font_size), - ) +fn italics_correction(font: &Font, id: GlyphId) -> Option { + font.ttf() + .tables() + .math? + .glyph_info? + .italic_corrections? + .get(id) + .map(|value| font.to_em(value.value)) } /// Loop up the top accent attachment position for a glyph. -fn accent_attach(ctx: &MathContext, id: GlyphId, font_size: Abs) -> Option { - Some( - ctx.table - .glyph_info? - .top_accent_attachments? - .get(id)? - .scaled(ctx, font_size), - ) +fn accent_attach(font: &Font, id: GlyphId) -> Option { + font.ttf() + .tables() + .math? + .glyph_info? + .top_accent_attachments? + .get(id) + .map(|value| font.to_em(value.value)) } /// Look up whether a glyph is an extended shape. -fn is_extended_shape(ctx: &MathContext, id: GlyphId) -> bool { - ctx.table - .glyph_info - .and_then(|info| info.extended_shapes) - .and_then(|info| info.get(id)) +fn is_extended_shape(font: &Font, id: GlyphId) -> bool { + font.ttf() + .tables() + .math + .and_then(|math| math.glyph_info) + .and_then(|glyph_info| glyph_info.extended_shapes) + .and_then(|coverage| coverage.get(id)) .is_some() } /// Look up a kerning value at a specific corner and height. -fn kern_at_height( - ctx: &MathContext, - font_size: Abs, - id: GlyphId, - corner: Corner, - height: Abs, -) -> Option { - let kerns = ctx.table.glyph_info?.kern_infos?.get(id)?; +fn kern_at_height(font: &Font, id: GlyphId, corner: Corner, height: Em) -> Option { + let kerns = font.ttf().tables().math?.glyph_info?.kern_infos?.get(id)?; let kern = match corner { Corner::TopLeft => kerns.top_left, Corner::TopRight => kerns.top_right, @@ -614,11 +641,187 @@ fn kern_at_height( }?; let mut i = 0; - while i < kern.count() && height > kern.height(i)?.scaled(ctx, font_size) { + while i < kern.count() && height > font.to_em(kern.height(i)?.value) { i += 1; } - Some(kern.kern(i)?.scaled(ctx, font_size)) + Some(font.to_em(kern.kern(i)?.value)) +} + +fn axis_height(font: &Font) -> Option { + Some(font.to_em(font.ttf().tables().math?.constants?.axis_height().value)) +} + +pub fn stretch_axes(font: &Font, id: u16) -> Axes { + let id = GlyphId(id); + let horizontal = font + .ttf() + .tables() + .math + .and_then(|math| math.variants) + .and_then(|variants| variants.horizontal_constructions.get(id)) + .is_some(); + let vertical = font + .ttf() + .tables() + .math + .and_then(|math| math.variants) + .and_then(|variants| variants.vertical_constructions.get(id)) + .is_some(); + + Axes::new(horizontal, vertical) +} + +fn min_connector_overlap(font: &Font) -> Option { + font.ttf() + .tables() + .math? + .variants + .map(|variants| font.to_em(variants.min_connector_overlap)) +} + +fn glyph_construction(font: &Font, id: GlyphId, axis: Axis) -> Option { + font.ttf() + .tables() + .math? + .variants + .map(|variants| match axis { + Axis::X => variants.horizontal_constructions, + Axis::Y => variants.vertical_constructions, + })? + .get(id) +} + +/// Assemble a glyph from parts. +fn assemble( + ctx: &mut MathContext, + base: &mut GlyphFragment, + assembly: GlyphAssembly, + min_overlap: Abs, + target: Abs, + axis: Axis, +) { + // Determine the number of times the extenders need to be repeated as well + // as a ratio specifying how much to spread the parts apart + // (0 = maximal overlap, 1 = minimal overlap). + let mut full; + let mut ratio; + let mut repeat = 0; + loop { + full = Abs::zero(); + ratio = 0.0; + + let mut parts = parts(assembly, repeat).peekable(); + let mut growable = Abs::zero(); + + while let Some(part) = parts.next() { + let mut advance = base.item.font.to_em(part.full_advance).at(base.item.size); + if let Some(next) = parts.peek() { + let max_overlap = base + .item + .font + .to_em(part.end_connector_length.min(next.start_connector_length)) + .at(base.item.size); + if max_overlap < min_overlap { + // This condition happening is indicative of a bug in the + // font. + ctx.engine.sink.warn(warning!( + base.item.glyphs[0].span.0, + "glyph has assembly parts with overlap less than minConnectorOverlap"; + hint: "its rendering may appear broken - this is probably a font bug"; + hint: "please file an issue at https://github.com/typst/typst/issues" + )); + } + + advance -= max_overlap; + growable += max_overlap - min_overlap; + } + + full += advance; + } + + if full < target { + let delta = target - full; + ratio = (delta / growable).min(1.0); + full += ratio * growable; + } + + if target <= full || repeat >= MAX_REPEATS { + break; + } + + repeat += 1; + } + + let mut glyphs = vec![]; + let mut parts = parts(assembly, repeat).peekable(); + while let Some(part) = parts.next() { + let mut advance = base.item.font.to_em(part.full_advance).at(base.item.size); + if let Some(next) = parts.peek() { + let max_overlap = base + .item + .font + .to_em(part.end_connector_length.min(next.start_connector_length)) + .at(base.item.size); + advance -= max_overlap; + advance += ratio * (max_overlap - min_overlap); + } + let (x, y) = match axis { + Axis::X => (Em::from_length(advance, base.item.size), Em::zero()), + Axis::Y => (Em::zero(), Em::from_length(advance, base.item.size)), + }; + glyphs.push(Glyph { + id: part.glyph_id.0, + x_advance: x, + x_offset: Em::zero(), + y_advance: y, + y_offset: Em::zero(), + ..base.item.glyphs[0].clone() + }); + } + + match axis { + Axis::X => base.size.x = full, + Axis::Y => { + base.baseline = None; + base.size.y = full; + base.size.x = glyphs + .iter() + .map(|glyph| base.item.font.x_advance(glyph.id).unwrap_or_default()) + .max() + .unwrap_or_default() + .at(base.item.size); + } + } + + base.item.glyphs = glyphs; + base.italics_correction = base + .item + .font + .to_em(assembly.italics_correction.value) + .at(base.item.size); + if axis == Axis::X { + base.accent_attach = (full / 2.0, full / 2.0); + } + base.mid_stretched = None; + base.extended_shape = true; +} + +/// Return an iterator over the assembly's parts with extenders repeated the +/// specified number of times. +fn parts(assembly: GlyphAssembly, repeat: usize) -> impl Iterator + '_ { + assembly.parts.into_iter().flat_map(move |part| { + let count = if part.part_flags.extender() { repeat } else { 1 }; + std::iter::repeat_n(part, count) + }) +} + +pub fn has_dtls_feat(font: &Font) -> bool { + font.ttf() + .tables() + .gsub + .and_then(|gsub| gsub.features.index(ttf_parser::Tag::from_bytes(b"dtls"))) + .is_some() } /// Describes in which situation a frame should use limits for attachments. @@ -671,56 +874,3 @@ impl Limits { fn is_integral_char(c: char) -> bool { ('∫'..='∳').contains(&c) || ('⨋'..='⨜').contains(&c) } - -/// An OpenType substitution table that is applicable to glyph-wise substitutions. -pub enum GlyphwiseSubsts<'a> { - Single(SingleSubstitution<'a>), - Alternate(AlternateSubstitution<'a>, u32), -} - -impl<'a> GlyphwiseSubsts<'a> { - pub fn new(gsub: Option>, feature: Feature) -> Option { - let gsub = gsub?; - let table = gsub - .features - .find(feature.tag) - .and_then(|feature| feature.lookup_indices.get(0)) - .and_then(|index| gsub.lookups.get(index))?; - let table = table.subtables.get::(0)?; - match table { - SubstitutionSubtable::Single(single_glyphs) => { - Some(Self::Single(single_glyphs)) - } - SubstitutionSubtable::Alternate(alt_glyphs) => { - Some(Self::Alternate(alt_glyphs, feature.value)) - } - _ => None, - } - } - - pub fn try_apply( - &self, - glyph_id: GlyphId, - alt_value: Option, - ) -> Option { - match self { - Self::Single(single) => match single { - SingleSubstitution::Format1 { coverage, delta } => coverage - .get(glyph_id) - .map(|_| GlyphId(glyph_id.0.wrapping_add(*delta as u16))), - SingleSubstitution::Format2 { coverage, substitutes } => { - coverage.get(glyph_id).and_then(|idx| substitutes.get(idx)) - } - }, - Self::Alternate(alternate, value) => alternate - .coverage - .get(glyph_id) - .and_then(|idx| alternate.alternate_sets.get(idx)) - .and_then(|set| set.alternates.get(alt_value.unwrap_or(*value) as u16)), - } - } - - pub fn apply(&self, glyph_id: GlyphId) -> GlyphId { - self.try_apply(glyph_id, None).unwrap_or(glyph_id) - } -} diff --git a/crates/typst-layout/src/math/lr.rs b/crates/typst-layout/src/math/lr.rs index bf82354118..e0caf4179e 100644 --- a/crates/typst-layout/src/math/lr.rs +++ b/crates/typst-layout/src/math/lr.rs @@ -45,20 +45,20 @@ pub fn layout_lr( // Scale up fragments at both ends. match inner_fragments { - [one] => scale(ctx, styles, one, relative_to, height, None), + [one] => scale(ctx, one, relative_to, height, None), [first, .., last] => { - scale(ctx, styles, first, relative_to, height, Some(MathClass::Opening)); - scale(ctx, styles, last, relative_to, height, Some(MathClass::Closing)); + scale(ctx, first, relative_to, height, Some(MathClass::Opening)); + scale(ctx, last, relative_to, height, Some(MathClass::Closing)); } _ => {} } - // Handle MathFragment::Variant fragments that should be scaled up. + // Handle MathFragment::Glyph fragments that should be scaled up. 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); - scale(ctx, styles, fragment, relative_to, height, Some(MathClass::Large)); + if let MathFragment::Glyph(ref mut glyph) = fragment { + if glyph.mid_stretched == Some(false) { + glyph.mid_stretched = Some(true); + scale(ctx, fragment, relative_to, height, Some(MathClass::Large)); } } } @@ -95,18 +95,9 @@ pub fn layout_mid( let mut fragments = ctx.layout_into_fragments(&elem.body, styles)?; for fragment in &mut fragments { - match fragment { - MathFragment::Glyph(glyph) => { - let mut new = glyph.clone().into_variant(); - new.mid_stretched = Some(false); - new.class = MathClass::Fence; - *fragment = MathFragment::Variant(new); - } - MathFragment::Variant(variant) => { - variant.mid_stretched = Some(false); - variant.class = MathClass::Fence; - } - _ => {} + if let MathFragment::Glyph(ref mut glyph) = fragment { + glyph.mid_stretched = Some(false); + glyph.class = MathClass::Fence; } } @@ -117,7 +108,6 @@ pub fn layout_mid( /// Scale a math fragment to a height. fn scale( ctx: &mut MathContext, - styles: StyleChain, fragment: &mut MathFragment, relative_to: Abs, height: Rel, @@ -132,7 +122,6 @@ fn scale( let short_fall = DELIM_SHORT_FALL.at(fragment.font_size().unwrap_or_default()); stretch_fragment( ctx, - styles, fragment, Some(Axis::Y), Some(relative_to), diff --git a/crates/typst-layout/src/math/mat.rs b/crates/typst-layout/src/math/mat.rs index e509cecc7e..278b1343ed 100644 --- a/crates/typst-layout/src/math/mat.rs +++ b/crates/typst-layout/src/math/mat.rs @@ -9,8 +9,8 @@ use typst_library::visualize::{FillRule, FixedStroke, Geometry, LineCap, Shape}; use typst_syntax::Span; use super::{ - alignments, delimiter_alignment, style_for_denominator, AlignmentResult, - FrameFragment, GlyphFragment, LeftRightAlternator, MathContext, DELIM_SHORT_FALL, + alignments, style_for_denominator, AlignmentResult, FrameFragment, GlyphFragment, + LeftRightAlternator, MathContext, DELIM_SHORT_FALL, }; const VERTICAL_PADDING: Ratio = Ratio::new(0.1); @@ -183,8 +183,12 @@ fn layout_body( // We pad ascent and descent with the ascent and descent of the paren // to ensure that normal matrices are aligned with others unless they are // way too big. - let paren = - GlyphFragment::new(ctx, styles.chain(&denom_style), '(', Span::detached()); + let paren = GlyphFragment::new_char( + ctx.font, + styles.chain(&denom_style), + '(', + Span::detached(), + )?; for (column, col) in columns.iter().zip(&mut cols) { for (cell, (ascent, descent)) in column.iter().zip(&mut heights) { @@ -202,8 +206,8 @@ fn layout_body( )); } - ascent.set_max(cell.ascent().max(paren.ascent)); - descent.set_max(cell.descent().max(paren.descent)); + ascent.set_max(cell.ascent().max(paren.ascent())); + descent.set_max(cell.descent().max(paren.descent())); col.push(cell); } @@ -312,19 +316,19 @@ fn layout_delimiters( let target = height + VERTICAL_PADDING.of(height); frame.set_baseline(height / 2.0 + axis); - if let Some(left) = left { - let mut left = GlyphFragment::new(ctx, styles, left, span) - .stretch_vertical(ctx, target - short_fall); - left.align_on_axis(ctx, delimiter_alignment(left.c)); + if let Some(left_c) = left { + let mut left = GlyphFragment::new_char(ctx.font, styles, left_c, span)?; + left.stretch_vertical(ctx, target - short_fall); + left.center_on_axis(); ctx.push(left); } ctx.push(FrameFragment::new(styles, frame)); - if let Some(right) = right { - let mut right = GlyphFragment::new(ctx, styles, right, span) - .stretch_vertical(ctx, target - short_fall); - right.align_on_axis(ctx, delimiter_alignment(right.c)); + if let Some(right_c) = right { + let mut right = GlyphFragment::new_char(ctx.font, styles, right_c, span)?; + right.stretch_vertical(ctx, target - short_fall); + right.center_on_axis(); ctx.push(right); } diff --git a/crates/typst-layout/src/math/mod.rs b/crates/typst-layout/src/math/mod.rs index 708a4443da..5fd22e5789 100644 --- a/crates/typst-layout/src/math/mod.rs +++ b/crates/typst-layout/src/math/mod.rs @@ -13,8 +13,6 @@ mod stretch; mod text; mod underover; -use rustybuzz::Feature; -use ttf_parser::Tag; use typst_library::diag::{bail, SourceResult}; use typst_library::engine::Engine; use typst_library::foundations::{ @@ -30,7 +28,7 @@ use typst_library::math::*; use typst_library::model::ParElem; use typst_library::routines::{Arenas, RealizationKind}; use typst_library::text::{ - families, features, variant, Font, LinebreakElem, SpaceElem, TextEdgeBounds, TextElem, + families, variant, Font, LinebreakElem, SpaceElem, TextEdgeBounds, TextElem, }; use typst_library::World; use typst_syntax::Span; @@ -38,11 +36,11 @@ use typst_utils::Numeric; use unicode_math_class::MathClass; use self::fragment::{ - FrameFragment, GlyphFragment, GlyphwiseSubsts, Limits, MathFragment, VariantFragment, + has_dtls_feat, stretch_axes, FrameFragment, GlyphFragment, Limits, MathFragment, }; use self::run::{LeftRightAlternator, MathRun, MathRunFrameBuilder}; use self::shared::*; -use self::stretch::{stretch_fragment, stretch_glyph}; +use self::stretch::stretch_fragment; /// Layout an inline equation (in a paragraph). #[typst_macros::time(span = elem.span())] @@ -58,7 +56,7 @@ pub fn layout_equation_inline( let font = find_math_font(engine, styles, elem.span())?; let mut locator = locator.split(); - let mut ctx = MathContext::new(engine, &mut locator, styles, region, &font); + let mut ctx = MathContext::new(engine, &mut locator, region, &font); let scale_style = style_for_script_scale(&ctx); let styles = styles.chain(&scale_style); @@ -113,7 +111,7 @@ pub fn layout_equation_block( let font = find_math_font(engine, styles, span)?; let mut locator = locator.split(); - let mut ctx = MathContext::new(engine, &mut locator, styles, regions.base(), &font); + let mut ctx = MathContext::new(engine, &mut locator, regions.base(), &font); let scale_style = style_for_script_scale(&ctx); let styles = styles.chain(&scale_style); @@ -374,14 +372,7 @@ struct MathContext<'a, 'v, 'e> { region: Region, // Font-related. font: &'a Font, - ttf: &'a ttf_parser::Face<'a>, - table: ttf_parser::math::Table<'a>, constants: ttf_parser::math::Constants<'a>, - dtls_table: Option>, - flac_table: Option>, - ssty_table: Option>, - glyphwise_tables: Option>>, - space_width: Em, // Mutable. fragments: Vec, } @@ -391,46 +382,20 @@ impl<'a, 'v, 'e> MathContext<'a, 'v, 'e> { fn new( engine: &'v mut Engine<'e>, locator: &'v mut SplitLocator<'a>, - styles: StyleChain<'a>, base: Size, font: &'a Font, ) -> Self { - let math_table = font.ttf().tables().math.unwrap(); - let gsub_table = font.ttf().tables().gsub; - let constants = math_table.constants.unwrap(); - - let feat = |tag: &[u8; 4]| { - GlyphwiseSubsts::new(gsub_table, Feature::new(Tag::from_bytes(tag), 0, ..)) - }; - - let features = features(styles); - let glyphwise_tables = Some( - features - .into_iter() - .filter_map(|feature| GlyphwiseSubsts::new(gsub_table, feature)) - .collect(), - ); - - let ttf = font.ttf(); - let space_width = ttf - .glyph_index(' ') - .and_then(|id| ttf.glyph_hor_advance(id)) - .map(|advance| font.to_em(advance)) - .unwrap_or(THICK); + // These unwraps are safe as the font given is one returned by the + // find_math_font function, which only returns fonts that have a math + // constants table. + let constants = font.ttf().tables().math.unwrap().constants.unwrap(); Self { engine, locator, region: Region::new(base, Axes::splat(false)), font, - ttf, - table: math_table, constants, - dtls_table: feat(b"dtls"), - flac_table: feat(b"flac"), - ssty_table: feat(b"ssty"), - glyphwise_tables, - space_width, fragments: vec![], } } @@ -529,7 +494,8 @@ fn layout_realized( if let Some(elem) = elem.to_packed::() { ctx.push(MathFragment::Tag(elem.tag.clone())); } else if elem.is::() { - ctx.push(MathFragment::Space(ctx.space_width.resolve(styles))); + let space_width = ctx.font.space_width().unwrap_or(THICK); + ctx.push(MathFragment::Space(space_width.resolve(styles))); } else if elem.is::() { ctx.push(MathFragment::Linebreak); } else if let Some(elem) = elem.to_packed::() { diff --git a/crates/typst-layout/src/math/root.rs b/crates/typst-layout/src/math/root.rs index 32f5271986..91b9b16afd 100644 --- a/crates/typst-layout/src/math/root.rs +++ b/crates/typst-layout/src/math/root.rs @@ -49,9 +49,9 @@ pub fn layout_root( // Layout root symbol. let target = radicand.height() + thickness + gap; - let sqrt = GlyphFragment::new(ctx, styles, '√', span) - .stretch_vertical(ctx, target) - .frame; + let mut sqrt = GlyphFragment::new_char(ctx.font, styles, '√', span)?; + sqrt.stretch_vertical(ctx, target); + let sqrt = sqrt.into_frame(); // Layout the index. let sscript = EquationElem::set_size(MathSize::ScriptScript).wrap(); diff --git a/crates/typst-layout/src/math/shared.rs b/crates/typst-layout/src/math/shared.rs index 600c130d47..1f88d2dd7e 100644 --- a/crates/typst-layout/src/math/shared.rs +++ b/crates/typst-layout/src/math/shared.rs @@ -1,7 +1,9 @@ use ttf_parser::math::MathValue; +use ttf_parser::Tag; use typst_library::foundations::{Style, StyleChain}; -use typst_library::layout::{Abs, Em, FixedAlignment, Frame, Point, Size, VAlignment}; +use typst_library::layout::{Abs, Em, FixedAlignment, Frame, Point, Size}; use typst_library::math::{EquationElem, MathSize}; +use typst_library::text::{FontFeatures, TextElem}; use typst_utils::LazyHash; use super::{LeftRightAlternator, MathContext, MathFragment, MathRun}; @@ -59,6 +61,16 @@ pub fn style_cramped() -> LazyHash + + diff --git a/tests/suite/html/syntax.typ b/tests/suite/html/syntax.typ index fb5caf3bd7..eb1c869940 100644 --- a/tests/suite/html/syntax.typ +++ b/tests/suite/html/syntax.typ @@ -10,3 +10,54 @@ #html.pre("hello") #html.pre("\nhello") #html.pre("\n\nhello") + +--- html-script html --- +// This should be pretty and indented. +#html.script( + ```js + const x = 1 + const y = 2 + console.log(x < y, Math.max(1, 2)) + ```.text, +) + +// This should have extra newlines, but no indent because of the multiline +// string literal. +#html.script("console.log(`Hello\nWorld`)") + +// This should be untouched. +#html.script( + type: "text/python", + ```py + x = 1 + y = 2 + print(x < y, max(x, y)) + ```.text, +) + +--- html-style html --- +// This should be pretty and indented. +#html.style( + ```css + body { + text: red; + } + ```.text, +) + +--- html-raw-text-contains-elem html --- +// Error: 14-32 HTML raw text element cannot have non-text children +#html.script(html.strong[Hello]) + +--- html-raw-text-contains-frame html --- +// Error: 2-29 HTML raw text element cannot have non-text children +#html.script(html.frame[Ok]) + +--- html-raw-text-contains-closing-tag html --- +// Error: 2-32 HTML raw text element cannot contain its own closing tag +// Hint: 2-32 the sequence `") From 38dd6da237b8d1ea86f82069338d9ceae479d180 Mon Sep 17 00:00:00 2001 From: Wannes Malfait <46323945+WannesMalfait@users.noreply.github.com> Date: Mon, 23 Jun 2025 16:58:04 +0200 Subject: [PATCH 174/558] Fix stroke cap of shapes with partial stroke (#5688) --- crates/typst-layout/src/shapes.rs | 113 +++++++++++++++++++++++++++--- tests/ref/rect-stroke-caps.png | Bin 0 -> 252 bytes tests/suite/visualize/rect.typ | 16 +++++ 3 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 tests/ref/rect-stroke-caps.png diff --git a/crates/typst-layout/src/shapes.rs b/crates/typst-layout/src/shapes.rs index 7ab41e9d4c..0616b4ce48 100644 --- a/crates/typst-layout/src/shapes.rs +++ b/crates/typst-layout/src/shapes.rs @@ -11,8 +11,8 @@ use typst_library::layout::{ }; use typst_library::visualize::{ CircleElem, CloseMode, Curve, CurveComponent, CurveElem, EllipseElem, FillRule, - FixedStroke, Geometry, LineElem, Paint, PathElem, PathVertex, PolygonElem, RectElem, - Shape, SquareElem, Stroke, + FixedStroke, Geometry, LineCap, LineElem, Paint, PathElem, PathVertex, PolygonElem, + RectElem, Shape, SquareElem, Stroke, }; use typst_syntax::Span; use typst_utils::{Get, Numeric}; @@ -889,7 +889,13 @@ fn segmented_rect( let end = current; last = current; let Some(stroke) = strokes.get_ref(start.side_cw()) else { continue }; - let (shape, ontop) = segment(start, end, &corners, stroke); + let start_cap = stroke.cap; + let end_cap = match strokes.get_ref(end.side_ccw()) { + Some(stroke) => stroke.cap, + None => start_cap, + }; + let (shape, ontop) = + segment(start, end, start_cap, end_cap, &corners, stroke); if ontop { res.push(shape); } else { @@ -899,7 +905,14 @@ fn segmented_rect( } } else if let Some(stroke) = &strokes.top { // single segment - let (shape, _) = segment(Corner::TopLeft, Corner::TopLeft, &corners, stroke); + let (shape, _) = segment( + Corner::TopLeft, + Corner::TopLeft, + stroke.cap, + stroke.cap, + &corners, + stroke, + ); res.push(shape); } res @@ -946,6 +959,8 @@ fn curve_segment( fn segment( start: Corner, end: Corner, + start_cap: LineCap, + end_cap: LineCap, corners: &Corners, stroke: &FixedStroke, ) -> (Shape, bool) { @@ -979,7 +994,7 @@ fn segment( let use_fill = solid && fill_corners(start, end, corners); let shape = if use_fill { - fill_segment(start, end, corners, stroke) + fill_segment(start, end, start_cap, end_cap, corners, stroke) } else { stroke_segment(start, end, corners, stroke.clone()) }; @@ -1010,6 +1025,8 @@ fn stroke_segment( fn fill_segment( start: Corner, end: Corner, + start_cap: LineCap, + end_cap: LineCap, corners: &Corners, stroke: &FixedStroke, ) -> Shape { @@ -1035,8 +1052,7 @@ fn fill_segment( if c.arc_outer() { curve.arc_line(c.mid_outer(), c.center_outer(), c.end_outer()); } else { - curve.line(c.outer()); - curve.line(c.end_outer()); + c.start_cap(&mut curve, start_cap); } } @@ -1079,7 +1095,7 @@ fn fill_segment( if c.arc_inner() { curve.arc_line(c.mid_inner(), c.center_inner(), c.start_inner()); } else { - curve.line(c.center_inner()); + c.end_cap(&mut curve, end_cap); } } @@ -1134,6 +1150,16 @@ struct ControlPoints { } impl ControlPoints { + /// Rotate point around the origin, relative to the top-left. + fn rotate_centered(&self, point: Point) -> Point { + match self.corner { + Corner::TopLeft => point, + Corner::TopRight => Point { x: -point.y, y: point.x }, + Corner::BottomRight => Point { x: -point.x, y: -point.y }, + Corner::BottomLeft => Point { x: point.y, y: -point.x }, + } + } + /// Move and rotate the point from top-left to the required corner. fn rotate(&self, point: Point) -> Point { match self.corner { @@ -1280,6 +1306,77 @@ impl ControlPoints { y: self.stroke_after, }) } + + /// Draw the cap at the beginning of the segment. + /// + /// If this corner has a stroke before it, + /// a default "butt" cap is used. + /// + /// NOTE: doesn't support the case where the corner has a radius. + pub fn start_cap(&self, curve: &mut Curve, cap_type: LineCap) { + if self.stroke_before != Abs::zero() + || self.radius != Abs::zero() + || cap_type == LineCap::Butt + { + // Just the default cap. + curve.line(self.outer()); + } else if cap_type == LineCap::Square { + // Extend by the stroke width. + let offset = + self.rotate_centered(Point { x: -self.stroke_after, y: Abs::zero() }); + curve.line(self.end_inner() + offset); + curve.line(self.outer() + offset); + } else if cap_type == LineCap::Round { + // We push the center by a little bit to ensure the correct + // half of the circle gets drawn. If it is perfectly centered + // the `arc` function just degenerates into a line, which we + // do not want in this case. + curve.arc( + self.end_inner(), + (self.end_inner() + + self.rotate_centered(Point { x: Abs::raw(1.0), y: Abs::zero() }) + + self.outer()) + / 2., + self.outer(), + ); + } + curve.line(self.end_outer()); + } + + /// Draw the cap at the end of the segment. + /// + /// If this corner has a stroke before it, + /// a default "butt" cap is used. + /// + /// NOTE: doesn't support the case where the corner has a radius. + pub fn end_cap(&self, curve: &mut Curve, cap_type: LineCap) { + if self.stroke_after != Abs::zero() + || self.radius != Abs::zero() + || cap_type == LineCap::Butt + { + // Just the default cap. + curve.line(self.center_inner()); + } else if cap_type == LineCap::Square { + // Extend by the stroke width. + let offset = + self.rotate_centered(Point { x: Abs::zero(), y: -self.stroke_before }); + curve.line(self.outer() + offset); + curve.line(self.center_inner() + offset); + } else if cap_type == LineCap::Round { + // We push the center by a little bit to ensure the correct + // half of the circle gets drawn. If it is perfectly centered + // the `arc` function just degenerates into a line, which we + // do not want in this case. + curve.arc( + self.outer(), + (self.outer() + + self.rotate_centered(Point { x: Abs::zero(), y: Abs::raw(1.0) }) + + self.center_inner()) + / 2., + self.center_inner(), + ); + } + } } /// Helper to draw arcs with Bézier curves. diff --git a/tests/ref/rect-stroke-caps.png b/tests/ref/rect-stroke-caps.png new file mode 100644 index 0000000000000000000000000000000000000000..13a34ad9aaf255c1a8f758c93842dd88cdb850ff GIT binary patch literal 252 zcmeAS@N?(olHy`uVBq!ia0vp^6+oQ90VEjYZ)Q&gQinZV978H@y}j-zc*sG(>iSw3$>=}Dp&?udk6iOuDG73+bd*Hz3W{yHpf3r%RKw) z&_(ZBlLmFqUC$1=8YV!&gW$fsFP7*@9q{%E{ Date: Mon, 23 Jun 2025 17:09:03 +0200 Subject: [PATCH 175/558] Adding Croatian translations entries (#6413) --- crates/typst-library/src/text/lang.rs | 1 + crates/typst-library/translations/hr.txt | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 crates/typst-library/translations/hr.txt diff --git a/crates/typst-library/src/text/lang.rs b/crates/typst-library/src/text/lang.rs index e06156c433..a170714b59 100644 --- a/crates/typst-library/src/text/lang.rs +++ b/crates/typst-library/src/text/lang.rs @@ -30,6 +30,7 @@ const TRANSLATIONS: &[(&str, &str)] = &[ translation!("fr"), translation!("gl"), translation!("he"), + translation!("hr"), translation!("hu"), translation!("id"), translation!("is"), diff --git a/crates/typst-library/translations/hr.txt b/crates/typst-library/translations/hr.txt new file mode 100644 index 0000000000..ea07545921 --- /dev/null +++ b/crates/typst-library/translations/hr.txt @@ -0,0 +1,8 @@ +figure = Slika +table = Tablica +equation = Jednadžba +bibliography = Literatura +heading = Odjeljak +outline = Sadržaj +raw = Kôd +page = str. From 24293a6c121a4b4e02c32901fec44e0093aa5d8c Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Tue, 24 Jun 2025 12:56:58 +0300 Subject: [PATCH 176/558] Rewrite `outline.indent` example (#6383) Co-authored-by: Laurenz --- crates/typst-library/src/model/outline.rs | 35 +++++++++++------------ 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/crates/typst-library/src/model/outline.rs b/crates/typst-library/src/model/outline.rs index 489c375e67..16a1161463 100644 --- a/crates/typst-library/src/model/outline.rs +++ b/crates/typst-library/src/model/outline.rs @@ -225,25 +225,21 @@ pub struct OutlineElem { /// to just specifying `{2em}`. /// /// ```example - /// #set heading(numbering: "1.a.") + /// >>> #show heading: none + /// #set heading(numbering: "I-I.") + /// #set outline(title: none) /// - /// #outline( - /// title: [Contents (Automatic)], - /// indent: auto, - /// ) - /// - /// #outline( - /// title: [Contents (Length)], - /// indent: 2em, - /// ) - /// - /// = About ACME Corp. - /// == History - /// === Origins - /// #lorem(10) + /// #outline() + /// #line(length: 100%) + /// #outline(indent: 3em) /// - /// == Products - /// #lorem(10) + /// = Software engineering technologies + /// == Requirements + /// == Tools and technologies + /// === Code editors + /// == Analyzing alternatives + /// = Designing software components + /// = Testing and integration /// ``` pub indent: Smart, } @@ -450,8 +446,9 @@ impl OutlineEntry { /// at the same level are aligned. /// /// If the outline's indent is a fixed value or a function, the prefixes are - /// indented, but the inner contents are simply inset from the prefix by the - /// specified `gap`, rather than aligning outline-wide. + /// indented, but the inner contents are simply offset from the prefix by + /// the specified `gap`, rather than aligning outline-wide. For a visual + /// explanation, see [`outline.indent`]($outline.indent). #[func(contextual)] pub fn indented( &self, From 899de6d5d501c6aed04897d425dd3615e745743e Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 24 Jun 2025 10:03:10 +0000 Subject: [PATCH 177/558] Use ICU data to check if accent is bottom (#6393) Co-authored-by: Laurenz --- crates/typst-library/src/math/accent.rs | 30 +++++++++++++++++-------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/crates/typst-library/src/math/accent.rs b/crates/typst-library/src/math/accent.rs index f2c9168c2f..c8569ea235 100644 --- a/crates/typst-library/src/math/accent.rs +++ b/crates/typst-library/src/math/accent.rs @@ -1,3 +1,10 @@ +use std::sync::LazyLock; + +use icu_properties::maps::CodePointMapData; +use icu_properties::CanonicalCombiningClass; +use icu_provider::AsDeserializingBufferProvider; +use icu_provider_blob::BlobDataProvider; + use crate::diag::bail; use crate::foundations::{cast, elem, func, Content, NativeElement, SymbolElem}; use crate::layout::{Length, Rel}; @@ -81,17 +88,22 @@ impl Accent { Self(Self::combine(c).unwrap_or(c)) } - /// List of bottom accents. Currently just a list of ones included in the - /// Unicode math class document. - const BOTTOM: &[char] = &[ - '\u{0323}', '\u{032C}', '\u{032D}', '\u{032E}', '\u{032F}', '\u{0330}', - '\u{0331}', '\u{0332}', '\u{0333}', '\u{033A}', '\u{20E8}', '\u{20EC}', - '\u{20ED}', '\u{20EE}', '\u{20EF}', - ]; - /// Whether this accent is a bottom accent or not. pub fn is_bottom(&self) -> bool { - Self::BOTTOM.contains(&self.0) + static COMBINING_CLASS_DATA: LazyLock> = + LazyLock::new(|| { + icu_properties::maps::load_canonical_combining_class( + &BlobDataProvider::try_new_from_static_blob(typst_assets::icu::ICU) + .unwrap() + .as_deserializing(), + ) + .unwrap() + }); + + matches!( + COMBINING_CLASS_DATA.as_borrowed().get(self.0), + CanonicalCombiningClass::Below + ) } } From 87c56865606e027f552a4dbc800c6851b0d0b821 Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Tue, 24 Jun 2025 13:22:55 +0300 Subject: [PATCH 178/558] Add docs for `std` module (#6407) Co-authored-by: Laurenz --- crates/typst-library/src/lib.rs | 2 +- docs/reference/groups.yml | 53 +++++++++++++++++++++++++++++++++ docs/src/lib.rs | 2 +- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/crates/typst-library/src/lib.rs b/crates/typst-library/src/lib.rs index c39024f71c..fa79778883 100644 --- a/crates/typst-library/src/lib.rs +++ b/crates/typst-library/src/lib.rs @@ -148,7 +148,7 @@ pub struct Library { /// The default style properties (for page size, font selection, and /// everything else configurable via set and show rules). pub styles: Styles, - /// The standard library as a value. Used to provide the `std` variable. + /// The standard library as a value. Used to provide the `std` module. pub std: Binding, /// In-development features that were enabled. pub features: Features, diff --git a/docs/reference/groups.yml b/docs/reference/groups.yml index e5aa7e9990..c7e3d99644 100644 --- a/docs/reference/groups.yml +++ b/docs/reference/groups.yml @@ -137,6 +137,59 @@ In addition to the functions listed below, the `calc` module also defines the constants `pi`, `tau`, `e`, and `inf`. +- name: std + title: Standard library + category: foundations + path: ["std"] + details: | + A module that contains all globally accessible items. + + # Using "shadowed" definitions + The `std` module is useful whenever you overrode a name from the global + scope (this is called _shadowing_). For instance, you might have used the + name `text` for a parameter. To still access the `text` element, write + `std.text`. + + ```example + >>> #set page(margin: (left: 3em)) + #let par = [My special paragraph.] + #let special(text) = { + set std.text(style: "italic") + set std.par.line(numbering: "1") + text + } + + #special(par) + + #lorem(10) + ``` + + # Conditional access + You can also use this in combination with the [dictionary + constructor]($dictionary) to conditionally access global definitions. This + can, for instance, be useful to use new or experimental functionality when + it is available, while falling back to an alternative implementation if + used on an older Typst version. In particular, this allows us to create + [polyfills](https://en.wikipedia.org/wiki/Polyfill_(programming)). + + This can be as simple as creating an alias to prevent warning messages, for + example, conditionally using `pattern` in Typst version 0.12, but using + [`tiling`] in newer versions. Since the parameters accepted by the `tiling` + function match those of the older `pattern` function, using the `tiling` + function when available and falling back to `pattern` otherwise will unify + the usage across all versions. Note that, when creating a polyfill, + [`sys.version`]($category/foundations/sys) can also be very useful. + + ```typ + #let tiling = if "tiling" in dictionary(std) { + tiling + } else { + pattern + } + + ... + ``` + - name: sys title: System category: foundations diff --git a/docs/src/lib.rs b/docs/src/lib.rs index b81f0dc66c..9bd21c2e81 100644 --- a/docs/src/lib.rs +++ b/docs/src/lib.rs @@ -37,7 +37,7 @@ static GROUPS: LazyLock> = LazyLock::new(|| { let mut groups: Vec = yaml::from_str(load!("reference/groups.yml")).unwrap(); for group in &mut groups { - if group.filter.is_empty() { + if group.filter.is_empty() && group.name != "std" { group.filter = group .module() .scope() From f162c371017f0d503cfae8738cbbf505b9f11173 Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Tue, 24 Jun 2025 15:49:28 +0300 Subject: [PATCH 179/558] Improve equation reference example (#6481) --- crates/typst-library/src/model/reference.rs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/crates/typst-library/src/model/reference.rs b/crates/typst-library/src/model/reference.rs index f22d70b326..6fddc56ca7 100644 --- a/crates/typst-library/src/model/reference.rs +++ b/crates/typst-library/src/model/reference.rs @@ -91,16 +91,13 @@ use crate::text::TextElem; /// #show ref: it => { /// let eq = math.equation /// let el = it.element -/// if el != none and el.func() == eq { -/// // Override equation references. -/// link(el.location(),numbering( -/// el.numbering, -/// ..counter(eq).at(el.location()) -/// )) -/// } else { -/// // Other references as usual. -/// it -/// } +/// // Skip all other references. +/// if el == none or el.func() != eq { return it } +/// // Override equation references. +/// link(el.location(), numbering( +/// el.numbering, +/// ..counter(eq).at(el.location()) +/// )) /// } /// /// = Beginnings From d4be7c4ca54ce1907ce5f7af8a603cf3f4c5a42f Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Tue, 24 Jun 2025 16:00:51 +0300 Subject: [PATCH 180/558] Add page reference customization example (#6480) Co-authored-by: Laurenz --- crates/typst-library/src/model/reference.rs | 30 +++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/typst-library/src/model/reference.rs b/crates/typst-library/src/model/reference.rs index 6fddc56ca7..17f93b7c4f 100644 --- a/crates/typst-library/src/model/reference.rs +++ b/crates/typst-library/src/model/reference.rs @@ -79,6 +79,36 @@ use crate::text::TextElem; /// reference: `[@intro[Chapter]]`. /// /// # Customization +/// When you only ever need to reference pages of a figure/table/heading/etc. in +/// a document, the default `form` field value can be changed to `{"page"}` with +/// a set rule. If you prefer a short "p." supplement over "page", the +/// [`page.supplement`]($page.supplement) field can be used for changing this: +/// +/// ```example +/// #set page( +/// numbering: "1", +/// supplement: "p.", +/// >>> margin: (bottom: 3em), +/// >>> footer-descent: 1.25em, +/// ) +/// #set ref(form: "page") +/// +/// #figure( +/// stack( +/// dir: ltr, +/// spacing: 1em, +/// circle(), +/// square(), +/// ), +/// caption: [Shapes], +/// ) +/// +/// #pagebreak() +/// +/// See @shapes for examples +/// of different shapes. +/// ``` +/// /// If you write a show rule for references, you can access the referenced /// element through the `element` field of the reference. The `element` may /// be `{none}` even if it exists if Typst hasn't discovered it yet, so you From 70399a94fd58cc5e3e953c10670c396de8f7f6f7 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Tue, 24 Jun 2025 15:23:37 +0200 Subject: [PATCH 181/558] Bump `krilla` to current Git version (#6488) Co-authored-by: Laurenz --- Cargo.lock | 18 ++++++++---------- Cargo.toml | 4 ++-- crates/typst-pdf/src/embed.rs | 3 +-- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 58cac3c586..3ea423f5f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -786,9 +786,9 @@ checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" [[package]] name = "font-types" -version = "0.8.4" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa6a5e5a77b5f3f7f9e32879f484aa5b3632ddfbe568a16266c904a6f32cdaf" +checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7" dependencies = [ "bytemuck", ] @@ -1367,8 +1367,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69ee6128ebf52d7ce684613b6431ead2959f2be9ff8cf776eeaaad0427c953e9" +source = "git+https://github.com/LaurenzV/krilla?rev=20c14fe#20c14fefee5002566b3d6668b338bbe2168784e7" dependencies = [ "base64", "bumpalo", @@ -1396,8 +1395,7 @@ dependencies = [ [[package]] name = "krilla-svg" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3462989578155cf620ef8035f8921533cc95c28e2a0c75de172f7219e6aba84e" +source = "git+https://github.com/LaurenzV/krilla?rev=20c14fe#20c14fefee5002566b3d6668b338bbe2168784e7" dependencies = [ "flate2", "fontdb", @@ -2106,9 +2104,9 @@ dependencies = [ [[package]] name = "read-fonts" -version = "0.28.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "600e807b48ac55bad68a8cb75cc3c7739f139b9248f7e003e01e080f589b5288" +checksum = "192735ef611aac958468e670cb98432c925426f3cb71521fda202130f7388d91" dependencies = [ "bytemuck", "font-types", @@ -2434,9 +2432,9 @@ checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "skrifa" -version = "0.30.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fa1e5622e4f7b98877e8a19890efddcac1230cec6198bd9de91ec0e00010dc8" +checksum = "e6d632b5a73f566303dbeabd344dc3e716fd4ddc9a70d6fc8ea8e6f06617da97" dependencies = [ "bytemuck", "read-fonts", diff --git a/Cargo.toml b/Cargo.toml index 72ab9094dd..3cfb720084 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,8 +73,8 @@ image = { version = "0.25.5", default-features = false, features = ["png", "jpeg indexmap = { version = "2", features = ["serde"] } infer = { version = "0.19.0", default-features = false } kamadak-exif = "0.6" -krilla = { version = "0.4.0", default-features = false, features = ["raster-images", "comemo", "rayon"] } -krilla-svg = "0.1.0" +krilla = { git = "https://github.com/LaurenzV/krilla", rev = "20c14fe", default-features = false, features = ["raster-images", "comemo", "rayon"] } +krilla-svg = { git = "https://github.com/LaurenzV/krilla", rev = "20c14fe" } kurbo = "0.11" libfuzzer-sys = "0.4" lipsum = "0.9" diff --git a/crates/typst-pdf/src/embed.rs b/crates/typst-pdf/src/embed.rs index f0cd9060a5..36330c445d 100644 --- a/crates/typst-pdf/src/embed.rs +++ b/crates/typst-pdf/src/embed.rs @@ -34,8 +34,7 @@ pub(crate) fn embed_files( }, }; let data: Arc + Send + Sync> = Arc::new(embed.data.clone()); - // TODO: update when new krilla version lands (https://github.com/LaurenzV/krilla/pull/203) - let compress = should_compress(&embed.data).unwrap_or(true); + let compress = should_compress(&embed.data); let file = EmbeddedFile { path, From 9e3c1199edddc0422d34a266681d2efe1babd0c1 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Tue, 24 Jun 2025 17:05:02 +0200 Subject: [PATCH 182/558] Check that git tree is clean after build (#6495) --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5c81537b2..2354de582a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,7 @@ jobs: - run: cargo clippy --workspace --all-targets --no-default-features - run: cargo fmt --check --all - run: cargo doc --workspace --no-deps + - run: git diff --exit-code min-version: name: Check minimum Rust version From f2f527c451b1b05b393af99b89c528aadb203ce6 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Tue, 24 Jun 2025 17:52:15 +0200 Subject: [PATCH 183/558] Also fix encoding of ` + diff --git a/tests/suite/html/syntax.typ b/tests/suite/html/syntax.typ index eb1c869940..4bda0c6865 100644 --- a/tests/suite/html/syntax.typ +++ b/tests/suite/html/syntax.typ @@ -11,6 +11,9 @@ #html.pre("\nhello") #html.pre("\n\nhello") +--- html-textarea-starting-with-newline html --- +#html.textarea("\nenter") + --- html-script html --- // This should be pretty and indented. #html.script( From d54544297beba0a762bee9bc731baab96e4d7250 Mon Sep 17 00:00:00 2001 From: +merlan #flirora Date: Wed, 25 Jun 2025 12:58:40 -0400 Subject: [PATCH 184/558] Minor fixes to doc comments (#6500) --- crates/typst-layout/src/inline/line.rs | 6 +++++- crates/typst-library/src/model/bibliography.rs | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/typst-layout/src/inline/line.rs b/crates/typst-layout/src/inline/line.rs index 659d33f4a1..f051892751 100644 --- a/crates/typst-layout/src/inline/line.rs +++ b/crates/typst-layout/src/inline/line.rs @@ -640,7 +640,7 @@ impl<'a> Items<'a> { self.0.push(entry.into()); } - /// Iterate over the items + /// Iterate over the items. pub fn iter(&self) -> impl Iterator> { self.0.iter().map(|item| &**item) } @@ -698,6 +698,10 @@ impl Debug for Items<'_> { } /// A reference to or a boxed item. +/// +/// This is conceptually similar to a [`Cow<'a, Item<'a>>`][std::borrow::Cow], +/// but we box owned items since an [`Item`] is much bigger than +/// a box. pub enum ItemEntry<'a> { Ref(&'a Item<'a>), Box(Box>), diff --git a/crates/typst-library/src/model/bibliography.rs b/crates/typst-library/src/model/bibliography.rs index 8056d4ab3d..e1a0735945 100644 --- a/crates/typst-library/src/model/bibliography.rs +++ b/crates/typst-library/src/model/bibliography.rs @@ -592,7 +592,7 @@ impl Works { /// Context for generating the bibliography. struct Generator<'a> { - /// The routines that is used to evaluate mathematical material in citations. + /// The routines that are used to evaluate mathematical material in citations. routines: &'a Routines, /// The world that is used to evaluate mathematical material in citations. world: Tracked<'a, dyn World + 'a>, @@ -609,7 +609,7 @@ struct Generator<'a> { /// Details about a group of merged citations. All citations are put into groups /// of adjacent ones (e.g., `@foo @bar` will merge into a group of length two). -/// Even single citations will be put into groups of length ones. +/// Even single citations will be put into groups of length one. struct GroupInfo { /// The group's location. location: Location, From d3caedd813b1ca4379a71eb1b4aa636096d53a04 Mon Sep 17 00:00:00 2001 From: Connor K Date: Wed, 25 Jun 2025 12:59:19 -0400 Subject: [PATCH 185/558] Fix typos in page-setup.md (#6499) --- docs/guides/page-setup.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/guides/page-setup.md b/docs/guides/page-setup.md index 36ed0fa234..1682c1220a 100644 --- a/docs/guides/page-setup.md +++ b/docs/guides/page-setup.md @@ -206,7 +206,6 @@ label exists on the current page: ```typ >>> #set page("a5", margin: (x: 2.5cm, y: 3cm)) #set page(header: context { - let page-counter = let matches = query() let current = counter(page).get() let has-table = matches.any(m => @@ -218,7 +217,7 @@ label exists on the current page: #h(1fr) National Academy of Sciences ] -})) +}) #lorem(100) #pagebreak() From 35809387f88483bfa3d0978cfc3303eba0de632b Mon Sep 17 00:00:00 2001 From: Laurenz Date: Thu, 26 Jun 2025 10:06:22 +0200 Subject: [PATCH 186/558] Support `in` operator on strings and modules (#6498) --- .../typst-library/src/foundations/module.rs | 19 +++++++++++++++---- crates/typst-library/src/foundations/ops.rs | 1 + docs/reference/groups.yml | 6 +----- tests/suite/scripting/ops.typ | 2 ++ 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/crates/typst-library/src/foundations/module.rs b/crates/typst-library/src/foundations/module.rs index 55d8bab635..14eefca39d 100644 --- a/crates/typst-library/src/foundations/module.rs +++ b/crates/typst-library/src/foundations/module.rs @@ -19,11 +19,8 @@ use crate::foundations::{repr, ty, Content, Scope, Value}; /// /// You can access definitions from the module using [field access /// notation]($scripting/#fields) and interact with it using the [import and -/// include syntaxes]($scripting/#modules). Alternatively, it is possible to -/// convert a module to a dictionary, and therefore access its contents -/// dynamically, using the [dictionary constructor]($dictionary/#constructor). +/// include syntaxes]($scripting/#modules). /// -/// # Example /// ```example /// <<< #import "utils.typ" /// <<< #utils.add(2, 5) @@ -34,6 +31,20 @@ use crate::foundations::{repr, ty, Content, Scope, Value}; /// >>> /// >>> #(-3) /// ``` +/// +/// You can check whether a definition is present in a module using the `{in}` +/// operator, with a string on the left-hand side. This can be useful to +/// [conditionally access]($category/foundations/std/#conditional-access) +/// definitions in a module. +/// +/// ```example +/// #("table" in std) \ +/// #("nope" in std) +/// ``` +/// +/// Alternatively, it is possible to convert a module to a dictionary, and +/// therefore access its contents dynamically, using the [dictionary +/// constructor]($dictionary/#constructor). #[ty(cast)] #[derive(Clone, Hash)] #[allow(clippy::derived_hash_with_manual_eq)] diff --git a/crates/typst-library/src/foundations/ops.rs b/crates/typst-library/src/foundations/ops.rs index 6c2408446e..3c6a5e6cfb 100644 --- a/crates/typst-library/src/foundations/ops.rs +++ b/crates/typst-library/src/foundations/ops.rs @@ -558,6 +558,7 @@ pub fn contains(lhs: &Value, rhs: &Value) -> Option { (Str(a), Str(b)) => Some(b.as_str().contains(a.as_str())), (Dyn(a), Str(b)) => a.downcast::().map(|regex| regex.is_match(b)), (Str(a), Dict(b)) => Some(b.contains(a)), + (Str(a), Module(b)) => Some(b.scope().get(a).is_some()), (a, Array(b)) => Some(b.contains(a.clone())), _ => Option::None, diff --git a/docs/reference/groups.yml b/docs/reference/groups.yml index c7e3d99644..e01d99dc42 100644 --- a/docs/reference/groups.yml +++ b/docs/reference/groups.yml @@ -181,11 +181,7 @@ [`sys.version`]($category/foundations/sys) can also be very useful. ```typ - #let tiling = if "tiling" in dictionary(std) { - tiling - } else { - pattern - } + #let tiling = if "tiling" in std { tiling } else { pattern } ... ``` diff --git a/tests/suite/scripting/ops.typ b/tests/suite/scripting/ops.typ index d17c0117f6..561682f051 100644 --- a/tests/suite/scripting/ops.typ +++ b/tests/suite/scripting/ops.typ @@ -264,6 +264,8 @@ #test("Hey" not in "abheyCd", true) #test("a" not /* fun comment? */ in "abc", false) +#test("sys" in std, true) +#test("system" in std, false) --- ops-not-trailing --- // Error: 10 expected keyword `in` From 6a1d6c08e2d6e4c184c6d177e67796b23ccbe4c7 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Thu, 26 Jun 2025 10:07:41 +0200 Subject: [PATCH 187/558] Consistent sizing for `html.frame` (#6505) --- crates/typst-html/src/encode.rs | 15 ++++++++++----- crates/typst-html/src/lib.rs | 7 +++++-- crates/typst-library/src/html/dom.rs | 17 ++++++++++++++--- .../src/introspection/introspector.rs | 2 +- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/crates/typst-html/src/encode.rs b/crates/typst-html/src/encode.rs index 9c79383602..84860dbe9e 100644 --- a/crates/typst-html/src/encode.rs +++ b/crates/typst-html/src/encode.rs @@ -3,9 +3,8 @@ use std::fmt::Write; use typst_library::diag::{bail, At, SourceResult, StrResult}; use typst_library::foundations::Repr; use typst_library::html::{ - attr, charsets, tag, HtmlDocument, HtmlElement, HtmlNode, HtmlTag, + attr, charsets, tag, HtmlDocument, HtmlElement, HtmlFrame, HtmlNode, HtmlTag, }; -use typst_library::layout::Frame; use typst_syntax::Span; /// Encodes an HTML document into a string. @@ -304,9 +303,15 @@ fn write_escape(w: &mut Writer, c: char) -> StrResult<()> { } /// Encode a laid out frame into the writer. -fn write_frame(w: &mut Writer, frame: &Frame) { +fn write_frame(w: &mut Writer, frame: &HtmlFrame) { // FIXME: This string replacement is obviously a hack. - let svg = typst_svg::svg_frame(frame) - .replace(" Self::intern(&v)?, } +/// Layouted content that will be embedded into HTML as an SVG. +#[derive(Debug, Clone, Hash)] +pub struct HtmlFrame { + /// The frame that will be displayed as an SVG. + pub inner: Frame, + /// The text size where the frame was defined. This is used to size the + /// frame with em units to make text in and outside of the frame sized + /// consistently. + pub text_size: Abs, +} + /// Defines syntactical properties of HTML tags, attributes, and text. pub mod charsets { /// Check whether a character is in a tag name. diff --git a/crates/typst-library/src/introspection/introspector.rs b/crates/typst-library/src/introspection/introspector.rs index 9751dfcb81..d2ad0525b0 100644 --- a/crates/typst-library/src/introspection/introspector.rs +++ b/crates/typst-library/src/introspection/introspector.rs @@ -446,7 +446,7 @@ impl IntrospectorBuilder { HtmlNode::Element(elem) => self.discover_in_html(sink, &elem.children), HtmlNode::Frame(frame) => self.discover_in_frame( sink, - frame, + &frame.inner, NonZeroUsize::ONE, Transform::identity(), ), From 04fd0acacab8cf2e82268da9c18ef4bcf37507dc Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Thu, 26 Jun 2025 09:24:21 +0100 Subject: [PATCH 188/558] Allow deprecating symbol variants (#6441) --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/typst-ide/src/complete.rs | 2 +- .../typst-library/src/foundations/symbol.rs | 59 ++++++++++++------- crates/typst-library/src/foundations/value.rs | 4 +- docs/src/lib.rs | 14 ++--- tests/suite/math/attach.typ | 6 +- 7 files changed, 51 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3ea423f5f6..91ff484320 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -413,7 +413,7 @@ dependencies = [ [[package]] name = "codex" version = "0.1.1" -source = "git+https://github.com/typst/codex?rev=56eb217#56eb2172fc0670f4c1c8b79a63d11f9354e5babe" +source = "git+https://github.com/typst/codex?rev=a5428cb#a5428cb9c81a41354d44b44dbd5a16a710bbd928" [[package]] name = "color-print" diff --git a/Cargo.toml b/Cargo.toml index 3cfb720084..76d83995f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +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 = "56eb217" } +codex = { git = "https://github.com/typst/codex", rev = "a5428cb" } color-print = "0.3.6" comemo = "0.4" csv = "1" diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index 536423318b..bc5b3e10e9 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -448,7 +448,7 @@ fn field_access_completions( match value { Value::Symbol(symbol) => { for modifier in symbol.modifiers() { - if let Ok(modified) = symbol.clone().modified(modifier) { + if let Ok(modified) = symbol.clone().modified((), modifier) { ctx.completions.push(Completion { kind: CompletionKind::Symbol(modified.get()), label: modifier.into(), diff --git a/crates/typst-library/src/foundations/symbol.rs b/crates/typst-library/src/foundations/symbol.rs index 0f503edd06..f57bb0c2a6 100644 --- a/crates/typst-library/src/foundations/symbol.rs +++ b/crates/typst-library/src/foundations/symbol.rs @@ -8,7 +8,7 @@ use serde::{Serialize, Serializer}; use typst_syntax::{is_ident, Span, Spanned}; use typst_utils::hash128; -use crate::diag::{bail, SourceResult, StrResult}; +use crate::diag::{bail, DeprecationSink, SourceResult, StrResult}; use crate::foundations::{ cast, elem, func, scope, ty, Array, Content, Func, NativeElement, NativeFunc, Packed, PlainText, Repr as _, @@ -54,18 +54,22 @@ enum Repr { /// A native symbol that has no named variant. Single(char), /// A native symbol with multiple named variants. - Complex(&'static [(ModifierSet<&'static str>, char)]), + Complex(&'static [Variant<&'static str>]), /// A symbol with multiple named variants, where some modifiers may have /// been applied. Also used for symbols defined at runtime by the user with /// no modifier applied. Modified(Arc<(List, ModifierSet)>), } +/// A symbol variant, consisting of a set of modifiers, a character, and an +/// optional deprecation message. +type Variant = (ModifierSet, char, Option); + /// A collection of symbols. #[derive(Clone, Eq, PartialEq, Hash)] enum List { - Static(&'static [(ModifierSet<&'static str>, char)]), - Runtime(Box<[(ModifierSet, char)]>), + Static(&'static [Variant<&'static str>]), + Runtime(Box<[Variant]>), } impl Symbol { @@ -76,14 +80,14 @@ impl Symbol { /// Create a symbol with a static variant list. #[track_caller] - pub const fn list(list: &'static [(ModifierSet<&'static str>, char)]) -> Self { + pub const fn list(list: &'static [Variant<&'static str>]) -> Self { debug_assert!(!list.is_empty()); Self(Repr::Complex(list)) } /// Create a symbol with a runtime variant list. #[track_caller] - pub fn runtime(list: Box<[(ModifierSet, char)]>) -> Self { + pub fn runtime(list: Box<[Variant]>) -> Self { debug_assert!(!list.is_empty()); Self(Repr::Modified(Arc::new((List::Runtime(list), ModifierSet::default())))) } @@ -93,9 +97,11 @@ impl Symbol { match &self.0 { Repr::Single(c) => *c, Repr::Complex(_) => ModifierSet::<&'static str>::default() - .best_match_in(self.variants()) + .best_match_in(self.variants().map(|(m, c, _)| (m, c))) .unwrap(), - Repr::Modified(arc) => arc.1.best_match_in(self.variants()).unwrap(), + Repr::Modified(arc) => { + arc.1.best_match_in(self.variants().map(|(m, c, _)| (m, c))).unwrap() + } } } @@ -128,7 +134,11 @@ impl Symbol { } /// Apply a modifier to the symbol. - pub fn modified(mut self, modifier: &str) -> StrResult { + pub fn modified( + mut self, + sink: impl DeprecationSink, + modifier: &str, + ) -> StrResult { if let Repr::Complex(list) = self.0 { self.0 = Repr::Modified(Arc::new((List::Static(list), ModifierSet::default()))); @@ -137,7 +147,12 @@ impl Symbol { if let Repr::Modified(arc) = &mut self.0 { let (list, modifiers) = Arc::make_mut(arc); modifiers.insert_raw(modifier); - if modifiers.best_match_in(list.variants()).is_some() { + if let Some(deprecation) = + modifiers.best_match_in(list.variants().map(|(m, _, d)| (m, d))) + { + if let Some(message) = deprecation { + sink.emit(message) + } return Ok(self); } } @@ -146,7 +161,7 @@ impl Symbol { } /// The characters that are covered by this symbol. - pub fn variants(&self) -> impl Iterator, char)> { + pub fn variants(&self) -> impl Iterator> { match &self.0 { Repr::Single(c) => Variants::Single(Some(*c).into_iter()), Repr::Complex(list) => Variants::Static(list.iter()), @@ -161,7 +176,7 @@ impl Symbol { _ => ModifierSet::default(), }; self.variants() - .flat_map(|(m, _)| m) + .flat_map(|(m, _, _)| m) .filter(|modifier| !modifier.is_empty() && !modifiers.contains(modifier)) .collect::>() .into_iter() @@ -256,7 +271,7 @@ impl Symbol { let list = variants .into_iter() - .map(|s| (ModifierSet::from_raw_dotted(s.v.0), s.v.1)) + .map(|s| (ModifierSet::from_raw_dotted(s.v.0), s.v.1, None)) .collect(); Ok(Symbol::runtime(list)) } @@ -316,17 +331,17 @@ impl crate::foundations::Repr for Symbol { } fn repr_variants<'a>( - variants: impl Iterator, char)>, + variants: impl Iterator>, applied_modifiers: ModifierSet<&str>, ) -> String { crate::foundations::repr::pretty_array_like( &variants - .filter(|(modifiers, _)| { + .filter(|(modifiers, _, _)| { // Only keep variants that can still be accessed, i.e., variants // that contain all applied modifiers. applied_modifiers.iter().all(|am| modifiers.contains(am)) }) - .map(|(modifiers, c)| { + .map(|(modifiers, c, _)| { let trimmed_modifiers = modifiers.into_iter().filter(|&m| !applied_modifiers.contains(m)); if trimmed_modifiers.clone().all(|m| m.is_empty()) { @@ -379,18 +394,20 @@ cast! { /// Iterator over variants. enum Variants<'a> { Single(std::option::IntoIter), - Static(std::slice::Iter<'static, (ModifierSet<&'static str>, char)>), - Runtime(std::slice::Iter<'a, (ModifierSet, char)>), + Static(std::slice::Iter<'static, Variant<&'static str>>), + Runtime(std::slice::Iter<'a, Variant>), } impl<'a> Iterator for Variants<'a> { - type Item = (ModifierSet<&'a str>, char); + type Item = Variant<&'a str>; fn next(&mut self) -> Option { match self { - Self::Single(iter) => Some((ModifierSet::default(), iter.next()?)), + Self::Single(iter) => Some((ModifierSet::default(), iter.next()?, None)), Self::Static(list) => list.next().copied(), - Self::Runtime(list) => list.next().map(|(m, c)| (m.as_deref(), *c)), + Self::Runtime(list) => { + list.next().map(|(m, c, d)| (m.as_deref(), *c, d.as_deref())) + } } } } diff --git a/crates/typst-library/src/foundations/value.rs b/crates/typst-library/src/foundations/value.rs index 854c2486ea..4bcf2d4e3b 100644 --- a/crates/typst-library/src/foundations/value.rs +++ b/crates/typst-library/src/foundations/value.rs @@ -157,7 +157,9 @@ impl Value { /// Try to access a field on the value. pub fn field(&self, field: &str, sink: impl DeprecationSink) -> StrResult { match self { - Self::Symbol(symbol) => symbol.clone().modified(field).map(Self::Symbol), + Self::Symbol(symbol) => { + symbol.clone().modified(sink, field).map(Self::Symbol) + } Self::Version(version) => version.component(field).map(Self::Int), Self::Dict(dict) => dict.get(field).cloned(), Self::Content(content) => content.field_by_name(field), diff --git a/docs/src/lib.rs b/docs/src/lib.rs index 9bd21c2e81..dc6b62c729 100644 --- a/docs/src/lib.rs +++ b/docs/src/lib.rs @@ -720,18 +720,12 @@ fn symbols_model(resolver: &dyn Resolver, group: &GroupData) -> SymbolsModel { } }; - for (variant, c) in symbol.variants() { + for (variant, c, deprecation) in symbol.variants() { let shorthand = |list: &[(&'static str, char)]| { list.iter().copied().find(|&(_, x)| x == c).map(|(s, _)| s) }; let name = complete(variant); - let deprecation = match name.as_str() { - "integral.sect" => { - Some("`integral.sect` is deprecated, use `integral.inter` instead") - } - _ => binding.deprecation(), - }; list.push(SymbolModel { name, @@ -742,10 +736,10 @@ fn symbols_model(resolver: &dyn Resolver, group: &GroupData) -> SymbolsModel { accent: typst::math::Accent::combine(c).is_some(), alternates: symbol .variants() - .filter(|(other, _)| other != &variant) - .map(|(other, _)| complete(other)) + .filter(|(other, _, _)| other != &variant) + .map(|(other, _, _)| complete(other)) .collect(), - deprecation, + deprecation: deprecation.or_else(|| binding.deprecation()), }); } } diff --git a/tests/suite/math/attach.typ b/tests/suite/math/attach.typ index cedc3a4aba..9790184783 100644 --- a/tests/suite/math/attach.typ +++ b/tests/suite/math/attach.typ @@ -121,8 +121,8 @@ $a scripts(=)^"def" b quad a scripts(lt.eq)_"really" b quad a scripts(arrow.r.lo --- math-attach-integral --- // Test default of scripts attachments on integrals at display size. -$ integral.sect_a^b quad \u{2a1b}_a^b quad limits(\u{2a1b})_a^b $ -$integral.sect_a^b quad \u{2a1b}_a^b quad limits(\u{2a1b})_a^b$ +$ integral.inter_a^b quad \u{2a1b}_a^b quad limits(\u{2a1b})_a^b $ +$integral.inter_a^b quad \u{2a1b}_a^b quad limits(\u{2a1b})_a^b$ --- math-attach-large-operator --- // Test default of limit attachments on large operators at display size only. @@ -179,7 +179,7 @@ $ a0 + a1 + a0_2 \ #{ let var = $x^1$ for i in range(24) { - var = $var$ + var = $var$ } $var_2$ } From 5dd5771df03a666fe17930b0b071b06266e5937f Mon Sep 17 00:00:00 2001 From: "Said A." <47973576+Daaiid@users.noreply.github.com> Date: Thu, 26 Jun 2025 11:18:51 +0200 Subject: [PATCH 189/558] Disallow empty labels and references (#5776) (#6332) Co-authored-by: Laurenz --- crates/typst-eval/src/markup.rs | 7 ++++-- crates/typst-ide/src/definition.rs | 3 ++- crates/typst-library/src/foundations/label.rs | 23 ++++++++++++------ .../typst-library/src/model/bibliography.rs | 6 ++++- crates/typst-syntax/src/ast.rs | 2 ++ crates/typst-syntax/src/lexer.rs | 2 +- tests/ref/ref-to-empty-label-not-possible.png | Bin 0 -> 182 bytes tests/suite/foundations/label.typ | 4 +++ tests/suite/model/bibliography.typ | 8 ++++++ tests/suite/model/ref.typ | 11 +++++++++ 10 files changed, 54 insertions(+), 12 deletions(-) create mode 100644 tests/ref/ref-to-empty-label-not-possible.png diff --git a/crates/typst-eval/src/markup.rs b/crates/typst-eval/src/markup.rs index 5beefa9122..9118ded56a 100644 --- a/crates/typst-eval/src/markup.rs +++ b/crates/typst-eval/src/markup.rs @@ -205,7 +205,9 @@ impl Eval for ast::Label<'_> { type Output = Value; fn eval(self, _: &mut Vm) -> SourceResult { - Ok(Value::Label(Label::new(PicoStr::intern(self.get())))) + Ok(Value::Label( + Label::new(PicoStr::intern(self.get())).expect("unexpected empty label"), + )) } } @@ -213,7 +215,8 @@ impl Eval for ast::Ref<'_> { type Output = Content; fn eval(self, vm: &mut Vm) -> SourceResult { - let target = Label::new(PicoStr::intern(self.target())); + let target = Label::new(PicoStr::intern(self.target())) + .expect("unexpected empty reference"); 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-ide/src/definition.rs b/crates/typst-ide/src/definition.rs index 69d702b3b0..ae1ba287be 100644 --- a/crates/typst-ide/src/definition.rs +++ b/crates/typst-ide/src/definition.rs @@ -72,7 +72,8 @@ pub fn definition( // Try to jump to the referenced content. DerefTarget::Ref(node) => { - let label = Label::new(PicoStr::intern(node.cast::()?.target())); + let label = Label::new(PicoStr::intern(node.cast::()?.target())) + .expect("unexpected empty reference"); let selector = Selector::Label(label); let elem = document?.introspector.query_first(&selector)?; return Some(Definition::Span(elem.span())); diff --git a/crates/typst-library/src/foundations/label.rs b/crates/typst-library/src/foundations/label.rs index 3b9b010c5f..b1ac58bf29 100644 --- a/crates/typst-library/src/foundations/label.rs +++ b/crates/typst-library/src/foundations/label.rs @@ -1,7 +1,8 @@ use ecow::{eco_format, EcoString}; use typst_utils::{PicoStr, ResolvedPicoStr}; -use crate::foundations::{func, scope, ty, Repr, Str}; +use crate::diag::StrResult; +use crate::foundations::{bail, func, scope, ty, Repr, Str}; /// A label for an element. /// @@ -27,7 +28,8 @@ use crate::foundations::{func, scope, ty, Repr, Str}; /// # Syntax /// This function also has dedicated syntax: You can create a label by enclosing /// its name in angle brackets. This works both in markup and code. A label's -/// name can contain letters, numbers, `_`, `-`, `:`, and `.`. +/// name can contain letters, numbers, `_`, `-`, `:`, and `.`. A label cannot +/// be empty. /// /// Note that there is a syntactical difference when using the dedicated syntax /// for this function. In the code below, the `[
]` terminates the heading and @@ -50,8 +52,11 @@ pub struct Label(PicoStr); impl Label { /// Creates a label from an interned string. - pub fn new(name: PicoStr) -> Self { - Self(name) + /// + /// Returns `None` if the given string is empty. + pub fn new(name: PicoStr) -> Option { + const EMPTY: PicoStr = PicoStr::constant(""); + (name != EMPTY).then_some(Self(name)) } /// Resolves the label to a string. @@ -70,10 +75,14 @@ impl Label { /// Creates a label from a string. #[func(constructor)] pub fn construct( - /// The name of the label. + /// The name of the label. Must not be empty. name: Str, - ) -> Label { - Self(PicoStr::intern(name.as_str())) + ) -> StrResult

${title}

See also [1], [2, p. 22], [1, p. 4], and [2, p. 5].

+
+

Bibliography

+
    +
  • [1] P. T. Leeson, “The Pirate Organization.”
  • +
  • [2] B. Aldrin, “An Insight into Bibliographical Distress.”
  • +
+
+ + diff --git a/tests/ref/html/bibliography-csl-display.html b/tests/ref/html/bibliography-csl-display.html new file mode 100644 index 0000000000..0f158cdba8 --- /dev/null +++ b/tests/ref/html/bibliography-csl-display.html @@ -0,0 +1,18 @@ + + + + + + + +
+
    +
  • +

    |1| Title 1

    +
    by Author 1
    +
    (Edition 2021)
    +
  • +
+
+ + diff --git a/tests/ref/html/bibliography-custom-title.html b/tests/ref/html/bibliography-custom-title.html new file mode 100644 index 0000000000..faab6a5c06 --- /dev/null +++ b/tests/ref/html/bibliography-custom-title.html @@ -0,0 +1,16 @@ + + + + + + + +

[1]

+
+

My References

+
    +
  • [1] B. Aldrin, “An Insight into Bibliographical Distress.”
  • +
+
+ + diff --git a/tests/ref/html/bibliography-multiple-files.html b/tests/ref/html/bibliography-multiple-files.html new file mode 100644 index 0000000000..078bdf526d --- /dev/null +++ b/tests/ref/html/bibliography-multiple-files.html @@ -0,0 +1,18 @@ + + + + + + + +

1. Multiple Bibs

+

Now we have multiple bibliographies containing [1], [2]

+
+

2. Bibliography

+
    +
  • [1] R. Hock, “Glacier melt: a review of processes and their modelling,” Progress in Physical Geography: Earth and Environment, vol. 29, no. 3, pp. 362–391, 2005, doi: 10.1191/0309133305pp453ra.
  • +
  • [2] S. Keshav, “How to read a paper,” ACM SIGCOMM Computer Communication Review, vol. 37, no. 3, pp. 83–84, 2007.
  • +
+
+ + diff --git a/tests/ref/html/bibliography-no-title.html b/tests/ref/html/bibliography-no-title.html new file mode 100644 index 0000000000..b672b2c9b1 --- /dev/null +++ b/tests/ref/html/bibliography-no-title.html @@ -0,0 +1,15 @@ + + + + + + + +

[1]

+
+
    +
  • [1] B. Aldrin, “An Insight into Bibliographical Distress.”
  • +
+
+ + diff --git a/tests/ref/html/cite-form.html b/tests/ref/html/cite-form.html new file mode 100644 index 0000000000..28c2340547 --- /dev/null +++ b/tests/ref/html/cite-form.html @@ -0,0 +1,18 @@ + + + + + + + +

Nothing:

+

Astley & Morris (2020) say stuff.

+
+

Bibliography

+
    +
  • Astley, R., & Morris, L. (2020). At-scale impact of the Net Wok: A culinarically holistic investigation of distributed dumplings. Armenian Journal of Proceedings, 61, 192–219.
  • +
  • Leeson, P. T. The Pirate Organization.
  • +
+
+ + diff --git a/tests/ref/html/cite-group.html b/tests/ref/html/cite-group.html new file mode 100644 index 0000000000..81736ead62 --- /dev/null +++ b/tests/ref/html/cite-group.html @@ -0,0 +1,20 @@ + + + + + + + +

A[1,2]B
A[1,2] B
A[1,2] B
A[1,2]. B

+

A [1,2]B
A [1,2], B
A [1,2], B
A [1,2]. B

+

A[13]B.
A [13] B.
A [13], B.

+
+

Bibliography

+
    +
  • [1] R. Astley and L. Morris, At-scale impact of the Net Wok: A culinarically holistic investigation of distributed dumplings, Armenian Journal of Proceedings 61, 192 (2020).
  • +
  • [2] P. T. Leeson, The Pirate Organization, (n.d.).
  • +
  • [3] P. T. Leeson, The Quark Organization, (n.d.).
  • +
+
+ + diff --git a/tests/suite/model/bibliography.typ b/tests/suite/model/bibliography.typ index c3e77403db..1e112e7cfc 100644 --- a/tests/suite/model/bibliography.typ +++ b/tests/suite/model/bibliography.typ @@ -1,12 +1,20 @@ // Test citations and bibliographies. ---- bibliography-basic --- -#set page(width: 200pt) +--- bibliography-basic render html --- +#show: it => context { set page(width: 200pt) if target() == "paged"; it } = Details See also @arrgh #cite(, supplement: [p.~22]), @arrgh[p.~4], and @distress[p.~5]. #bibliography("/assets/bib/works.bib") +--- bibliography-no-title render html --- +@distress +#bibliography("/assets/bib/works.bib", title: none) + +--- bibliography-custom-title render html --- +@distress +#bibliography("/assets/bib/works.bib", title: [My References]) + --- bibliography-before-content --- // Test unconventional order. #set page(width: 200pt) @@ -22,8 +30,9 @@ the net-work is a creature of its own. This is close to piratery! @arrgh And quark! @quark ---- bibliography-multiple-files --- -#set page(width: 200pt) +--- bibliography-multiple-files render html --- +#show: it => context { set page(width: 200pt) if target() == "paged"; it } + #set heading(numbering: "1.") #show bibliography: set heading(numbering: "1.") @@ -101,7 +110,7 @@ Now we have multiple bibliographies containing @glacier-melt @keshav2007read // Warning: 47-66 style "chicago-fullnotes" has been deprecated in favor of "chicago-notes" #bibliography("/assets/bib/works.bib", style: "chicago-fullnotes", title: none) ---- bibliography-csl-display --- +--- bibliography-csl-display render html --- // Test a combination of CSL `display` attributes. Most of the display // attributes are barely used by any styles, so we have a custom style here. diff --git a/tests/suite/model/cite.typ b/tests/suite/model/cite.typ index 59da6bd03d..cf71ca5c77 100644 --- a/tests/suite/model/cite.typ +++ b/tests/suite/model/cite.typ @@ -5,8 +5,8 @@ And again: @netwok #pagebreak() #bibliography("/assets/bib/works.bib", style: "chicago-shortened-notes") ---- cite-form --- -#set page(width: 200pt) +--- cite-form render html --- +#show: it => context { set page(width: 200pt) if target() == "paged"; it } Nothing: #cite(, form: none) @@ -14,7 +14,7 @@ Nothing: #cite(, form: none) #bibliography("/assets/bib/works.bib", style: "apa") ---- cite-group --- +--- cite-group render html --- A#[@netwok@arrgh]B \ A@netwok@arrgh B \ A@netwok @arrgh B \ @@ -29,7 +29,7 @@ A#[@netwok @arrgh @quark]B. \ A @netwok @arrgh @quark B. \ A @netwok @arrgh @quark, B. -#set text(0pt) +#show bibliography: it => if target() == "html" { it } #bibliography("/assets/bib/works.bib", style: "american-physics-society") --- cite-grouping-and-ordering --- From e17a7675f96218a4ca21111e6ef1d231cbe753c9 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Wed, 1 Oct 2025 10:55:33 +0200 Subject: [PATCH 387/558] Revise DOM for footnotes in HTML (#6986) --- crates/typst-html/src/rules.rs | 46 ++++++++----------- crates/typst-layout/src/rules.rs | 6 +-- crates/typst-library/src/model/footnote.rs | 12 +++-- tests/ref/html/footnote-basic.html | 8 ++-- .../footnote-container-set-rule-html.html | 8 ++-- ...footnote-container-show-set-rule-html.html | 8 ++-- tests/ref/html/footnote-entry-html.html | 12 ++--- tests/ref/html/footnote-nested.html | 28 ++++------- tests/ref/html/footnote-space-collapsing.html | 12 ++--- 9 files changed, 54 insertions(+), 86 deletions(-) diff --git a/crates/typst-html/src/rules.rs b/crates/typst-html/src/rules.rs index 9e44a19389..d4b8e68e1c 100644 --- a/crates/typst-html/src/rules.rs +++ b/crates/typst-html/src/rules.rs @@ -335,11 +335,10 @@ const FOOTNOTE_CONTAINER_RULE: ShowFn = |_, engine, _| { let loc = note.location().unwrap(); let span = note.span(); HtmlElem::new(tag::li) - .with_body(Some( - FootnoteEntry::new(note).pack().spanned(span).located(loc.variant(1)), - )) + .with_body(Some(FootnoteEntry::new(note).pack().spanned(span))) .with_parent(loc) .pack() + .located(loc.variant(1)) .spanned(span) }); @@ -352,36 +351,27 @@ const FOOTNOTE_CONTAINER_RULE: ShowFn = |_, engine, _| { .pack(); // The user may want to style the whole footnote element so we wrap it in an - // additional selectable container. `aside` has the right semantics as a - // container for auxiliary page content. There is no ARIA role for - // footnotes, so we use a class instead. (There is `doc-endnotes`, but has - // footnotes and endnotes have somewhat different semantics.) - Ok(HtmlElem::new(tag::aside) - .with_attr(attr::class, "footnotes") + // additional selectable container. This is also how it's done in the ARIA + // spec (although there, the section also contains an additional heading). + Ok(HtmlElem::new(tag::section) + .with_attr(attr::role, "doc-endnotes") .with_body(Some(list)) .pack()) }; const FOOTNOTE_ENTRY_RULE: ShowFn = |elem, engine, styles| { - let span = elem.span(); - let (dest, num, body) = elem.realize(engine, styles)?; - let sup = SuperElem::new(num).pack().spanned(span); - - // We create a link back to the first footnote reference. - let link = LinkElem::new(dest.into(), sup) - .pack() - .spanned(span) - .styled(HtmlElem::role.set(Some("doc-backlink".into()))); - - // We want to use the Digital Publishing ARIA role `doc-footnote` and the - // fallback role `note` for each individual footnote. Because the enclosing - // `li`, as a child of an `ol`, must have the implicit `listitem` role, we - // need an additional container. We chose a `div` instead of a `span` to - // allow for block-level content in the footnote. - Ok(HtmlElem::new(tag::div) - .with_attr(attr::role, "doc-footnote note") - .with_body(Some(link + body)) - .pack()) + let (prefix, body) = elem.realize(engine, styles)?; + + // The prefix is a link back to the first footnote reference, so + // `doc-backlink` is the appropriate ARIA role. + let backlink = prefix.styled(HtmlElem::role.set(Some("doc-backlink".into()))); + + // We do not use the ARIA role `doc-footnote` because it "is only for + // representing individual notes that occur within the body of a work" (see + // ). Our footnotes more + // appropriately modelled as ARIA endnotes. This is also in line with how + // Pandoc handles footnotes. + Ok(backlink + body) }; const OUTLINE_RULE: ShowFn = |elem, engine, styles| { diff --git a/crates/typst-layout/src/rules.rs b/crates/typst-layout/src/rules.rs index cb0dfa0749..d7c2752595 100644 --- a/crates/typst-layout/src/rules.rs +++ b/crates/typst-layout/src/rules.rs @@ -386,13 +386,11 @@ const FOOTNOTE_RULE: ShowFn = |elem, engine, styles| { }; const FOOTNOTE_ENTRY_RULE: ShowFn = |elem, engine, styles| { - let span = elem.span(); let number_gap = Em::new(0.05); - let (dest, num, body) = elem.realize(engine, styles)?; - let sup = SuperElem::new(num).pack().spanned(span).linked(dest); + let (prefix, body) = elem.realize(engine, styles)?; Ok(Content::sequence([ HElem::new(elem.indent.get(styles).into()).pack(), - sup, + prefix, HElem::new(number_gap.into()).with_weak(true).pack(), body, ])) diff --git a/crates/typst-library/src/model/footnote.rs b/crates/typst-library/src/model/footnote.rs index 5a32baef4b..a18d8b6787 100644 --- a/crates/typst-library/src/model/footnote.rs +++ b/crates/typst-library/src/model/footnote.rs @@ -11,8 +11,8 @@ use crate::foundations::{ }; use crate::introspection::{Count, Counter, CounterUpdate, Locatable, Location}; use crate::layout::{Abs, Em, Length, Ratio}; -use crate::model::{Destination, Numbering, NumberingPattern, ParElem}; -use crate::text::{TextElem, TextSize}; +use crate::model::{Destination, DirectLinkElem, Numbering, NumberingPattern, ParElem}; +use crate::text::{SuperElem, TextElem, TextSize}; use crate::visualize::{LineElem, Stroke}; /// A footnote. @@ -280,7 +280,8 @@ impl Packed { &self, engine: &mut Engine, styles: StyleChain, - ) -> SourceResult<(Destination, Content, Content)> { + ) -> SourceResult<(Content, Content)> { + let span = self.span(); let default = StyleChain::default(); let numbering = self.note.numbering.get_ref(default); let counter = Counter::of(FootnoteElem::ELEM); @@ -292,8 +293,11 @@ impl Packed { }; let num = counter.display_at_loc(engine, loc, styles, numbering)?; + let sup = SuperElem::new(num).pack().spanned(span); + let prefix = DirectLinkElem::new(loc, sup).pack().spanned(span); let body = self.note.body_content().unwrap().clone(); - Ok((Destination::Location(loc), num, body)) + + Ok((prefix, body)) } } diff --git a/tests/ref/html/footnote-basic.html b/tests/ref/html/footnote-basic.html index 602f9ee297..5da67087f5 100644 --- a/tests/ref/html/footnote-basic.html +++ b/tests/ref/html/footnote-basic.html @@ -6,12 +6,10 @@

1

- + diff --git a/tests/ref/html/footnote-container-set-rule-html.html b/tests/ref/html/footnote-container-set-rule-html.html index 6e98234e1b..f45554cf7f 100644 --- a/tests/ref/html/footnote-container-set-rule-html.html +++ b/tests/ref/html/footnote-container-set-rule-html.html @@ -6,12 +6,10 @@

An [A]1

- + diff --git a/tests/ref/html/footnote-container-show-set-rule-html.html b/tests/ref/html/footnote-container-show-set-rule-html.html index b70a58d22b..493ec1db83 100644 --- a/tests/ref/html/footnote-container-show-set-rule-html.html +++ b/tests/ref/html/footnote-container-show-set-rule-html.html @@ -6,12 +6,10 @@

An [A]1

- + diff --git a/tests/ref/html/footnote-entry-html.html b/tests/ref/html/footnote-entry-html.html index 0c0ff15f05..04f5e9415f 100644 --- a/tests/ref/html/footnote-entry-html.html +++ b/tests/ref/html/footnote-entry-html.html @@ -6,14 +6,12 @@

A1 B2 C3

- + diff --git a/tests/ref/html/footnote-nested.html b/tests/ref/html/footnote-nested.html index 676005b9a6..bff5a7e131 100644 --- a/tests/ref/html/footnote-nested.html +++ b/tests/ref/html/footnote-nested.html @@ -6,27 +6,15 @@

First
Second1 Third4
Fourth6

- + diff --git a/tests/ref/html/footnote-space-collapsing.html b/tests/ref/html/footnote-space-collapsing.html index 000a737607..7ab40b6f78 100644 --- a/tests/ref/html/footnote-space-collapsing.html +++ b/tests/ref/html/footnote-space-collapsing.html @@ -6,15 +6,11 @@

A1
A2

- + From d0d1b9e07e3fbaec05d66065997a6f743e347a85 Mon Sep 17 00:00:00 2001 From: "Said A." <47973576+Daaiid@users.noreply.github.com> Date: Wed, 1 Oct 2025 11:07:48 +0200 Subject: [PATCH 388/558] Fix crash when the body of a context expression is a closure (#6975) Co-authored-by: Laurenz --- crates/typst-eval/src/call.rs | 18 ++++++++++----- crates/typst-eval/src/code.rs | 6 ++--- crates/typst-library/src/foundations/func.rs | 23 +++++++++++++++---- tests/ref/context-body-is-closure.png | Bin 0 -> 443 bytes tests/suite/foundations/context.typ | 4 ++++ 5 files changed, 37 insertions(+), 14 deletions(-) create mode 100644 tests/ref/context-body-is-closure.png diff --git a/crates/typst-eval/src/call.rs b/crates/typst-eval/src/call.rs index c4087f6bba..a0a281c527 100644 --- a/crates/typst-eval/src/call.rs +++ b/crates/typst-eval/src/call.rs @@ -7,8 +7,8 @@ use typst_library::diag::{ }; use typst_library::engine::{Engine, Sink, Traced}; use typst_library::foundations::{ - Arg, Args, Binding, Capturer, Closure, Content, Context, Func, NativeElement, Scope, - Scopes, SymbolElem, Value, + Arg, Args, Binding, Capturer, Closure, ClosureNode, Content, Context, Func, + NativeElement, Scope, Scopes, SymbolElem, Value, }; use typst_library::introspection::Introspector; use typst_library::math::LrElem; @@ -154,7 +154,7 @@ impl Eval for ast::Closure<'_> { // Define the closure. let closure = Closure { - node: self.to_untyped().clone(), + node: ClosureNode::Closure(self.to_untyped().clone()), defaults, captured, num_pos_params: self @@ -183,9 +183,15 @@ pub fn eval_closure( context: Tracked, mut args: Args, ) -> SourceResult { - let (name, params, body) = match closure.node.cast::() { - Some(node) => (node.name(), node.params(), node.body()), - None => (None, ast::Params::default(), closure.node.cast().unwrap()), + let (name, params, body) = match closure.node { + ClosureNode::Closure(ref node) => { + let closure = + node.cast::().expect("node to be an `ast::Closure`"); + (closure.name(), closure.params(), closure.body()) + } + ClosureNode::Context(ref node) => { + (None, ast::Params::default(), node.cast().unwrap()) + } }; // Don't leak the scopes from the call site. Instead, we use the scope diff --git a/crates/typst-eval/src/code.rs b/crates/typst-eval/src/code.rs index 80efb3ff65..e5fbff1320 100644 --- a/crates/typst-eval/src/code.rs +++ b/crates/typst-eval/src/code.rs @@ -2,8 +2,8 @@ use ecow::{EcoVec, eco_vec}; use typst_library::diag::{At, SourceResult, bail, error, warning}; use typst_library::engine::Engine; use typst_library::foundations::{ - Array, Capturer, Closure, Content, ContextElem, Dict, Func, NativeElement, Selector, - Str, Value, ops, + Array, Capturer, Closure, ClosureNode, Content, ContextElem, Dict, Func, + NativeElement, Selector, Str, Value, ops, }; use typst_library::introspection::{Counter, State}; use typst_syntax::ast::{self, AstNode}; @@ -356,7 +356,7 @@ impl Eval for ast::Contextual<'_> { // Define the closure. let closure = Closure { - node: self.body().to_untyped().clone(), + node: ClosureNode::Context(self.body().to_untyped().clone()), defaults: vec![], captured, num_pos_params: 0, diff --git a/crates/typst-library/src/foundations/func.rs b/crates/typst-library/src/foundations/func.rs index 90c379f605..dece6b03dd 100644 --- a/crates/typst-library/src/foundations/func.rs +++ b/crates/typst-library/src/foundations/func.rs @@ -577,13 +577,21 @@ pub struct ParamInfo { pub settable: bool, } +/// Distinguishes between variants of closures. +#[derive(Debug, Hash)] +pub enum ClosureNode { + /// A regular closure. Must always be castable to a `ast::Closure`. + Closure(SyntaxNode), + /// Synthetic closure used for `context` expressions. Can be any `ast::Expr` + /// and has no parameters. + Context(SyntaxNode), +} + /// A user-defined closure. #[derive(Debug, Hash)] pub struct Closure { - /// The closure's syntax node. Must be either castable to `ast::Closure` or - /// `ast::Expr`. In the latter case, this is a synthesized closure without - /// any parameters (used by `context` expressions). - pub node: SyntaxNode, + /// The closure's syntax node. + pub node: ClosureNode, /// Default values of named parameters. pub defaults: Vec, /// Captured values from outer scopes. @@ -595,7 +603,12 @@ pub struct Closure { impl Closure { /// The name of the closure. pub fn name(&self) -> Option<&str> { - self.node.cast::()?.name().map(|ident| ident.as_str()) + match self.node { + ClosureNode::Closure(ref node) => { + node.cast::()?.name().map(|ident| ident.as_str()) + } + _ => None, + } } } diff --git a/tests/ref/context-body-is-closure.png b/tests/ref/context-body-is-closure.png new file mode 100644 index 0000000000000000000000000000000000000000..36b0068e378fc278a62b4420e31dab8d6e52ec8d GIT binary patch literal 443 zcmV;s0Yv_ZP)DE9^TEaGlA8PL z?Bsie@Uywz=0j>g(K8VfN9~ z=ZTW$g^uHMef{>+9=7Lqo&E!~FdG^78Wf`uc7t ziV*++0HjGoK~#9!?bJmM!cYK3(TlsZ#oe9aP~6)6Z;3$(`6EUx3CTZ`m%D+7BuUbL zJ70uYuK1PX2~jjAM74%{4jV*~nZ0R6QOcEiqlsJgYeZq)-ju4UC1|&IIUs3%a0! {}) From 776e9285e3f270c7c8586fc7aae82c9a79704286 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Wed, 1 Oct 2025 11:18:10 +0200 Subject: [PATCH 389/558] More helpful error message for footnotes in combination with custom `` or `` (#6992) --- crates/typst-html/src/document.rs | 1 + crates/typst-html/src/rules.rs | 22 +++++++++++++++++++++- tests/suite/layout/flow/footnote.typ | 9 +++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/typst-html/src/document.rs b/crates/typst-html/src/document.rs index 2523c7b515..029a06509f 100644 --- a/crates/typst-html/src/document.rs +++ b/crates/typst-html/src/document.rs @@ -106,6 +106,7 @@ fn html_document_impl( leaves.extend(notes); leaves } else { + FootnoteContainer::unsupported_with_custom_dom(&engine)?; &nodes }; diff --git a/crates/typst-html/src/rules.rs b/crates/typst-html/src/rules.rs index d4b8e68e1c..c4cc008edb 100644 --- a/crates/typst-html/src/rules.rs +++ b/crates/typst-html/src/rules.rs @@ -2,7 +2,8 @@ use std::num::NonZeroUsize; use comemo::Track; use ecow::{EcoVec, eco_format}; -use typst_library::diag::{At, bail, warning}; +use typst_library::diag::{At, SourceResult, bail, error, warning}; +use typst_library::engine::Engine; use typst_library::foundations::{ Content, Context, NativeElement, NativeRuleMap, ShowFn, Smart, StyleChain, Target, }; @@ -321,6 +322,25 @@ impl FootnoteContainer { pub fn shared() -> &'static Content { singleton!(Content, FootnoteContainer::new().pack()) } + + /// Fails with an error if there are footnotes. + pub fn unsupported_with_custom_dom(engine: &Engine) -> SourceResult<()> { + let notes = engine.introspector.query(&FootnoteElem::ELEM.select()); + if notes.is_empty() { + return Ok(()); + } + + Err(notes + .iter() + .map(|note| { + error!( + note.span(), + "footnotes are not currently supported in combination \ + with a custom `` or `` element" + ) + }) + .collect()) + } } const FOOTNOTE_CONTAINER_RULE: ShowFn = |_, engine, _| { diff --git a/tests/suite/layout/flow/footnote.typ b/tests/suite/layout/flow/footnote.typ index b247771384..e5dbc8f4af 100644 --- a/tests/suite/layout/flow/footnote.typ +++ b/tests/suite/layout/flow/footnote.typ @@ -389,3 +389,12 @@ C #set footnote.entry(separator: v(5em)) #footnote[] + +--- footnote-custom-head-html html --- +#html.html({ + html.head() + html.body[ + // Error: 12-32 footnotes are not currently supported in combination with a custom `` or `` element + Hello #footnote[Footnote 1] + ] +}) From aafa2744acd959893d8eb70ace9b52d4219190f9 Mon Sep 17 00:00:00 2001 From: Wojciech Kordalski Date: Wed, 1 Oct 2025 15:10:06 +0200 Subject: [PATCH 390/558] Bump hayagriva to 0.9.1 and fix citation supplement comparison (#6171) Co-authored-by: PgBiel <9021226+PgBiel@users.noreply.github.com> Co-authored-by: Laurenz --- Cargo.lock | 59 +++++--------- Cargo.toml | 2 +- .../typst-library/src/model/bibliography.rs | 8 +- tests/ref/cite-form.png | Bin 10698 -> 10867 bytes tests/ref/cite-supplements-and-ibid.png | Bin 0 -> 7901 bytes tests/ref/html/cite-form.html | 2 +- tests/suite/model/cite.typ | 75 ++++++++++++++++++ 7 files changed, 103 insertions(+), 43 deletions(-) create mode 100644 tests/ref/cite-supplements-and-ibid.png diff --git a/Cargo.lock b/Cargo.lock index ff3e19c271..73aa450280 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,11 +138,12 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "biblatex" -version = "0.10.0" -source = "git+https://github.com/typst/biblatex.git#d6a0dc3b84412b6ac2e66516f4d434a971c540df" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d0c374feba1b9a59042a7c1cf00ce7c34b977b9134fe7c42b08e5183729f66" dependencies = [ - "numerals", "paste", + "roman-numerals-rs", "strum", "unic-langid", "unicode-normalization", @@ -326,8 +327,9 @@ dependencies = [ [[package]] name = "citationberg" -version = "0.5.0" -source = "git+https://github.com/typst/citationberg.git?rev=35cc83fb325d22c9b5bd5c7fc1d8e79897308076#35cc83fb325d22c9b5bd5c7fc1d8e79897308076" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f6597e8bdbca37f1f56e5a80d15857b0932aead21a78d20de49e99e74933046" dependencies = [ "quick-xml 0.38.3", "serde", @@ -937,18 +939,19 @@ dependencies = [ [[package]] name = "hayagriva" -version = "0.8.1" -source = "git+https://github.com/typst/hayagriva?rev=03ae2acf#03ae2acf168e65604e74f0361e74ba4ef7689f16" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cb69425736f184173b3ca6e27fcba440a61492a790c786b1c6af7e06a03e575" dependencies = [ "biblatex", "ciborium", "citationberg", "indexmap 2.7.1", - "numerals", "paste", + "roman-numerals-rs", "serde", "serde_yaml 0.9.34+deprecated", - "thiserror 1.0.69", + "thiserror", "unic-langid", "unicode-segmentation", "unscanny", @@ -1736,12 +1739,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "numerals" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e25be21376a772d15f97ae789845340a9651d3c4246ff5ebb6a2b35f9c37bd31" - [[package]] name = "once_cell" version = "1.20.2" @@ -2190,7 +2187,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ "getrandom 0.2.15", "libredox", - "thiserror 2.0.16", + "thiserror", ] [[package]] @@ -2254,6 +2251,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3" +[[package]] +name = "roman-numerals-rs" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c85cd47a33a4510b1424fe796498e174c6a9cf94e606460ef022a19f3e4ff85e" + [[package]] name = "roxmltree" version = "0.20.0" @@ -2650,7 +2653,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "thiserror 2.0.16", + "thiserror", "walkdir", "yaml-rust", ] @@ -2711,33 +2714,13 @@ version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a38c90d48152c236a3ab59271da4f4ae63d678c5d7ad6b7714d7cb9760be5e4b" -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "thiserror-impl 2.0.16", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9c43f3279b..a2377b825a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,7 +61,7 @@ fontdb = { version = "0.23", default-features = false } fs_extra = "1.3" rustc-hash = "2.1" glidesort = "0.1.2" -hayagriva = { git = "https://github.com/typst/hayagriva", rev = "03ae2acf" } +hayagriva = "0.9.1" hayro-syntax = "0.2.0" hayro = "0.2.0" heck = "0.5" diff --git a/crates/typst-library/src/model/bibliography.rs b/crates/typst-library/src/model/bibliography.rs index 696853eae2..fb63ca5810 100644 --- a/crates/typst-library/src/model/bibliography.rs +++ b/crates/typst-library/src/model/bibliography.rs @@ -11,7 +11,7 @@ use hayagriva::archive::ArchivedStyle; use hayagriva::io::BibLaTeXError; use hayagriva::{ BibliographyDriver, BibliographyRequest, CitationItem, CitationRequest, Library, - SpecificLocator, citationberg, + SpecificLocator, TransparentLocator, citationberg, }; use indexmap::IndexMap; use rustc_hash::{FxBuildHasher, FxHashMap}; @@ -686,10 +686,12 @@ impl<'a> Generator<'a> { }; let supplement = child.supplement.get_cloned(StyleChain::default()); - let locator = supplement.as_ref().map(|_| { + let locator = supplement.as_ref().map(|c| { SpecificLocator( citationberg::taxonomy::Locator::Custom, - hayagriva::LocatorPayload::Transparent, + hayagriva::LocatorPayload::Transparent(TransparentLocator::new( + c.clone(), + )), ) }); diff --git a/tests/ref/cite-form.png b/tests/ref/cite-form.png index 91a6d2e26f6d6da26a81d4d0a92fdebb850b647c..879ae3a90e6ae2c7c0df647537fe68d1019cf266 100644 GIT binary patch delta 7564 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 diff --git a/tests/ref/cite-supplements-and-ibid.png b/tests/ref/cite-supplements-and-ibid.png new file mode 100644 index 0000000000000000000000000000000000000000..8a4919aaade73488fc6755f108937ed33ceb8349 GIT binary patch literal 7901 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 literal 0 HcmV?d00001 diff --git a/tests/ref/html/cite-form.html b/tests/ref/html/cite-form.html index 28c2340547..2325b817e4 100644 --- a/tests/ref/html/cite-form.html +++ b/tests/ref/html/cite-form.html @@ -11,7 +11,7 @@

Bibliography

  • Astley, R., & Morris, L. (2020). At-scale impact of the Net Wok: A culinarically holistic investigation of distributed dumplings. Armenian Journal of Proceedings, 61, 192–219.
  • -
  • Leeson, P. T. The Pirate Organization.
  • +
  • Leeson, P. T. (n.d.). The Pirate Organization.
diff --git a/tests/suite/model/cite.typ b/tests/suite/model/cite.typ index cf71ca5c77..6be78a5bdd 100644 --- a/tests/suite/model/cite.typ +++ b/tests/suite/model/cite.typ @@ -171,3 +171,78 @@ aaa // Test warning for deprecated alias. // Warning: 18-37 style "chicago-fullnotes" has been deprecated in favor of "chicago-notes" #set cite(style: "chicago-fullnotes") + +--- cite-supplements-and-ibid --- +#set page(width: 300pt) + +Par 1 @arrgh + +Par 2 @arrgh[p. 5-8] + +Par 3 @arrgh[p. 5-8] + +Par 4 @arrgh[p. 9-10] + +Par 5 @arrgh[*p. 9-10*] + +Par 6 @arrgh[*p. 9-10*] + +#let style = bytes( + ```xml + + + ```.text +) + +#bibliography("/assets/bib/works.bib", style: style) From ed7a89190cd1f026cfcf550bdf1a8c06cb542cc1 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:16:39 -0300 Subject: [PATCH 391/558] Deprecate renamed `modern-humanities-research-association` style (#6994) --- crates/typst-library/src/model/bibliography.rs | 6 ++++++ tests/suite/model/bibliography.typ | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/crates/typst-library/src/model/bibliography.rs b/crates/typst-library/src/model/bibliography.rs index fb63ca5810..79cd9d22a7 100644 --- a/crates/typst-library/src/model/bibliography.rs +++ b/crates/typst-library/src/model/bibliography.rs @@ -512,6 +512,12 @@ impl FromValue for CslSource { "style \"chicago-fullnotes\" has been deprecated \ in favor of \"chicago-notes\"", ); + } else if string.as_str() == "modern-humanities-research-association" { + warning = Some( + "style \"modern-humanities-research-association\" \ + has been deprecated in favor of \ + \"modern-humanities-research-association-notes\"", + ); } let style = ArchivedStyle::by_name(&string) diff --git a/tests/suite/model/bibliography.typ b/tests/suite/model/bibliography.typ index 1e112e7cfc..97be4ab848 100644 --- a/tests/suite/model/bibliography.typ +++ b/tests/suite/model/bibliography.typ @@ -110,6 +110,11 @@ Now we have multiple bibliographies containing @glacier-melt @keshav2007read // Warning: 47-66 style "chicago-fullnotes" has been deprecated in favor of "chicago-notes" #bibliography("/assets/bib/works.bib", style: "chicago-fullnotes", title: none) +--- bibliography-modern-humanities-research-association-warning --- +// Test warning for deprecated alias. +// Warning: 47-87 style "modern-humanities-research-association" has been deprecated in favor of "modern-humanities-research-association-notes" +#bibliography("/assets/bib/works.bib", style: "modern-humanities-research-association", title: none) + --- bibliography-csl-display render html --- // Test a combination of CSL `display` attributes. Most of the display // attributes are barely used by any styles, so we have a custom style here. From 563c8d3659e80eff6ffbea5a5a4c75115cf733da Mon Sep 17 00:00:00 2001 From: Laurenz Date: Wed, 1 Oct 2025 17:28:28 +0200 Subject: [PATCH 392/558] Deduplicate `window-sys` dependency (#6996) --- Cargo.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 73aa450280..430bc6eb3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -702,7 +702,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1631,14 +1631,14 @@ dependencies = [ [[package]] name = "mio" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", "log", "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2289,7 +2289,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2547,7 +2547,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2686,7 +2686,7 @@ dependencies = [ "getrandom 0.3.1", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3713,7 +3713,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] From 3f8e37970caafe3cb0a8538255d7e8f39301763b Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Tue, 19 Aug 2025 18:02:13 +0200 Subject: [PATCH 393/558] Update krilla --- Cargo.lock | 148 +++++++++++++----- Cargo.toml | 12 +- .../typst-library/src/visualize/image/mod.rs | 3 +- crates/typst-pdf/src/attach.rs | 17 +- crates/typst-pdf/src/convert.rs | 10 +- crates/typst-pdf/src/metadata.rs | 29 ++-- tests/ref/image-pdf-multiple-pages.png | Bin 7618 -> 7654 bytes tests/ref/image-pdf.png | Bin 3987 -> 3973 bytes 8 files changed, 146 insertions(+), 73 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 430bc6eb3a..bdf4dc2693 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -705,6 +705,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "euclid" +version = "0.22.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48" +dependencies = [ + "num-traits", +] + [[package]] name = "fancy-regex" version = "0.16.2" @@ -789,9 +798,9 @@ checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" [[package]] name = "font-types" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7" +checksum = "511e2c18a516c666d27867d2f9821f76e7d591f762e9fc41dd6cc5c90fe54b0b" dependencies = [ "bytemuck", ] @@ -960,21 +969,21 @@ dependencies = [ [[package]] name = "hayro" -version = "0.2.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a063475c55469656ada01db8d502ccafe3351f463854019aeb686c105246307b" +checksum = "048488ba88552bb0fb2a7e4001c64d5bed65d1a92167186a1bb9151571f32e60" dependencies = [ "bytemuck", "hayro-interpret", "image", - "kurbo", + "kurbo 0.12.0", ] [[package]] name = "hayro-font" -version = "0.1.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "961d753ae00c7aaf512ed386dd4c723a18cf1c0cb2e0772e87bbe8c18ed1a6df" +checksum = "10e7e97ce840a6a70e7901e240ec65ba61106b66b37a4a1b899a2ce484248463" dependencies = [ "log", "phf", @@ -982,17 +991,17 @@ dependencies = [ [[package]] name = "hayro-interpret" -version = "0.2.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35e0d2bc380332441b45e47d52b10d05c5df74a7b1e6c8ca95254aef2be1b38" +checksum = "56204c972d08e844f3db13b1e14be769f846e576699b46d4f4637cc4f8f70102" dependencies = [ "bitflags 2.9.1", "hayro-font", "hayro-syntax", - "kurbo", + "kurbo 0.12.0", "log", + "moxcms", "phf", - "qcms", "rustc-hash", "siphasher", "skrifa", @@ -1002,12 +1011,12 @@ dependencies = [ [[package]] name = "hayro-syntax" -version = "0.2.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d3e06bd0f8a1861e353ed4d516751fac3c1c0a202cbe9f3ed325be9194e1cf" +checksum = "3f9e5c7dbc0f11dc42775d1a6cc00f5f5137b90b6288dd7fe5f71d17b14d10be" dependencies = [ "flate2", - "kurbo", + "kurbo 0.12.0", "log", "rustc-hash", "smallvec", @@ -1016,9 +1025,9 @@ dependencies = [ [[package]] name = "hayro-write" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d55f2b3e748993b8d87ff2e72d34f74b4c9a11d09d6c5f4eb54340f5d2aed275" +checksum = "cc05d8b4bc878b9aee48d980ecb25ed08f1dd9fad6da5ab4d9b7c56ec03a0cf6" dependencies = [ "flate2", "hayro-syntax", @@ -1293,6 +1302,12 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + [[package]] name = "indexmap" version = "1.9.3" @@ -1423,7 +1438,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.4.0" -source = "git+https://github.com/LaurenzV/krilla?rev=747be0d#747be0db9abc14e97b1036e0ced5a069080705b6" +source = "git+https://github.com/LaurenzV/krilla?rev=7c8605a#7c8605a3292c800b276e957dd43d77ca1b36bcd0" dependencies = [ "base64", "bumpalo", @@ -1433,7 +1448,7 @@ dependencies = [ "gif", "hayro-write", "image-webp", - "imagesize", + "imagesize 0.14.0", "once_cell", "pdf-writer", "png", @@ -1453,7 +1468,7 @@ dependencies = [ [[package]] name = "krilla-svg" version = "0.1.0" -source = "git+https://github.com/LaurenzV/krilla?rev=747be0d#747be0db9abc14e97b1036e0ced5a069080705b6" +source = "git+https://github.com/LaurenzV/krilla?rev=7c8605a#7c8605a3292c800b276e957dd43d77ca1b36bcd0" dependencies = [ "flate2", "fontdb", @@ -1474,6 +1489,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "kurbo" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce9729cc38c18d86123ab736fd2e7151763ba226ac2490ec092d1dd148825e32" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + [[package]] name = "libc" version = "0.2.169" @@ -1641,6 +1667,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "moxcms" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd32fa8935aeadb8a8a6b6b351e40225570a37c43de67690383d87ef170cd08" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multi-stash" version = "0.2.0" @@ -1895,9 +1931,9 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "pdf-writer" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ea27c5015ab81753fc61e49f8cde74999346605ee148bb20008ef3d3150e0dc" +checksum = "92a79477295a713c2ed425aa82a8b5d20cec3fdee203706cbe6f3854880c1c81" dependencies = [ "bitflags 2.9.1", "itoa", @@ -1913,29 +1949,30 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "phf" -version = "0.11.3" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros", "phf_shared", + "serde", ] [[package]] name = "phf_generator" -version = "0.11.3" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ + "fastrand", "phf_shared", - "rand", ] [[package]] name = "phf_macros" -version = "0.11.3" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ "phf_generator", "phf_shared", @@ -1946,9 +1983,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.11.3" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ "siphasher", ] @@ -2041,9 +2078,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.93" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -2069,6 +2106,15 @@ dependencies = [ "unicase", ] +[[package]] +name = "pxfm" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83f9b339b02259ada5c0f4a389b7fb472f933aa17ce176fd2ad98f28bb401fde" +dependencies = [ + "num-traits", +] + [[package]] name = "qcms" version = "0.3.0" @@ -2162,9 +2208,9 @@ dependencies = [ [[package]] name = "read-fonts" -version = "0.30.1" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "192735ef611aac958468e670cb98432c925426f3cb71521fda202130f7388d91" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" dependencies = [ "bytemuck", "font-types", @@ -2496,9 +2542,9 @@ checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "skrifa" -version = "0.32.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d632b5a73f566303dbeabd344dc3e716fd4ddc9a70d6fc8ea8e6f06617da97" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" dependencies = [ "bytemuck", "read-fonts", @@ -2598,11 +2644,14 @@ dependencies = [ [[package]] name = "subsetter" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35725d9d2d056905865f8a36146e45be43691b15fc5d973bd7f79dd438288544" +checksum = "cb6895a12ac5599bb6057362f00e8a3cf1daab4df33f553a55690a44e4fed8d0" dependencies = [ + "kurbo 0.12.0", "rustc-hash", + "skrifa", + "write-fonts", ] [[package]] @@ -2611,7 +2660,7 @@ version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" dependencies = [ - "kurbo", + "kurbo 0.11.2", "siphasher", ] @@ -3103,7 +3152,7 @@ dependencies = [ "icu_provider_adapters", "icu_provider_blob", "icu_segmenter", - "kurbo", + "kurbo 0.12.0", "memchr", "rustc-hash", "rustybuzz", @@ -3145,7 +3194,7 @@ dependencies = [ "image", "indexmap 2.7.1", "kamadak-exif", - "kurbo", + "kurbo 0.12.0", "lipsum", "memchr", "palette", @@ -3507,8 +3556,8 @@ dependencies = [ "data-url", "flate2", "fontdb", - "imagesize", - "kurbo", + "imagesize 0.13.0", + "kurbo 0.11.2", "log", "pico-args", "roxmltree", @@ -3825,6 +3874,19 @@ dependencies = [ "bitflags 2.9.1", ] +[[package]] +name = "write-fonts" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "886614b5ce857341226aa091f3c285e450683894acaaa7887f366c361efef79d" +dependencies = [ + "font-types", + "indexmap 2.7.1", + "kurbo 0.12.0", + "log", + "read-fonts", +] + [[package]] name = "write16" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index a2377b825a..6c34db2920 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,8 +62,8 @@ fs_extra = "1.3" rustc-hash = "2.1" glidesort = "0.1.2" hayagriva = "0.9.1" -hayro-syntax = "0.2.0" -hayro = "0.2.0" +hayro-syntax = "0.4.0" +hayro = "0.4.0" heck = "0.5" hypher = "0.1.4" icu_properties = { version = "1.4", features = ["serde"] } @@ -75,9 +75,9 @@ image = { version = "0.25.5", default-features = false, features = ["png", "jpeg indexmap = { version = "2", features = ["serde"] } infer = { version = "0.19.0", default-features = false } kamadak-exif = "0.6" -krilla = { git = "https://github.com/LaurenzV/krilla", rev = "747be0d", default-features = false, features = ["raster-images", "comemo", "rayon", "pdf"] } -krilla-svg = { git = "https://github.com/LaurenzV/krilla", rev = "747be0d" } -kurbo = "0.11" +krilla = { git = "https://github.com/LaurenzV/krilla", rev = "7c8605a", default-features = false, features = ["raster-images", "comemo", "rayon", "pdf"] } +krilla-svg = { git = "https://github.com/LaurenzV/krilla", rev = "7c8605a" } +kurbo = "0.12" libfuzzer-sys = "0.4" lipsum = "0.9" memchr = "2" @@ -90,7 +90,7 @@ oxipng = { version = "9.0", default-features = false, features = ["filetime", "p palette = { version = "0.7.3", default-features = false, features = ["approx", "libm"] } parking_lot = "0.12.1" pathdiff = "0.2" -phf = { version = "0.11", features = ["macros"] } +phf = { version = "0.13", features = ["macros"] } pixglyph = "0.6" png = "0.17" portable-atomic = "1.6" diff --git a/crates/typst-library/src/visualize/image/mod.rs b/crates/typst-library/src/visualize/image/mod.rs index 9350842a32..427ee93e48 100644 --- a/crates/typst-library/src/visualize/image/mod.rs +++ b/crates/typst-library/src/visualize/image/mod.rs @@ -291,7 +291,8 @@ impl Packed { let document = match PdfDocument::new(loaded.data.clone()) { Ok(doc) => doc, Err(e) => match e { - LoadPdfError::Encryption => { + // TODO: the `DecyptionError` is currently not public + LoadPdfError::Decryption(_) => { bail!( span, "the PDF is encrypted or password-protected"; diff --git a/crates/typst-pdf/src/attach.rs b/crates/typst-pdf/src/attach.rs index 895fe9ffc5..710fdc64a9 100644 --- a/crates/typst-pdf/src/attach.rs +++ b/crates/typst-pdf/src/attach.rs @@ -1,17 +1,19 @@ use std::sync::Arc; use krilla::Document; -use krilla::embed::{AssociationKind, EmbeddedFile}; +use krilla::embed::{AssociationKind, EmbeddedFile, MimeType}; use typst_library::diag::{SourceResult, bail}; use typst_library::foundations::{NativeElement, StyleChain}; -use typst_library::layout::PagedDocument; use typst_library::pdf::{AttachElem, AttachedFileRelationship}; +use crate::convert::GlobalContext; +use crate::metadata; + pub(crate) fn attach_files( - typst_doc: &PagedDocument, + gc: &GlobalContext, document: &mut Document, ) -> SourceResult<()> { - let elements = typst_doc.introspector.query(&AttachElem::ELEM.select()); + let elements = gc.document.introspector.query(&AttachElem::ELEM.select()); for elem in &elements { let elem = elem.to_packed::().unwrap(); @@ -22,7 +24,11 @@ pub(crate) fn attach_files( .mime_type .get_ref(StyleChain::default()) .as_ref() - .map(|s| s.to_string()); + .map(|s| match MimeType::new(s) { + Some(mime_type) => Ok(mime_type), + None => bail!(elem.span(), "invalid mime type"), + }) + .transpose()?; let description = elem .description .get_ref(StyleChain::default()) @@ -48,6 +54,7 @@ pub(crate) fn attach_files( data: data.into(), compress, location: Some(span.into_raw()), + modification_date: metadata::creation_date(gc), }; if document.embed_file(file).is_none() { diff --git a/crates/typst-pdf/src/convert.rs b/crates/typst-pdf/src/convert.rs index 010e709f31..c8d2b7fc3d 100644 --- a/crates/typst-pdf/src/convert.rs +++ b/crates/typst-pdf/src/convert.rs @@ -63,7 +63,7 @@ pub fn convert( ); convert_pages(&mut gc, &mut document)?; - attach_files(typst_document, &mut document)?; + attach_files(&gc, &mut document)?; document.set_outline(build_outline(&gc)); document.set_metadata(build_metadata(&gc)); @@ -503,6 +503,14 @@ fn convert_error( forbidden in this export mode", ) } + ValidationError::RestrictedLicense(f) => error!( + Span::detached(), + "{prefix} license of font {} is too restrictive", + display_font(gc.fonts_backward.get(f).unwrap()).repr(); + hint: "the font has specified \"Restricted License embedding\" in its metadata"; + hint: "restrictive font licenses are prohibited by {} because they limit the suitability for archival", + validator.as_str() + ), ValidationError::Transparency(loc) => { let span = to_span(*loc); let hint1 = "try exporting with a different standard that \ diff --git a/crates/typst-pdf/src/metadata.rs b/crates/typst-pdf/src/metadata.rs index d51cb53359..3bc9dc390a 100644 --- a/crates/typst-pdf/src/metadata.rs +++ b/crates/typst-pdf/src/metadata.rs @@ -38,19 +38,7 @@ pub(crate) fn build_metadata(gc: &GlobalContext) -> Metadata { metadata = metadata.document_id(ident.to_string()); } - // (1) If the `document.date` is set to specific `datetime` or `none`, use it. - // (2) If the `document.date` is set to `auto` or not set, try to use the - // date from the options. - // (3) Otherwise, we don't write date metadata. - let (date, tz) = match (gc.document.info.date, gc.options.timestamp) { - (Smart::Custom(date), _) => (date, None), - (Smart::Auto, Some(timestamp)) => { - (Some(timestamp.datetime), Some(timestamp.timezone)) - } - _ => (None, None), - }; - - if let Some(date) = date.and_then(|d| convert_date(d, tz)) { + if let Some(date) = creation_date(gc) { metadata = metadata.creation_date(date); } @@ -59,10 +47,17 @@ pub(crate) fn build_metadata(gc: &GlobalContext) -> Metadata { metadata } -fn convert_date( - datetime: Datetime, - tz: Option, -) -> Option { +/// (1) If the `document.date` is set to specific `datetime` or `none`, use it. +/// (2) If the `document.date` is set to `auto` or not set, try to use the +/// date from the options. +/// (3) Otherwise, we don't write date metadata. +pub fn creation_date(gc: &GlobalContext) -> Option { + let (datetime, tz) = match (gc.document.info.date, gc.options.timestamp) { + (Smart::Custom(Some(date)), _) => (date, None), + (Smart::Auto, Some(timestamp)) => (timestamp.datetime, Some(timestamp.timezone)), + _ => return None, + }; + let year = datetime.year().filter(|&y| y >= 0)? as u16; let mut kd = krilla::metadata::DateTime::new(year); diff --git a/tests/ref/image-pdf-multiple-pages.png b/tests/ref/image-pdf-multiple-pages.png index a6be4a31391ab97054623ceaf5f702f033f724a2..63640b452d9113bb897835c775f1cdb475546c04 100644 GIT binary patch literal 7654 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 From 7d81d53cb5c664ba3a086f340dbb5b5450f87a4d Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Wed, 24 Sep 2025 13:01:11 +0200 Subject: [PATCH 394/558] Make more elements locatable --- crates/typst-library/src/layout/grid/mod.rs | 5 +++-- crates/typst-library/src/layout/hide.rs | 3 ++- crates/typst-library/src/layout/repeat.rs | 3 ++- crates/typst-library/src/model/cite.rs | 2 +- crates/typst-library/src/model/emph.rs | 3 ++- crates/typst-library/src/model/enum.rs | 5 +++-- crates/typst-library/src/model/figure.rs | 2 +- crates/typst-library/src/model/footnote.rs | 2 +- crates/typst-library/src/model/list.rs | 5 +++-- crates/typst-library/src/model/outline.rs | 2 +- crates/typst-library/src/model/par.rs | 2 +- crates/typst-library/src/model/strong.rs | 3 ++- crates/typst-library/src/model/table.rs | 5 +++-- crates/typst-library/src/model/terms.rs | 5 +++-- crates/typst-library/src/text/deco.rs | 9 +++++---- crates/typst-library/src/text/raw.rs | 4 +++- crates/typst-library/src/text/shift.rs | 5 +++-- crates/typst-library/src/visualize/image/mod.rs | 3 ++- 18 files changed, 41 insertions(+), 27 deletions(-) diff --git a/crates/typst-library/src/layout/grid/mod.rs b/crates/typst-library/src/layout/grid/mod.rs index cbe6e91b1b..647608c9bc 100644 --- a/crates/typst-library/src/layout/grid/mod.rs +++ b/crates/typst-library/src/layout/grid/mod.rs @@ -13,6 +13,7 @@ use crate::foundations::{ Array, CastInfo, Content, Context, Fold, FromValue, Func, IntoValue, Packed, Reflect, Resolve, Smart, StyleChain, Value, cast, elem, scope, }; +use crate::introspection::Locatable; use crate::layout::{ Alignment, Length, OuterHAlignment, OuterVAlignment, Rel, Sides, Sizing, }; @@ -168,7 +169,7 @@ use crate::visualize::{Paint, Stroke}; /// /// Furthermore, strokes of a repeated grid header or footer will take /// precedence over regular cell strokes. -#[elem(scope)] +#[elem(scope, Locatable)] pub struct GridElem { /// The column sizes. /// @@ -737,7 +738,7 @@ pub struct GridVLine { /// which allows you, for example, to apply styles based on a cell's position. /// Refer to the examples of the [`table.cell`] element to learn more about /// this. -#[elem(name = "cell", title = "Grid Cell")] +#[elem(name = "cell", title = "Grid Cell", Locatable)] pub struct GridCell { /// The cell's body. #[required] diff --git a/crates/typst-library/src/layout/hide.rs b/crates/typst-library/src/layout/hide.rs index 7e096eaa96..5ef30461df 100644 --- a/crates/typst-library/src/layout/hide.rs +++ b/crates/typst-library/src/layout/hide.rs @@ -1,4 +1,5 @@ use crate::foundations::{Content, elem}; +use crate::introspection::Locatable; /// Hides content without affecting layout. /// @@ -12,7 +13,7 @@ use crate::foundations::{Content, elem}; /// Hello Jane \ /// #hide[Hello] Joe /// ``` -#[elem] +#[elem(Locatable)] pub struct HideElem { /// The content to hide. #[required] diff --git a/crates/typst-library/src/layout/repeat.rs b/crates/typst-library/src/layout/repeat.rs index ef7a5bb053..c6845aba7e 100644 --- a/crates/typst-library/src/layout/repeat.rs +++ b/crates/typst-library/src/layout/repeat.rs @@ -1,4 +1,5 @@ use crate::foundations::{Content, elem}; +use crate::introspection::Locatable; use crate::layout::Length; /// Repeats content to the available space. @@ -22,7 +23,7 @@ use crate::layout::Length; /// Berlin, the 22nd of December, 2022 /// ] /// ``` -#[elem] +#[elem(Locatable)] pub struct RepeatElem { /// The content to repeat. #[required] diff --git a/crates/typst-library/src/model/cite.rs b/crates/typst-library/src/model/cite.rs index 930fa98065..2fb86af4de 100644 --- a/crates/typst-library/src/model/cite.rs +++ b/crates/typst-library/src/model/cite.rs @@ -42,7 +42,7 @@ use crate::text::{Lang, Region, TextElem}; /// This function indirectly has dedicated syntax. [References]($ref) can be /// used to cite works from the bibliography. The label then corresponds to the /// citation key. -#[elem(Synthesize)] +#[elem(Locatable, Synthesize)] pub struct CiteElem { /// The citation key that identifies the entry in the bibliography that /// shall be cited, as a label. diff --git a/crates/typst-library/src/model/emph.rs b/crates/typst-library/src/model/emph.rs index b9eb9ebddb..55b69aee2a 100644 --- a/crates/typst-library/src/model/emph.rs +++ b/crates/typst-library/src/model/emph.rs @@ -1,4 +1,5 @@ use crate::foundations::{Content, elem}; +use crate::introspection::Locatable; /// Emphasizes content by toggling italics. /// @@ -23,7 +24,7 @@ use crate::foundations::{Content, elem}; /// This function also has dedicated syntax: To emphasize content, simply /// enclose it in underscores (`_`). Note that this only works at word /// boundaries. To emphasize part of a word, you have to use the function. -#[elem(title = "Emphasis", keywords = ["italic"])] +#[elem(title = "Emphasis", keywords = ["italic"], Locatable)] pub struct EmphElem { /// The content to emphasize. #[required] diff --git a/crates/typst-library/src/model/enum.rs b/crates/typst-library/src/model/enum.rs index 14811b66c1..1248cbaaf1 100644 --- a/crates/typst-library/src/model/enum.rs +++ b/crates/typst-library/src/model/enum.rs @@ -4,6 +4,7 @@ use smallvec::SmallVec; use crate::diag::bail; use crate::foundations::{Array, Content, Packed, Smart, Styles, cast, elem, scope}; +use crate::introspection::Locatable; use crate::layout::{Alignment, Em, HAlignment, Length, VAlignment}; use crate::model::{ListItemLike, ListLike, Numbering, NumberingPattern}; @@ -63,7 +64,7 @@ use crate::model::{ListItemLike, ListLike, Numbering, NumberingPattern}; /// Enumeration items can contain multiple paragraphs and other block-level /// content. All content that is indented more than an item's marker becomes /// part of that item. -#[elem(scope, title = "Numbered List")] +#[elem(scope, title = "Numbered List", Locatable)] pub struct EnumElem { /// Defines the default [spacing]($enum.spacing) of the enumeration. If it /// is `{false}`, the items are spaced apart with @@ -216,7 +217,7 @@ impl EnumElem { } /// An enumeration item. -#[elem(name = "item", title = "Numbered List Item")] +#[elem(name = "item", title = "Numbered List Item", Locatable)] pub struct EnumItem { /// The item's number. #[positional] diff --git a/crates/typst-library/src/model/figure.rs b/crates/typst-library/src/model/figure.rs index 7bde77d7f6..7a0f39a5ed 100644 --- a/crates/typst-library/src/model/figure.rs +++ b/crates/typst-library/src/model/figure.rs @@ -453,7 +453,7 @@ impl Outlinable for Packed { /// caption: [A rectangle], /// ) /// ``` -#[elem(name = "caption", Synthesize)] +#[elem(name = "caption", Locatable, Synthesize)] pub struct FigureCaption { /// The caption's position in the figure. Either `{top}` or `{bottom}`. /// diff --git a/crates/typst-library/src/model/footnote.rs b/crates/typst-library/src/model/footnote.rs index a18d8b6787..7841bb2f17 100644 --- a/crates/typst-library/src/model/footnote.rs +++ b/crates/typst-library/src/model/footnote.rs @@ -190,7 +190,7 @@ cast! { /// page run is a sequence of pages without an explicit pagebreak in between). /// For this reason, set and show rules for footnote entries should be defined /// before any page content, typically at the very start of the document. -#[elem(name = "entry", title = "Footnote Entry", ShowSet)] +#[elem(name = "entry", title = "Footnote Entry", Locatable, ShowSet)] pub struct FootnoteEntry { /// The footnote for this entry. Its location can be used to determine /// the footnote counter state. diff --git a/crates/typst-library/src/model/list.rs b/crates/typst-library/src/model/list.rs index 5df37ad330..5b81ce8fe7 100644 --- a/crates/typst-library/src/model/list.rs +++ b/crates/typst-library/src/model/list.rs @@ -6,6 +6,7 @@ use crate::foundations::{ Array, Content, Context, Depth, Func, NativeElement, Packed, Smart, StyleChain, Styles, Value, cast, elem, scope, }; +use crate::introspection::Locatable; use crate::layout::{Em, Length}; use crate::text::TextElem; @@ -40,7 +41,7 @@ use crate::text::TextElem; /// followed by a space to create a list item. A list item can contain multiple /// paragraphs and other block-level content. All content that is indented /// more than an item's marker becomes part of that item. -#[elem(scope, title = "Bullet List")] +#[elem(scope, title = "Bullet List", Locatable)] pub struct ListElem { /// Defines the default [spacing]($list.spacing) of the list. If it is /// `{false}`, the items are spaced apart with @@ -135,7 +136,7 @@ impl ListElem { } /// A bullet list item. -#[elem(name = "item", title = "Bullet List Item")] +#[elem(name = "item", title = "Bullet List Item", Locatable)] pub struct ListItem { /// The item's body. #[required] diff --git a/crates/typst-library/src/model/outline.rs b/crates/typst-library/src/model/outline.rs index 356ced8629..a1dd96ee0d 100644 --- a/crates/typst-library/src/model/outline.rs +++ b/crates/typst-library/src/model/outline.rs @@ -467,7 +467,7 @@ pub trait Outlinable: Refable { /// With show-set and show rules on outline entries, you can richly customize /// the outline's appearance. See the /// [section on styling the outline]($outline/#styling-the-outline) for details. -#[elem(scope, name = "entry", title = "Outline Entry")] +#[elem(scope, name = "entry", title = "Outline Entry", Locatable)] pub struct OutlineEntry { /// The nesting level of this outline entry. Starts at `{1}` for top-level /// entries. diff --git a/crates/typst-library/src/model/par.rs b/crates/typst-library/src/model/par.rs index 5009015242..eeee602ac8 100644 --- a/crates/typst-library/src/model/par.rs +++ b/crates/typst-library/src/model/par.rs @@ -93,7 +93,7 @@ use crate::model::Numbering; /// let $a$ be the smallest of the /// three integers. Then, we ... /// ``` -#[elem(scope, title = "Paragraph")] +#[elem(scope, title = "Paragraph", Locatable)] pub struct ParElem { /// The spacing between lines. /// diff --git a/crates/typst-library/src/model/strong.rs b/crates/typst-library/src/model/strong.rs index a1cfb36ab9..49c7def954 100644 --- a/crates/typst-library/src/model/strong.rs +++ b/crates/typst-library/src/model/strong.rs @@ -1,4 +1,5 @@ use crate::foundations::{Content, elem}; +use crate::introspection::Locatable; /// Strongly emphasizes content by increasing the font weight. /// @@ -18,7 +19,7 @@ use crate::foundations::{Content, elem}; /// simply enclose it in stars/asterisks (`*`). Note that this only works at /// word boundaries. To strongly emphasize part of a word, you have to use the /// function. -#[elem(title = "Strong Emphasis", keywords = ["bold", "weight"])] +#[elem(title = "Strong Emphasis", keywords = ["bold", "weight"], Locatable)] pub struct StrongElem { /// The delta to apply on the font weight. /// diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs index 11dd9ad967..dd5eac6911 100644 --- a/crates/typst-library/src/model/table.rs +++ b/crates/typst-library/src/model/table.rs @@ -5,6 +5,7 @@ use typst_utils::NonZeroExt; use crate::diag::{HintedStrResult, HintedString, bail}; use crate::foundations::{Content, Packed, Smart, cast, elem, scope}; +use crate::introspection::Locatable; use crate::layout::{ Abs, Alignment, Celled, GridCell, GridFooter, GridHLine, GridHeader, GridVLine, Length, OuterHAlignment, OuterVAlignment, Rel, Sides, TrackSizings, @@ -116,7 +117,7 @@ use crate::visualize::{Paint, Stroke}; /// [Robert], b, a, b, /// ) /// ``` -#[elem(scope, LocalName, Figurable)] +#[elem(scope, Locatable, LocalName, Figurable)] pub struct TableElem { /// The column sizes. See the [grid documentation]($grid/#track-size) for /// more information on track sizing. @@ -675,7 +676,7 @@ pub struct TableVLine { /// [Vikram], [49], [Perseverance], /// ) /// ``` -#[elem(name = "cell", title = "Table Cell")] +#[elem(name = "cell", title = "Table Cell", Locatable)] pub struct TableCell { /// The cell's body. #[required] diff --git a/crates/typst-library/src/model/terms.rs b/crates/typst-library/src/model/terms.rs index 47939b9b49..4a03e6bfea 100644 --- a/crates/typst-library/src/model/terms.rs +++ b/crates/typst-library/src/model/terms.rs @@ -2,6 +2,7 @@ use crate::diag::bail; use crate::foundations::{ Array, Content, NativeElement, Packed, Smart, Styles, cast, elem, scope, }; +use crate::introspection::Locatable; use crate::layout::{Em, HElem, Length}; use crate::model::{ListItemLike, ListLike}; @@ -21,7 +22,7 @@ use crate::model::{ListItemLike, ListLike}; /// # Syntax /// This function also has dedicated syntax: Starting a line with a slash, /// followed by a term, a colon and a description creates a term list item. -#[elem(scope, title = "Term List")] +#[elem(scope, title = "Term List", Locatable)] pub struct TermsElem { /// Defines the default [spacing]($terms.spacing) of the term list. If it is /// `{false}`, the items are spaced apart with @@ -112,7 +113,7 @@ impl TermsElem { } /// A term list item. -#[elem(name = "item", title = "Term List Item")] +#[elem(name = "item", title = "Term List Item", Locatable)] pub struct TermItem { /// The term described by the list item. #[required] diff --git a/crates/typst-library/src/text/deco.rs b/crates/typst-library/src/text/deco.rs index d7383f0f12..ba9044071e 100644 --- a/crates/typst-library/src/text/deco.rs +++ b/crates/typst-library/src/text/deco.rs @@ -1,4 +1,5 @@ use crate::foundations::{Content, Smart, elem}; +use crate::introspection::Locatable; use crate::layout::{Abs, Corners, Length, Rel, Sides}; use crate::text::{BottomEdge, BottomEdgeMetric, TopEdge, TopEdgeMetric}; use crate::visualize::{Color, FixedStroke, Paint, Stroke}; @@ -9,7 +10,7 @@ use crate::visualize::{Color, FixedStroke, Paint, Stroke}; /// ```example /// This is #underline[important]. /// ``` -#[elem] +#[elem(Locatable)] pub struct UnderlineElem { /// How to [stroke] the line. /// @@ -77,7 +78,7 @@ pub struct UnderlineElem { /// ```example /// #overline[A line over text.] /// ``` -#[elem] +#[elem(Locatable)] pub struct OverlineElem { /// How to [stroke] the line. /// @@ -151,7 +152,7 @@ pub struct OverlineElem { /// ```example /// This is #strike[not] relevant. /// ``` -#[elem(title = "Strikethrough")] +#[elem(title = "Strikethrough", Locatable)] pub struct StrikeElem { /// How to [stroke] the line. /// @@ -210,7 +211,7 @@ pub struct StrikeElem { /// ```example /// This is #highlight[important]. /// ``` -#[elem] +#[elem(Locatable)] pub struct HighlightElem { /// The color to highlight the text with. /// diff --git a/crates/typst-library/src/text/raw.rs b/crates/typst-library/src/text/raw.rs index 75ea32b6a8..cacaf412df 100644 --- a/crates/typst-library/src/text/raw.rs +++ b/crates/typst-library/src/text/raw.rs @@ -20,6 +20,7 @@ use crate::foundations::{ Bytes, Content, Derived, OneOrMultiple, Packed, PlainText, ShowSet, Smart, StyleChain, Styles, Synthesize, Target, TargetElem, cast, elem, scope, }; +use crate::introspection::Locatable; use crate::layout::{Em, HAlignment}; use crate::loading::{DataSource, Load}; use crate::model::{Figurable, ParElem}; @@ -127,6 +128,7 @@ use crate::visualize::Color; scope, title = "Raw Text / Code", Synthesize, + Locatable, ShowSet, LocalName, Figurable, @@ -677,7 +679,7 @@ fn format_theme_error(error: syntect::LoadingError) -> LoadError { /// It allows you to access various properties of the line, such as the line /// number, the raw non-highlighted text, the highlighted text, and whether it /// is the first or last line of the raw block. -#[elem(name = "line", title = "Raw Text / Code Line", PlainText)] +#[elem(name = "line", title = "Raw Text / Code Line", Locatable, PlainText)] pub struct RawLine { /// The line number of the raw line inside of the raw block, starts at 1. #[required] diff --git a/crates/typst-library/src/text/shift.rs b/crates/typst-library/src/text/shift.rs index 2a6494c927..88995d3bcb 100644 --- a/crates/typst-library/src/text/shift.rs +++ b/crates/typst-library/src/text/shift.rs @@ -1,3 +1,4 @@ +use crate::introspection::Locatable; use ttf_parser::Tag; use crate::foundations::{Content, Smart, elem}; @@ -12,7 +13,7 @@ use crate::text::{FontMetrics, ScriptMetrics, TextSize}; /// ```example /// Revenue#sub[yearly] /// ``` -#[elem(title = "Subscript")] +#[elem(title = "Subscript", Locatable)] pub struct SubElem { /// Whether to use subscript glyphs from the font if available. /// @@ -67,7 +68,7 @@ pub struct SubElem { /// ```example /// 1#super[st] try! /// ``` -#[elem(title = "Superscript")] +#[elem(title = "Superscript", Locatable)] pub struct SuperElem { /// Whether to use superscript glyphs from the font if available. /// diff --git a/crates/typst-library/src/visualize/image/mod.rs b/crates/typst-library/src/visualize/image/mod.rs index 427ee93e48..7105d157e6 100644 --- a/crates/typst-library/src/visualize/image/mod.rs +++ b/crates/typst-library/src/visualize/image/mod.rs @@ -26,6 +26,7 @@ use crate::foundations::{ Bytes, Cast, Content, Derived, NativeElement, Packed, Smart, StyleChain, cast, elem, func, scope, }; +use crate::introspection::Locatable; use crate::layout::{Length, Rel, Sizing}; use crate::loading::{DataSource, Load, LoadSource, Loaded, Readable}; use crate::model::Figurable; @@ -50,7 +51,7 @@ use crate::visualize::image::pdf::PdfDocument; /// ], /// ) /// ``` -#[elem(scope, LocalName, Figurable)] +#[elem(scope, Locatable, LocalName, Figurable)] pub struct ImageElem { /// A [path]($syntax/#paths) to an image file or raw bytes making up an /// image in one of the supported [formats]($image.format). From 1744deafc2f18945a0d7f27308adfb2e420a4e25 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Wed, 1 Oct 2025 09:29:08 +0200 Subject: [PATCH 395/558] Update fuzzy snapshot tests --- tests/ref/html/link-html-frame.html | 2 +- .../line-numbers-deduplication-tall-line.png | Bin 4062 -> 4065 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ref/html/link-html-frame.html b/tests/ref/html/link-html-frame.html index 60cca836de..b6a92414df 100644 --- a/tests/ref/html/link-html-frame.html +++ b/tests/ref/html/link-html-frame.html @@ -6,7 +6,7 @@

Frame 1

- +

Text

Go to teal square

Frame 2

diff --git a/tests/ref/line-numbers-deduplication-tall-line.png b/tests/ref/line-numbers-deduplication-tall-line.png index 5411590720d67f921db681888c34b43edbc4ca81..5eb641a07f78e86b2aa4498213411f874c9829ad 100644 GIT binary patch delta 3723 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 P0DrXAVQNSfoACbt1wuC{ From e50d40252550765f2bbf42b89f3ce291151c3d75 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Wed, 14 May 2025 00:06:06 +0200 Subject: [PATCH 396/558] Generate PDF accessibility tag tree - write tags for - headings - figures - images - mark page artifacts - mark repeat element as artifact - add pdf.artifact element - add cli args for PDF/UA-1 standard and to disable tagging --- crates/typst-cli/src/args.rs | 10 + crates/typst-cli/src/compile.rs | 9 + crates/typst-layout/src/pages/run.rs | 12 +- crates/typst-layout/src/rules.rs | 9 +- .../src/foundations/content/mod.rs | 7 + crates/typst-library/src/model/figure.rs | 3 + crates/typst-library/src/pdf/accessibility.rs | 31 +++ crates/typst-library/src/pdf/mod.rs | 3 + crates/typst-pdf/src/convert.rs | 20 +- crates/typst-pdf/src/image.rs | 13 +- crates/typst-pdf/src/lib.rs | 10 + crates/typst-pdf/src/shape.rs | 5 +- crates/typst-pdf/src/tags/mod.rs | 229 ++++++++++++++++++ crates/typst-pdf/src/text.rs | 6 +- crates/typst-render/src/lib.rs | 2 +- 15 files changed, 346 insertions(+), 23 deletions(-) create mode 100644 crates/typst-library/src/pdf/accessibility.rs create mode 100644 crates/typst-pdf/src/tags/mod.rs diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index f31d26a2e5..e5cf54a005 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -262,6 +262,13 @@ pub struct CompileArgs { #[arg(long = "pdf-standard", value_delimiter = ',')] pub pdf_standard: Vec, + /// By default, even when not producing a `PDF/UA-1` document, a tagged PDF + /// document is written to provide a baseline of accessibility. In some + /// circumstances (for example when trying to reduce the size of a document) + /// it can be desirable to disable tagged PDF. + #[arg(long = "no-pdf-tags")] + pub no_pdf_tags: bool, + /// The PPI (pixels per inch) to use for PNG export. #[arg(long = "ppi", default_value_t = 144.0)] pub ppi: f32, @@ -534,6 +541,9 @@ pub enum PdfStandard { /// PDF/A-4e. #[value(name = "a-4e")] A_4e, + /// PDF/UA-1. + #[value(name = "ua-1")] + UA_1, } display_possible_values!(PdfStandard); diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index 508a08faf5..e69679ded9 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -65,6 +65,8 @@ pub struct CompileConfig { pub open: Option>, /// A list of standards the PDF should conform to. pub pdf_standards: PdfStandards, + /// Whether to write PDF (accessibility) tags. + pub disable_pdf_tags: bool, /// 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. @@ -129,6 +131,10 @@ impl CompileConfig { PageRanges::new(export_ranges.iter().map(|r| r.0.clone()).collect()) }); + if args.no_pdf_tags && args.pdf_standard.contains(&PdfStandard::UA_1) { + bail!("cannot disable PDF tags when exporting a PDF/UA-1 document"); + } + let pdf_standards = PdfStandards::new( &args.pdf_standard.iter().copied().map(Into::into).collect::>(), )?; @@ -150,6 +156,7 @@ impl CompileConfig { output_format, pages, pdf_standards, + disable_pdf_tags: args.no_pdf_tags, creation_timestamp: args.world.creation_timestamp, make_deps: args.make_deps.clone(), ppi: args.ppi, @@ -291,6 +298,7 @@ fn export_pdf(document: &PagedDocument, config: &CompileConfig) -> SourceResult< timestamp, page_ranges: config.pages.clone(), standards: config.pdf_standards.clone(), + disable_tags: config.disable_pdf_tags, }; let buffer = typst_pdf::pdf(document, &options)?; config @@ -775,6 +783,7 @@ impl From for typst_pdf::PdfStandard { PdfStandard::A_4 => typst_pdf::PdfStandard::A_4, PdfStandard::A_4f => typst_pdf::PdfStandard::A_4f, PdfStandard::A_4e => typst_pdf::PdfStandard::A_4e, + PdfStandard::UA_1 => typst_pdf::PdfStandard::Ua_1, } } } diff --git a/crates/typst-layout/src/pages/run.rs b/crates/typst-layout/src/pages/run.rs index f7b0d86e42..e3372f9626 100644 --- a/crates/typst-layout/src/pages/run.rs +++ b/crates/typst-layout/src/pages/run.rs @@ -14,6 +14,7 @@ use typst_library::layout::{ VAlignment, }; use typst_library::model::Numbering; +use typst_library::pdf::ArtifactKind; use typst_library::routines::{Pair, Routines}; use typst_library::text::{LocalName, TextElem}; use typst_library::visualize::Paint; @@ -202,6 +203,11 @@ fn layout_page_run_impl( // Layout marginals. let mut layouted = Vec::with_capacity(fragment.len()); + + let header = header.clone().map(|h| h.artifact(ArtifactKind::Header)); + let footer = footer.clone().map(|f| f.artifact(ArtifactKind::Footer)); + let background = background.clone().map(|b| b.artifact(ArtifactKind::Page)); + for inner in fragment { let header_size = Size::new(inner.width(), margin.top - header_ascent); let footer_size = Size::new(inner.width(), margin.bottom - footer_descent); @@ -212,9 +218,9 @@ fn layout_page_run_impl( fill: fill.clone(), numbering: numbering.clone(), supplement: supplement.clone(), - header: layout_marginal(header, header_size, Alignment::BOTTOM)?, - footer: layout_marginal(footer, footer_size, Alignment::TOP)?, - background: layout_marginal(background, full_size, mid)?, + header: layout_marginal(&header, header_size, Alignment::BOTTOM)?, + footer: layout_marginal(&footer, footer_size, Alignment::TOP)?, + background: layout_marginal(&background, full_size, mid)?, foreground: layout_marginal(foreground, full_size, mid)?, margin, binding, diff --git a/crates/typst-layout/src/rules.rs b/crates/typst-layout/src/rules.rs index d7c2752595..640f07a941 100644 --- a/crates/typst-layout/src/rules.rs +++ b/crates/typst-layout/src/rules.rs @@ -3,8 +3,8 @@ use ecow::EcoVec; use smallvec::smallvec; use typst_library::diag::{At, SourceResult, bail}; use typst_library::foundations::{ - Content, Context, NativeElement, NativeRuleMap, Packed, Resolve, ShowFn, Smart, - StyleChain, Target, dict, + Content, Context, NativeElement, NativeRuleMap, Packed, Resolve, ShowFn, + Smart, StyleChain, Target, dict, }; use typst_library::introspection::{Counter, Locator, LocatorLink}; use typst_library::layout::{ @@ -22,7 +22,7 @@ use typst_library::model::{ OutlineEntry, ParElem, ParbreakElem, QuoteElem, RefElem, StrongElem, TableCell, TableElem, TermsElem, TitleElem, Works, }; -use typst_library::pdf::AttachElem; +use typst_library::pdf::{ArtifactElem, AttachElem}; use typst_library::text::{ DecoLine, Decoration, HighlightElem, ItalicToggle, LinebreakElem, OverlineElem, RawElem, RawLine, ScriptKind, ShiftSettings, Smallcaps, SmallcapsElem, SpaceElem, @@ -105,6 +105,7 @@ pub fn register(rules: &mut NativeRuleMap) { // PDF. rules.register(Paged, ATTACH_RULE); + rules.register(Paged, ARTIFACT_RULE); } const STRONG_RULE: ShowFn = |elem, _, styles| { @@ -788,3 +789,5 @@ const EQUATION_RULE: ShowFn = |elem, _, styles| { }; const ATTACH_RULE: ShowFn = |_, _, _| Ok(Content::empty()); + +const ARTIFACT_RULE: ShowFn = |elem, _, _| Ok(elem.body.clone()); diff --git a/crates/typst-library/src/foundations/content/mod.rs b/crates/typst-library/src/foundations/content/mod.rs index 05740e55b5..4bf56d61e8 100644 --- a/crates/typst-library/src/foundations/content/mod.rs +++ b/crates/typst-library/src/foundations/content/mod.rs @@ -32,6 +32,7 @@ use crate::foundations::{ use crate::introspection::Location; use crate::layout::{AlignElem, Alignment, Axes, Length, MoveElem, PadElem, Rel, Sides}; use crate::model::{Destination, EmphElem, LinkElem, StrongElem}; +use crate::pdf::{ArtifactElem, ArtifactKind}; use crate::text::UnderlineElem; /// A piece of document content. @@ -494,6 +495,12 @@ impl Content { .pack() .spanned(span) } + + /// Mark content as a PDF artifact. + pub fn artifact(self, kind: ArtifactKind) -> Self { + let span = self.span(); + ArtifactElem::new(self).with_kind(kind).pack().spanned(span) + } } #[scope] diff --git a/crates/typst-library/src/model/figure.rs b/crates/typst-library/src/model/figure.rs index 7a0f39a5ed..ae65b3fc1d 100644 --- a/crates/typst-library/src/model/figure.rs +++ b/crates/typst-library/src/model/figure.rs @@ -107,6 +107,9 @@ pub struct FigureElem { #[required] pub body: Content, + /// An alternative description of the figure. + pub alt: Option, + /// The figure's placement on the page. /// /// - `{none}`: The figure stays in-flow exactly where it was specified diff --git a/crates/typst-library/src/pdf/accessibility.rs b/crates/typst-library/src/pdf/accessibility.rs new file mode 100644 index 0000000000..98f16dd90e --- /dev/null +++ b/crates/typst-library/src/pdf/accessibility.rs @@ -0,0 +1,31 @@ +use typst_macros::{Cast, elem}; + +use crate::foundations::Content; +use crate::introspection::Locatable; + +/// Mark content as a PDF artifact. +// TODO: maybe generalize this and use it to mark html elements with `aria-hidden="true"`? +#[elem(Locatable)] +pub struct ArtifactElem { + /// The artifact kind. + #[default(ArtifactKind::Other)] + pub kind: ArtifactKind, + + /// The content that is an artifact. + #[required] + pub body: Content, +} + +/// The type of artifact. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Cast)] +pub enum ArtifactKind { + /// Page header artifacts. + Header, + /// Page footer artifacts. + Footer, + /// Page artifacts, such as cut marks or color bars. + Page, + /// Other artifacts. + #[default] + Other, +} diff --git a/crates/typst-library/src/pdf/mod.rs b/crates/typst-library/src/pdf/mod.rs index 7b7f5581fa..aae9e57bbc 100644 --- a/crates/typst-library/src/pdf/mod.rs +++ b/crates/typst-library/src/pdf/mod.rs @@ -1,7 +1,9 @@ //! PDF-specific functionality. +mod accessibility; mod attach; +pub use self::accessibility::*; pub use self::attach::*; use crate::foundations::{Deprecation, Element, Module, Scope}; @@ -17,5 +19,6 @@ pub fn module() -> Module { .with_message("the name `embed` is deprecated, use `attach` instead") .with_until("0.15.0"), ); + pdf.define_elem::(); Module::new("pdf", pdf) } diff --git a/crates/typst-pdf/src/convert.rs b/crates/typst-pdf/src/convert.rs index c8d2b7fc3d..51ed603d62 100644 --- a/crates/typst-pdf/src/convert.rs +++ b/crates/typst-pdf/src/convert.rs @@ -15,7 +15,7 @@ use krilla_svg::render_svg_glyph; use rustc_hash::{FxHashMap, FxHashSet}; use typst_library::diag::{SourceDiagnostic, SourceResult, bail, error}; use typst_library::foundations::{NativeElement, Repr}; -use typst_library::introspection::Location; +use typst_library::introspection::{Location, Tag}; use typst_library::layout::{ Frame, FrameItem, GroupItem, PagedDocument, Size, Transform, }; @@ -32,6 +32,7 @@ use crate::metadata::build_metadata; use crate::outline::build_outline; use crate::page::PageLabelExt; use crate::shape::handle_shape; +use crate::tags::{self, Tags}; use crate::text::handle_text; use crate::util::{AbsExt, TransformExt, convert_path, display_font}; @@ -47,7 +48,7 @@ pub fn convert( xmp_metadata: true, cmyk_profile: None, configuration: options.standards.config, - enable_tagging: false, + enable_tagging: !options.disable_tags, render_svg_glyph_fn: render_svg_glyph, }; @@ -55,6 +56,7 @@ pub fn convert( let page_index_converter = PageIndexConverter::new(typst_document, options); let named_destinations = collect_named_destinations(typst_document, &page_index_converter); + let mut gc = GlobalContext::new( typst_document, options, @@ -67,6 +69,7 @@ pub fn convert( document.set_outline(build_outline(&gc)); document.set_metadata(build_metadata(&gc)); + document.set_tag_tree(gc.tags.build_tree()); finish(document, gc, options.standards.config) } @@ -226,6 +229,8 @@ pub(crate) struct GlobalContext<'a> { /// The languages used throughout the document. pub(crate) languages: BTreeMap, pub(crate) page_index_converter: PageIndexConverter, + /// Tagged PDF context. + pub(crate) tags: Tags, } impl<'a> GlobalContext<'a> { @@ -245,6 +250,8 @@ impl<'a> GlobalContext<'a> { image_spans: FxHashSet::default(), languages: BTreeMap::new(), page_index_converter, + + tags: Tags::new(), } } } @@ -279,8 +286,9 @@ pub(crate) fn handle_frame( FrameItem::Image(image, size, span) => { handle_image(gc, fc, image, *size, surface, *span)? } - FrameItem::Link(d, s) => handle_link(fc, gc, d, *s), - FrameItem::Tag(_) => {} + FrameItem::Link(dest, size) => handle_link(fc, gc, dest, *size), + FrameItem::Tag(Tag::Start(elem)) => tags::handle_start(gc, elem), + FrameItem::Tag(Tag::End(loc, _)) => tags::handle_end(gc, *loc), } fc.pop(); @@ -295,7 +303,7 @@ pub(crate) fn handle_group( fc: &mut FrameContext, group: &GroupItem, surface: &mut Surface, - context: &mut GlobalContext, + gc: &mut GlobalContext, ) -> SourceResult<()> { fc.push(); fc.state_mut().pre_concat(group.transform); @@ -314,7 +322,7 @@ pub(crate) fn handle_group( surface.push_clip_path(clip_path, &krilla::paint::FillRule::NonZero); } - handle_frame(fc, &group.frame, None, surface, context)?; + handle_frame(fc, &group.frame, None, surface, gc)?; if clip_path.is_some() { surface.pop(); diff --git a/crates/typst-pdf/src/image.rs b/crates/typst-pdf/src/image.rs index 8f28873002..5a0ed2d37a 100644 --- a/crates/typst-pdf/src/image.rs +++ b/crates/typst-pdf/src/image.rs @@ -5,6 +5,7 @@ use image::{DynamicImage, EncodableLayout, GenericImageView, Rgba}; use krilla::image::{BitsPerComponent, CustomImage, ImageColorspace}; use krilla::pdf::PdfDocument; use krilla::surface::Surface; +use krilla::tagging::SpanTag; use krilla_svg::{SurfaceExt, SvgSettings}; use typst_library::diag::{SourceResult, bail}; use typst_library::foundations::Smart; @@ -15,6 +16,7 @@ use typst_library::visualize::{ use typst_syntax::Span; use crate::convert::{FrameContext, GlobalContext}; +use crate::tags; use crate::util::{SizeExt, TransformExt}; #[typst_macros::time(name = "handle image")] @@ -31,12 +33,11 @@ pub(crate) fn handle_image( let interpolate = image.scaling() == Smart::Custom(ImageScaling::Smooth); - if let Some(alt) = image.alt() { - surface.start_alt_text(alt); - } - gc.image_spans.insert(span); + let mut handle = + tags::start_span(gc, surface, SpanTag::empty().with_alt_text(image.alt())); + let surface = handle.surface(); match image.kind() { ImageKind::Raster(raster) => { let (exif_transform, new_size) = exif_transform(raster, size); @@ -66,10 +67,6 @@ pub(crate) fn handle_image( } } - if image.alt().is_some() { - surface.end_alt_text(); - } - surface.pop(); surface.reset_location(); diff --git a/crates/typst-pdf/src/lib.rs b/crates/typst-pdf/src/lib.rs index 7beeacaa22..c2829ed045 100644 --- a/crates/typst-pdf/src/lib.rs +++ b/crates/typst-pdf/src/lib.rs @@ -9,6 +9,7 @@ mod outline; mod page; mod paint; mod shape; +mod tags; mod text; mod util; @@ -53,6 +54,11 @@ pub struct PdfOptions<'a> { pub page_ranges: Option, /// A list of PDF standards that Typst will enforce conformance with. pub standards: PdfStandards, + /// By default, even when not producing a `PDF/UA-1` document, a tagged PDF + /// document is written to provide a baseline of accessibility. In some + /// circumstances, for example when trying to reduce the size of a document, + /// it can be desirable to disable tagged PDF. + pub disable_tags: bool, } /// Encapsulates a list of compatible PDF standards. @@ -104,6 +110,7 @@ impl PdfStandards { PdfStandard::A_4 => set_validator(Validator::A4)?, PdfStandard::A_4f => set_validator(Validator::A4F)?, PdfStandard::A_4e => set_validator(Validator::A4E)?, + PdfStandard::Ua_1 => set_validator(Validator::UA1)?, } } @@ -187,4 +194,7 @@ pub enum PdfStandard { /// PDF/A-4e. #[serde(rename = "a-4e")] A_4e, + /// PDF/UA-1. + #[serde(rename = "ua-1")] + Ua_1, } diff --git a/crates/typst-pdf/src/shape.rs b/crates/typst-pdf/src/shape.rs index f3419c6cce..56453cfaf0 100644 --- a/crates/typst-pdf/src/shape.rs +++ b/crates/typst-pdf/src/shape.rs @@ -5,8 +5,8 @@ use typst_library::visualize::{Geometry, Shape}; use typst_syntax::Span; use crate::convert::{FrameContext, GlobalContext}; -use crate::paint; use crate::util::{AbsExt, TransformExt, convert_path}; +use crate::{paint, tags}; #[typst_macros::time(name = "handle shape")] pub(crate) fn handle_shape( @@ -16,6 +16,9 @@ pub(crate) fn handle_shape( gc: &mut GlobalContext, span: Span, ) -> SourceResult<()> { + let mut handle = tags::start_marked(gc, surface); + let surface = handle.surface(); + surface.set_location(span.into_raw()); surface.push_transform(&fc.state().transform().to_krilla()); diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs new file mode 100644 index 0000000000..89c2aff5cf --- /dev/null +++ b/crates/typst-pdf/src/tags/mod.rs @@ -0,0 +1,229 @@ +use std::num::NonZeroU16; + +use krilla::surface::Surface; +use krilla::tagging::{ + ArtifactType, ContentTag, Identifier, Node, SpanTag, Tag, TagGroup, TagKind, TagTree, +}; +use typst_library::foundations::{Content, StyleChain}; +use typst_library::introspection::Location; +use typst_library::layout::RepeatElem; +use typst_library::model::{FigureCaption, FigureElem, HeadingElem, Outlinable}; +use typst_library::pdf::{ArtifactElem, ArtifactKind}; +use typst_library::visualize::ImageElem; + +use crate::convert::GlobalContext; + +pub struct Tags { + /// The intermediary stack of nested tag groups. + pub stack: Vec, + pub in_artifact: Option<(Location, ArtifactKind)>, + + /// The output. + pub tree: Vec, +} + +#[derive(Debug)] +pub struct StackEntry { + pub loc: Location, + pub kind: StackEntryKind, + pub nodes: Vec, +} + +#[derive(Debug)] +pub enum StackEntryKind { + Standard(TagKind), +} + +#[derive(Debug)] +pub enum TagNode { + Group(TagKind, Vec), + Leaf(Identifier), +} + +impl TagNode { + pub fn group(tag: impl Into, children: Vec) -> Self { + TagNode::Group(tag.into(), children) + } +} + +impl Tags { + pub fn new() -> Self { + Self { + stack: Vec::new(), + in_artifact: None, + + tree: Vec::new(), + } + } + + /// Returns the current parent's list of children and the structure type ([Tag]). + /// In case of the document root the structure type will be `None`. + pub fn parent(&mut self) -> Option<&mut StackEntryKind> { + self.stack.last_mut().map(|e| &mut e.kind) + } + + pub fn push(&mut self, node: TagNode) { + if let Some(entry) = self.stack.last_mut() { + entry.nodes.push(node); + } else { + self.tree.push(node); + } + } + + pub fn build_tree(&mut self) -> TagTree { + assert!(self.stack.is_empty(), "tags weren't properly closed"); + + let children = std::mem::take(&mut self.tree) + .into_iter() + .map(|node| self.resolve_node(node)) + .collect::>(); + TagTree::from(children) + } + + /// Resolves [`Placeholder`] nodes. + fn resolve_node(&mut self, node: TagNode) -> Node { + match node { + TagNode::Group(tag, nodes) => { + let children = nodes + .into_iter() + .map(|node| self.resolve_node(node)) + .collect::>(); + Node::Group(TagGroup::with_children(tag, children)) + } + TagNode::Leaf(identifier) => Node::Leaf(identifier), + } + } +} + +/// Automatically calls [`Surface::end_tagged`] when dropped. +pub struct TagHandle<'a, 'b> { + surface: &'b mut Surface<'a>, +} + +impl Drop for TagHandle<'_, '_> { + fn drop(&mut self) { + self.surface.end_tagged(); + } +} + +impl<'a> TagHandle<'a, '_> { + pub fn surface<'c>(&'c mut self) -> &'c mut Surface<'a> { + self.surface + } +} + +/// Returns a [`TagHandle`] that automatically calls [`Surface::end_tagged`] +/// when dropped. +pub fn start_marked<'a, 'b>( + gc: &mut GlobalContext, + surface: &'b mut Surface<'a>, +) -> TagHandle<'a, 'b> { + start_content(gc, surface, ContentTag::Other) +} + +/// Returns a [`TagHandle`] that automatically calls [`Surface::end_tagged`] +/// when dropped. +pub fn start_span<'a, 'b>( + gc: &mut GlobalContext, + surface: &'b mut Surface<'a>, + span: SpanTag, +) -> TagHandle<'a, 'b> { + start_content(gc, surface, ContentTag::Span(span)) +} + +fn start_content<'a, 'b>( + gc: &mut GlobalContext, + surface: &'b mut Surface<'a>, + content: ContentTag, +) -> TagHandle<'a, 'b> { + let content = if let Some((_, kind)) = gc.tags.in_artifact { + let ty = artifact_type(kind); + ContentTag::Artifact(ty) + } else { + content + }; + let id = surface.start_tagged(content); + gc.tags.push(TagNode::Leaf(id)); + TagHandle { surface } +} + +pub fn handle_start(gc: &mut GlobalContext, elem: &Content) { + if gc.tags.in_artifact.is_some() { + // Don't nest artifacts + return; + } + + let loc = elem.location().expect("elem to be locatable"); + + if let Some(artifact) = elem.to_packed::() { + let kind = artifact.kind.get(StyleChain::default()); + start_artifact(gc, loc, kind); + return; + } else if let Some(_) = elem.to_packed::() { + start_artifact(gc, loc, ArtifactKind::Other); + return; + } + + let tag = if let Some(heading) = elem.to_packed::() { + let level = heading.level().try_into().unwrap_or(NonZeroU16::MAX); + let name = heading.body.plain_text().to_string(); + Tag::Hn(level, Some(name)).into() + } else if let Some(figure) = elem.to_packed::() { + let alt = figure.alt.get_cloned(StyleChain::default()).map(|s| s.to_string()); + Tag::Figure(alt).into() + } else if let Some(image) = elem.to_packed::() { + let alt = image.alt.get_cloned(StyleChain::default()).map(|s| s.to_string()); + + if let Some(StackEntryKind::Standard(TagKind::Figure(tag))) = gc.tags.parent() { + // Set alt text of outer figure tag, if not present. + if tag.alt_text().is_none() { + tag.set_alt_text(alt); + } + return; + } else { + Tag::Figure(alt).into() + } + } else if let Some(_) = elem.to_packed::() { + Tag::Caption.into() + } else { + return; + }; + + push_stack(gc, loc, StackEntryKind::Standard(tag)); +} + +fn push_stack(gc: &mut GlobalContext, loc: Location, kind: StackEntryKind) { + gc.tags.stack.push(StackEntry { loc, kind, nodes: Vec::new() }); +} + +pub fn handle_end(gc: &mut GlobalContext, loc: Location) { + if let Some((l, _)) = gc.tags.in_artifact { + if l == loc { + gc.tags.in_artifact = None; + } + return; + } + + let Some(entry) = gc.tags.stack.pop_if(|e| e.loc == loc) else { + return; + }; + + let node = match entry.kind { + StackEntryKind::Standard(tag) => TagNode::group(tag, entry.nodes), + }; + + gc.tags.push(node); +} + +fn start_artifact(gc: &mut GlobalContext, loc: Location, kind: ArtifactKind) { + gc.tags.in_artifact = Some((loc, kind)); +} + +fn artifact_type(kind: ArtifactKind) -> ArtifactType { + match kind { + ArtifactKind::Header => ArtifactType::Header, + ArtifactKind::Footer => ArtifactType::Footer, + ArtifactKind::Page => ArtifactType::Page, + ArtifactKind::Other => ArtifactType::Other, + } +} diff --git a/crates/typst-pdf/src/text.rs b/crates/typst-pdf/src/text.rs index b4ace94013..632b716a61 100644 --- a/crates/typst-pdf/src/text.rs +++ b/crates/typst-pdf/src/text.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use bytemuck::TransparentWrapper; use krilla::surface::{Location, Surface}; +use krilla::tagging::SpanTag; use krilla::text::GlyphId; use typst_library::diag::{SourceResult, bail}; use typst_library::layout::Size; @@ -11,8 +12,8 @@ use typst_library::visualize::FillRule; use typst_syntax::Span; use crate::convert::{FrameContext, GlobalContext}; -use crate::paint; use crate::util::{AbsExt, TransformExt, display_font}; +use crate::{paint, tags}; #[typst_macros::time(name = "handle text")] pub(crate) fn handle_text( @@ -23,6 +24,9 @@ pub(crate) fn handle_text( ) -> SourceResult<()> { *gc.languages.entry(t.lang).or_insert(0) += t.glyphs.len(); + let mut handle = tags::start_span(gc, surface, SpanTag::empty()); + let surface = handle.surface(); + let font = convert_font(gc, t.font.clone())?; let fill = paint::convert_fill( gc, diff --git a/crates/typst-render/src/lib.rs b/crates/typst-render/src/lib.rs index 8856b08655..cef19dfdcf 100644 --- a/crates/typst-render/src/lib.rs +++ b/crates/typst-render/src/lib.rs @@ -164,7 +164,7 @@ fn render_frame(canvas: &mut sk::Pixmap, state: State, frame: &Frame) { FrameItem::Image(image, size, _) => { image::render_image(canvas, state.pre_translate(*pos), image, *size); } - FrameItem::Link(_, _) => {} + FrameItem::Link(..) => {} FrameItem::Tag(_) => {} } } From 90a94dab9156babf89c5a640ce4770f13f537cbe Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Wed, 24 Sep 2025 14:09:12 +0200 Subject: [PATCH 397/558] Generate tags for links - Introduce LinkMarker element that wraps all kinds of links and carries an optional alternative description of the link - Use quadpoints for link annotations when targeting PDF/UA-1 --- crates/typst-layout/src/rules.rs | 30 ++-- .../src/foundations/content/mod.rs | 10 +- .../typst-library/src/model/bibliography.rs | 6 +- crates/typst-library/src/model/footnote.rs | 3 +- crates/typst-library/src/model/link.rs | 25 ++++ crates/typst-library/src/model/reference.rs | 2 +- crates/typst-pdf/src/convert.rs | 22 +-- crates/typst-pdf/src/link.rs | 133 ++++++++++-------- crates/typst-pdf/src/tags/mod.rs | 83 ++++++++++- crates/typst-render/src/lib.rs | 2 +- 10 files changed, 225 insertions(+), 91 deletions(-) diff --git a/crates/typst-layout/src/rules.rs b/crates/typst-layout/src/rules.rs index 640f07a941..f7980e29a0 100644 --- a/crates/typst-layout/src/rules.rs +++ b/crates/typst-layout/src/rules.rs @@ -3,8 +3,8 @@ use ecow::EcoVec; use smallvec::smallvec; use typst_library::diag::{At, SourceResult, bail}; use typst_library::foundations::{ - Content, Context, NativeElement, NativeRuleMap, Packed, Resolve, ShowFn, - Smart, StyleChain, Target, dict, + Content, Context, NativeElement, NativeRuleMap, Packed, Resolve, ShowFn, Smart, + StyleChain, Target, dict, }; use typst_library::introspection::{Counter, Locator, LocatorLink}; use typst_library::layout::{ @@ -18,9 +18,9 @@ use typst_library::math::EquationElem; use typst_library::model::{ Attribution, BibliographyElem, CiteElem, CiteGroup, CslIndentElem, CslLightElem, Destination, DirectLinkElem, EmphElem, EnumElem, FigureCaption, FigureElem, - FootnoteElem, FootnoteEntry, HeadingElem, LinkElem, ListElem, OutlineElem, - OutlineEntry, ParElem, ParbreakElem, QuoteElem, RefElem, StrongElem, TableCell, - TableElem, TermsElem, TitleElem, Works, + FootnoteElem, FootnoteEntry, HeadingElem, LinkElem, LinkMarker, ListElem, + OutlineElem, OutlineEntry, ParElem, ParbreakElem, QuoteElem, RefElem, StrongElem, + TableCell, TableElem, TermsElem, TitleElem, Works, }; use typst_library::pdf::{ArtifactElem, AttachElem}; use typst_library::text::{ @@ -44,6 +44,7 @@ pub fn register(rules: &mut NativeRuleMap) { rules.register(Paged, LIST_RULE); rules.register(Paged, ENUM_RULE); rules.register(Paged, TERMS_RULE); + rules.register(Paged, LINK_MARKER_RULE); rules.register(Paged, LINK_RULE); rules.register(Paged, DIRECT_LINK_RULE); rules.register(Paged, TITLE_RULE); @@ -213,14 +214,19 @@ const TERMS_RULE: ShowFn = |elem, _, styles| { Ok(realized) }; -const LINK_RULE: ShowFn = |elem, engine, _| { +const LINK_MARKER_RULE: ShowFn = |elem, _, _| Ok(elem.body.clone()); + +const LINK_RULE: ShowFn = |elem, engine, styles| { let body = elem.body.clone(); let dest = elem.dest.resolve(engine.introspector).at(elem.span())?; - Ok(body.linked(dest)) + let alt = elem.alt.get_cloned(styles); + Ok(body.linked(dest, alt)) }; -const DIRECT_LINK_RULE: ShowFn = - |elem, _, _| Ok(elem.body.clone().linked(Destination::Location(elem.loc))); +const DIRECT_LINK_RULE: ShowFn = |elem, _, _| { + let dest = Destination::Location(elem.loc); + Ok(elem.body.clone().linked(dest, elem.alt.clone())) +}; const TITLE_RULE: ShowFn = |elem, _, styles| { Ok(BlockElem::new() @@ -383,7 +389,8 @@ const FOOTNOTE_RULE: ShowFn = |elem, engine, styles| { let span = elem.span(); let (dest, num) = elem.realize(engine, styles)?; let sup = SuperElem::new(num).pack().spanned(span); - Ok(HElem::hole().clone() + sup.linked(dest)) + // TODO: generate alt text + Ok(HElem::hole().clone() + sup.linked(dest, None)) }; const FOOTNOTE_ENTRY_RULE: ShowFn = |elem, engine, styles| { @@ -424,7 +431,8 @@ const OUTLINE_ENTRY_RULE: ShowFn = |elem, engine, styles| { }; let loc = elem.element_location().at(span)?; - Ok(block.linked(Destination::Location(loc))) + // TODO: generate alt text + Ok(block.linked(Destination::Location(loc), None)) }; const REF_RULE: ShowFn = |elem, engine, styles| elem.realize(engine, styles); diff --git a/crates/typst-library/src/foundations/content/mod.rs b/crates/typst-library/src/foundations/content/mod.rs index 4bf56d61e8..2e43dc6b34 100644 --- a/crates/typst-library/src/foundations/content/mod.rs +++ b/crates/typst-library/src/foundations/content/mod.rs @@ -31,7 +31,7 @@ use crate::foundations::{ }; use crate::introspection::Location; use crate::layout::{AlignElem, Alignment, Axes, Length, MoveElem, PadElem, Rel, Sides}; -use crate::model::{Destination, EmphElem, LinkElem, StrongElem}; +use crate::model::{Destination, EmphElem, LinkElem, LinkMarker, StrongElem}; use crate::pdf::{ArtifactElem, ArtifactKind}; use crate::text::UnderlineElem; @@ -465,8 +465,12 @@ impl Content { } /// Link the content somewhere. - pub fn linked(self, dest: Destination) -> Self { - self.set(LinkElem::current, Some(dest)) + pub fn linked(self, dest: Destination, alt: Option) -> Self { + let span = self.span(); + LinkMarker::new(self, alt) + .pack() + .spanned(span) + .set(LinkElem::current, Some(dest)) } /// Set alignments for this content. diff --git a/crates/typst-library/src/model/bibliography.rs b/crates/typst-library/src/model/bibliography.rs index 79cd9d22a7..5e782d126c 100644 --- a/crates/typst-library/src/model/bibliography.rs +++ b/crates/typst-library/src/model/bibliography.rs @@ -882,7 +882,8 @@ impl<'a> Generator<'a> { let prefix = prefix.map(|content| { if let Some(location) = first_occurrences.get(item.key.as_str()) { - DirectLinkElem::new(*location, content).pack() + // TODO: generate alt text + DirectLinkElem::new(*location, content, None).pack() } else { content } @@ -1003,7 +1004,8 @@ impl ElemRenderer<'_> { if let Some(hayagriva::ElemMeta::Entry(i)) = elem.meta && let Some(location) = (self.link)(i) { - content = DirectLinkElem::new(location, content).pack(); + // TODO: generate alt text + content = DirectLinkElem::new(location, content, None).pack(); } Ok(content) diff --git a/crates/typst-library/src/model/footnote.rs b/crates/typst-library/src/model/footnote.rs index 7841bb2f17..fdf3a097d7 100644 --- a/crates/typst-library/src/model/footnote.rs +++ b/crates/typst-library/src/model/footnote.rs @@ -294,7 +294,8 @@ impl Packed { let num = counter.display_at_loc(engine, loc, styles, numbering)?; let sup = SuperElem::new(num).pack().spanned(span); - let prefix = DirectLinkElem::new(loc, sup).pack().spanned(span); + // TODO: generate alt text + let prefix = DirectLinkElem::new(loc, sup, None).pack().spanned(span); let body = self.note.body_content().unwrap().clone(); Ok((prefix, body)) diff --git a/crates/typst-library/src/model/link.rs b/crates/typst-library/src/model/link.rs index 18867b803b..5256ce01f2 100644 --- a/crates/typst-library/src/model/link.rs +++ b/crates/typst-library/src/model/link.rs @@ -87,6 +87,9 @@ use crate::text::TextElem; /// generated. #[elem(Locatable)] pub struct LinkElem { + /// A text describing the link. + pub alt: Option, + /// The destination the link points to. /// /// - To link to web pages, `dest` should be a valid URL string. If the URL @@ -287,6 +290,9 @@ pub struct DirectLinkElem { #[required] #[internal] pub body: Content, + #[required] + #[internal] + pub alt: Option, } impl Construct for DirectLinkElem { @@ -294,3 +300,22 @@ impl Construct for DirectLinkElem { bail!(args.span, "cannot be constructed manually"); } } + +/// An element that wraps all conent that is [`Content::linked`] to a +/// destination. +#[elem(Locatable, Construct)] +pub struct LinkMarker { + /// The content. + #[internal] + #[required] + pub body: Content, + #[internal] + #[required] + pub alt: Option, +} + +impl Construct for LinkMarker { + fn construct(_: &mut Engine, args: &mut Args) -> SourceResult { + bail!(args.span, "cannot be constructed manually"); + } +} diff --git a/crates/typst-library/src/model/reference.rs b/crates/typst-library/src/model/reference.rs index bb6f9a79a0..74c378f1dc 100644 --- a/crates/typst-library/src/model/reference.rs +++ b/crates/typst-library/src/model/reference.rs @@ -346,7 +346,7 @@ fn realize_reference( content = supplement + TextElem::packed("\u{a0}") + content; } - Ok(DirectLinkElem::new(loc, content).pack()) + Ok(DirectLinkElem::new(loc, content, None).pack()) } /// Turn a reference into a citation. diff --git a/crates/typst-pdf/src/convert.rs b/crates/typst-pdf/src/convert.rs index 51ed603d62..29ee13b91c 100644 --- a/crates/typst-pdf/src/convert.rs +++ b/crates/typst-pdf/src/convert.rs @@ -1,7 +1,6 @@ use std::collections::BTreeMap; use ecow::{EcoVec, eco_format}; -use krilla::annotation::Annotation; use krilla::configure::{Configuration, ValidationError, Validator}; use krilla::destination::NamedDestination; use krilla::embed::EmbedError; @@ -27,7 +26,7 @@ use typst_syntax::Span; use crate::PdfOptions; use crate::attach::attach_files; use crate::image::handle_image; -use crate::link::handle_link; +use crate::link::{LinkAnnotation, handle_link}; use crate::metadata::build_metadata; use crate::outline::build_outline; use crate::page::PageLabelExt; @@ -119,9 +118,7 @@ fn convert_pages(gc: &mut GlobalContext, document: &mut Document) -> SourceResul surface.finish(); - for annotation in fc.annotations { - page.add_annotation(annotation); - } + tags::add_link_annotations(gc, &mut page, fc.link_annotations); } } @@ -175,14 +172,14 @@ impl State { /// Context needed for converting a single frame. pub(crate) struct FrameContext { states: Vec, - annotations: Vec, + link_annotations: Vec, } impl FrameContext { pub(crate) fn new(size: Size) -> Self { Self { states: vec![State::new(size)], - annotations: vec![], + link_annotations: Vec::new(), } } @@ -202,8 +199,15 @@ impl FrameContext { self.states.last_mut().unwrap() } - pub(crate) fn push_annotation(&mut self, annotation: Annotation) { - self.annotations.push(annotation); + pub(crate) fn get_link_annotation( + &mut self, + link_id: tags::LinkId, + ) -> Option<&mut LinkAnnotation> { + self.link_annotations.iter_mut().rfind(|a| a.id == link_id) + } + + pub(crate) fn push_link_annotation(&mut self, annotation: LinkAnnotation) { + self.link_annotations.push(annotation); } } diff --git a/crates/typst-pdf/src/link.rs b/crates/typst-pdf/src/link.rs index ef104f6455..389ea4f8c4 100644 --- a/crates/typst-pdf/src/link.rs +++ b/crates/typst-pdf/src/link.rs @@ -1,92 +1,103 @@ +use ecow::EcoString; use krilla::action::{Action, LinkAction}; -use krilla::annotation::{LinkAnnotation, Target}; +use krilla::annotation::Target; +use krilla::configure::Validator; use krilla::destination::XyzDestination; -use krilla::geom::Rect; +use krilla::geom as kg; use typst_library::layout::{Abs, Point, Position, Size}; use typst_library::model::Destination; use crate::convert::{FrameContext, GlobalContext, PageIndexConverter}; +use crate::tags::{LinkId, Placeholder, TagNode}; use crate::util::{AbsExt, PointExt}; +pub struct LinkAnnotation { + pub id: LinkId, + pub placeholder: Placeholder, + pub alt: Option, + pub quad_points: Vec, + pub target: Target, +} + pub(crate) fn handle_link( fc: &mut FrameContext, gc: &mut GlobalContext, dest: &Destination, size: Size, ) { - let mut min_x = Abs::inf(); - let mut min_y = Abs::inf(); - let mut max_x = -Abs::inf(); - let mut max_y = -Abs::inf(); - - let pos = Point::zero(); - - // Compute the bounding box of the transformed link. - for point in [ - pos, - pos + Point::with_x(size.x), - pos + Point::with_y(size.y), - pos + size.to_point(), - ] { - let t = point.transform(fc.state().transform()); - min_x.set_min(t.x); - min_y.set_min(t.y); - max_x.set_max(t.x); - max_y.set_max(t.y); - } - - let x1 = min_x.to_f32(); - let x2 = max_x.to_f32(); - let y1 = min_y.to_f32(); - let y2 = max_y.to_f32(); - - let rect = Rect::from_ltrb(x1, y1, x2, y2).unwrap(); - - // TODO: Support quad points. - - let pos = match dest { + let target = match dest { Destination::Url(u) => { - fc.push_annotation( - LinkAnnotation::new( - rect, - Target::Action(Action::Link(LinkAction::new(u.to_string()))), - ) - .into(), - ); - return; + Target::Action(Action::Link(LinkAction::new(u.to_string()))) + } + Destination::Position(p) => { + let Some(dest) = pos_to_xyz(&gc.page_index_converter, *p) else { return }; + Target::Destination(krilla::destination::Destination::Xyz(dest)) } - Destination::Position(p) => *p, Destination::Location(loc) => { if let Some(nd) = gc.loc_to_names.get(loc) { // If a named destination has been registered, it's already guaranteed to // not point to an excluded page. - fc.push_annotation( - LinkAnnotation::new( - rect, - Target::Destination(krilla::destination::Destination::Named( - nd.clone(), - )), - ) - .into(), - ); - return; + Target::Destination(krilla::destination::Destination::Named(nd.clone())) } else { - gc.document.introspector.position(*loc) + let pos = gc.document.introspector.position(*loc); + let Some(dest) = pos_to_xyz(&gc.page_index_converter, pos) else { + return; + }; + Target::Destination(krilla::destination::Destination::Xyz(dest)) } } }; - if let Some(xyz) = pos_to_xyz(&gc.page_index_converter, pos) { - fc.push_annotation( - LinkAnnotation::new( - rect, - Target::Destination(krilla::destination::Destination::Xyz(xyz)), - ) - .into(), - ); + let Some((link_id, link)) = gc.tags.find_parent_link() else { + unreachable!("expected a link parent") + }; + let alt = link.alt.as_ref().map(EcoString::to_string); + + let quad = to_quadrilateral(fc, size); + + // Unfortunately quadpoints still aren't well supported by most PDF readers. + // So only add multiple quadpoint entries to one annotation when targeting + // PDF/UA. Otherwise generate multiple annotations, to avoid pdf readers + // falling back to the bounding box rectangle, which can span parts unrelated + // to the link. For example if there is a linebreak: + // ``` + // Imagine this is a paragraph containing a link. It starts here https://github.com/ + // typst/typst and then ends on another line. + // ``` + // The bounding box would span the entire paragraph, which is undesirable. + let join_annotations = gc.options.standards.config.validator() == Validator::UA1; + match fc.get_link_annotation(link_id) { + Some(annotation) if join_annotations => annotation.quad_points.push(quad), + _ => { + let placeholder = gc.tags.reserve_placeholder(); + gc.tags.push(TagNode::Placeholder(placeholder)); + fc.push_link_annotation(LinkAnnotation { + id: link_id, + placeholder, + quad_points: vec![quad], + alt, + target, + }); + } } } +/// Compute the quadrilateral representing the transformed rectangle of this frame. +fn to_quadrilateral(fc: &FrameContext, size: Size) -> kg::Quadrilateral { + let pos = Point::zero(); + let points = [ + pos + Point::with_y(size.y), + pos + size.to_point(), + pos + Point::with_x(size.x), + pos, + ]; + + kg::Quadrilateral(points.map(|point| { + let p = point.transform(fc.state().transform()); + kg::Point::from_xy(p.x.to_f32(), p.y.to_f32()) + })) +} + /// Turns a position link into a PDF XYZ destination. /// /// - Takes into account page index conversion (if only part of the document is diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index 89c2aff5cf..402079e207 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -1,22 +1,30 @@ +use std::cell::OnceCell; use std::num::NonZeroU16; +use krilla::page::Page; use krilla::surface::Surface; use krilla::tagging::{ ArtifactType, ContentTag, Identifier, Node, SpanTag, Tag, TagGroup, TagKind, TagTree, }; -use typst_library::foundations::{Content, StyleChain}; +use typst_library::foundations::{Content, Packed, StyleChain}; use typst_library::introspection::Location; use typst_library::layout::RepeatElem; -use typst_library::model::{FigureCaption, FigureElem, HeadingElem, Outlinable}; +use typst_library::model::{ + FigureCaption, FigureElem, HeadingElem, LinkMarker, Outlinable, +}; use typst_library::pdf::{ArtifactElem, ArtifactKind}; use typst_library::visualize::ImageElem; use crate::convert::GlobalContext; +use crate::link::LinkAnnotation; pub struct Tags { /// The intermediary stack of nested tag groups. pub stack: Vec, + /// A list of placeholders corresponding to a [`TagNode::Placeholder`]. + pub placeholders: Vec>, pub in_artifact: Option<(Location, ArtifactKind)>, + pub link_id: LinkId, /// The output. pub tree: Vec, @@ -32,12 +40,22 @@ pub struct StackEntry { #[derive(Debug)] pub enum StackEntryKind { Standard(TagKind), + Link(LinkId, Packed), +} + +impl StackEntryKind { + pub fn as_link(&self) -> Option<(LinkId, &Packed)> { + if let Self::Link(id, link) = self { Some((*id, link)) } else { None } + } } #[derive(Debug)] pub enum TagNode { Group(TagKind, Vec), Leaf(Identifier), + /// Allows inserting a placeholder into the tag tree. + /// Currently used for [`krilla::page::Page::add_tagged_annotation`]. + Placeholder(Placeholder), } impl TagNode { @@ -46,22 +64,53 @@ impl TagNode { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LinkId(u32); + +#[derive(Clone, Copy, Debug)] +pub struct Placeholder(usize); + impl Tags { pub fn new() -> Self { Self { stack: Vec::new(), + placeholders: Vec::new(), in_artifact: None, tree: Vec::new(), + link_id: LinkId(0), } } + pub fn reserve_placeholder(&mut self) -> Placeholder { + let idx = self.placeholders.len(); + self.placeholders.push(OnceCell::new()); + Placeholder(idx) + } + + pub fn init_placeholder(&mut self, placeholder: Placeholder, node: Node) { + self.placeholders[placeholder.0] + .set(node) + .map_err(|_| ()) + .expect("placeholder to be uninitialized"); + } + + pub fn take_placeholder(&mut self, placeholder: Placeholder) -> Node { + self.placeholders[placeholder.0] + .take() + .expect("initialized placeholder node") + } + /// Returns the current parent's list of children and the structure type ([Tag]). /// In case of the document root the structure type will be `None`. pub fn parent(&mut self) -> Option<&mut StackEntryKind> { self.stack.last_mut().map(|e| &mut e.kind) } + pub fn find_parent_link(&self) -> Option<(LinkId, &Packed)> { + self.stack.iter().rev().find_map(|entry| entry.kind.as_link()) + } + pub fn push(&mut self, node: TagNode) { if let Some(entry) = self.stack.last_mut() { entry.nodes.push(node); @@ -91,8 +140,14 @@ impl Tags { Node::Group(TagGroup::with_children(tag, children)) } TagNode::Leaf(identifier) => Node::Leaf(identifier), + TagNode::Placeholder(placeholder) => self.take_placeholder(placeholder), } } + + fn next_link_id(&mut self) -> LinkId { + self.link_id.0 += 1; + self.link_id + } } /// Automatically calls [`Surface::end_tagged`] when dropped. @@ -147,6 +202,25 @@ fn start_content<'a, 'b>( TagHandle { surface } } +/// Add all annotations that were found in the page frame. +pub fn add_link_annotations( + gc: &mut GlobalContext, + page: &mut Page, + annotations: Vec, +) { + for a in annotations.into_iter() { + let annotation = krilla::annotation::Annotation::new_link( + krilla::annotation::LinkAnnotation::new_with_quad_points( + a.quad_points, + a.target, + ), + a.alt, + ); + let annot_id = page.add_tagged_annotation(annotation); + gc.tags.init_placeholder(a.placeholder, Node::Leaf(annot_id)); + } +} + pub fn handle_start(gc: &mut GlobalContext, elem: &Content) { if gc.tags.in_artifact.is_some() { // Don't nest artifacts @@ -185,6 +259,10 @@ pub fn handle_start(gc: &mut GlobalContext, elem: &Content) { } } else if let Some(_) = elem.to_packed::() { Tag::Caption.into() + } else if let Some(link) = elem.to_packed::() { + let link_id = gc.tags.next_link_id(); + push_stack(gc, loc, StackEntryKind::Link(link_id, link.clone())); + return; } else { return; }; @@ -210,6 +288,7 @@ pub fn handle_end(gc: &mut GlobalContext, loc: Location) { let node = match entry.kind { StackEntryKind::Standard(tag) => TagNode::group(tag, entry.nodes), + StackEntryKind::Link(_, _) => TagNode::group(Tag::Link, entry.nodes), }; gc.tags.push(node); diff --git a/crates/typst-render/src/lib.rs b/crates/typst-render/src/lib.rs index cef19dfdcf..8856b08655 100644 --- a/crates/typst-render/src/lib.rs +++ b/crates/typst-render/src/lib.rs @@ -164,7 +164,7 @@ fn render_frame(canvas: &mut sk::Pixmap, state: State, frame: &Frame) { FrameItem::Image(image, size, _) => { image::render_image(canvas, state.pre_translate(*pos), image, *size); } - FrameItem::Link(..) => {} + FrameItem::Link(_, _) => {} FrameItem::Tag(_) => {} } } From 6804cd74b096b118a77a2d561975f74c5d4d26ea Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Wed, 24 Sep 2025 13:59:52 +0200 Subject: [PATCH 398/558] Generate tags for tables --- crates/typst-library/src/model/table.rs | 6 ++ crates/typst-pdf/src/tags/mod.rs | 101 +++++++++++++++++++++++- 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs index dd5eac6911..1cd90f2e38 100644 --- a/crates/typst-library/src/model/table.rs +++ b/crates/typst-library/src/model/table.rs @@ -1,6 +1,7 @@ use std::num::{NonZeroU32, NonZeroUsize}; use std::sync::Arc; +use ecow::EcoString; use typst_utils::NonZeroExt; use crate::diag::{HintedStrResult, HintedString, bail}; @@ -253,6 +254,11 @@ pub struct TableElem { #[default(Celled::Value(Sides::splat(Some(Some(Arc::new(Stroke::default()))))))] pub stroke: Celled>>>>, + /// A summary of the table's purpose and structure. + /// + /// This will be available for assistive techonologies (such as screen readers). + pub summary: Option, + /// The contents of the table cells, plus any extra table lines specified /// with the [`table.hline`] and [`table.vline`] elements. #[variadic] diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index 402079e207..65507991af 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -1,16 +1,18 @@ use std::cell::OnceCell; -use std::num::NonZeroU16; +use std::num::{NonZeroU16, NonZeroU32}; +use ecow::EcoString; use krilla::page::Page; use krilla::surface::Surface; use krilla::tagging::{ - ArtifactType, ContentTag, Identifier, Node, SpanTag, Tag, TagGroup, TagKind, TagTree, + ArtifactType, ContentTag, Identifier, Node, SpanTag, TableHeaderScope, Tag, TagGroup, + TagKind, TagTree, }; use typst_library::foundations::{Content, Packed, StyleChain}; use typst_library::introspection::Location; use typst_library::layout::RepeatElem; use typst_library::model::{ - FigureCaption, FigureElem, HeadingElem, LinkMarker, Outlinable, + FigureCaption, FigureElem, HeadingElem, LinkMarker, Outlinable, TableCell, TableElem, }; use typst_library::pdf::{ArtifactElem, ArtifactKind}; use typst_library::visualize::ImageElem; @@ -40,6 +42,8 @@ pub struct StackEntry { #[derive(Debug)] pub enum StackEntryKind { Standard(TagKind), + Table(TableCtx), + TableCell(Packed), Link(LinkId, Packed), } @@ -49,6 +53,66 @@ impl StackEntryKind { } } +#[derive(Debug)] +pub struct TableCtx { + table: Packed, + rows: Vec, TagKind, Vec)>>>, +} + +impl TableCtx { + fn insert(&mut self, cell: Packed, nodes: Vec) { + let x = cell.x.get(StyleChain::default()).unwrap_or_else(|| unreachable!()); + let y = cell.y.get(StyleChain::default()).unwrap_or_else(|| unreachable!()); + let rowspan = cell.rowspan.get(StyleChain::default()).get(); + let colspan = cell.colspan.get(StyleChain::default()).get(); + + let tag = { + // TODO: possibly set internal field on TableCell when resolving + // the cell grid. + let is_header = false; + let rowspan = + (rowspan != 1).then_some(NonZeroU32::new(rowspan as u32).unwrap()); + let colspan = + (colspan != 1).then_some(NonZeroU32::new(colspan as u32).unwrap()); + if is_header { + let scope = TableHeaderScope::Column; // TODO + Tag::TH(scope).with_row_span(rowspan).with_col_span(colspan).into() + } else { + Tag::TD.with_row_span(rowspan).with_col_span(colspan).into() + } + }; + + let required_height = y + rowspan; + if self.rows.len() < required_height { + self.rows.resize_with(required_height, Vec::new); + } + + let required_width = x + colspan; + let row = &mut self.rows[y]; + if row.len() < required_width { + row.resize_with(required_width, || None); + } + + row[x] = Some((cell, tag, nodes)); + } + + fn build_table(self, mut nodes: Vec) -> Vec { + // Table layouting ensures that there are no overlapping cells, and that + // any gaps left by the user are filled with empty cells. + for row in self.rows.into_iter() { + let mut row_nodes = Vec::new(); + for (_, tag, nodes) in row.into_iter().flatten() { + row_nodes.push(TagNode::group(tag, nodes)); + } + + // TODO: generate `THead`, `TBody`, and `TFoot` + nodes.push(TagNode::group(Tag::TR, row_nodes)); + } + + nodes + } +} + #[derive(Debug)] pub enum TagNode { Group(TagKind, Vec), @@ -194,6 +258,10 @@ fn start_content<'a, 'b>( let content = if let Some((_, kind)) = gc.tags.in_artifact { let ty = artifact_type(kind); ContentTag::Artifact(ty) + } else if let Some(StackEntryKind::Table(_)) = gc.tags.stack.last().map(|e| &e.kind) { + // Mark any direct child of a table as an aritfact. Any real content + // will be wrapped inside a `TableCell`. + ContentTag::Artifact(ArtifactType::Other) } else { content }; @@ -259,6 +327,13 @@ pub fn handle_start(gc: &mut GlobalContext, elem: &Content) { } } else if let Some(_) = elem.to_packed::() { Tag::Caption.into() + } else if let Some(table) = elem.to_packed::() { + let ctx = TableCtx { table: table.clone(), rows: Vec::new() }; + push_stack(gc, loc, StackEntryKind::Table(ctx)); + return; + } else if let Some(cell) = elem.to_packed::() { + push_stack(gc, loc, StackEntryKind::TableCell(cell.clone())); + return; } else if let Some(link) = elem.to_packed::() { let link_id = gc.tags.next_link_id(); push_stack(gc, loc, StackEntryKind::Link(link_id, link.clone())); @@ -288,6 +363,26 @@ pub fn handle_end(gc: &mut GlobalContext, loc: Location) { let node = match entry.kind { StackEntryKind::Standard(tag) => TagNode::group(tag, entry.nodes), + StackEntryKind::Table(ctx) => { + let summary = ctx + .table + .summary + .get_ref(StyleChain::default()) + .as_ref() + .map(EcoString::to_string); + let nodes = ctx.build_table(entry.nodes); + TagNode::group(Tag::Table.with_summary(summary), nodes) + } + StackEntryKind::TableCell(cell) => { + let parent = gc.tags.stack.last_mut().expect("table"); + let StackEntryKind::Table(table_ctx) = &mut parent.kind else { + unreachable!("expected table") + }; + + table_ctx.insert(cell, entry.nodes); + + return; + } StackEntryKind::Link(_, _) => TagNode::group(Tag::Link, entry.nodes), }; From ce525c1e26b0d5768577e4b19780f182adb7975b Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Tue, 1 Jul 2025 16:09:41 +0200 Subject: [PATCH 399/558] Manually generate tags for table and grid cells This fixes issues due to table/grid cell introspection tags not being handled properly in grid layout code --- crates/typst-layout/src/grid/layouter.rs | 11 ++++- crates/typst-layout/src/grid/mod.rs | 48 ++++++++++++++++++--- crates/typst-library/src/layout/grid/mod.rs | 2 +- crates/typst-library/src/model/table.rs | 2 +- 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index fb48a4f335..fe61a5568f 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -1316,14 +1316,21 @@ impl<'a> GridLayouter<'a> { let frames = layout_cell(cell, engine, locator, self.styles, pod)?.into_frames(); + // HACK: Also consider frames empty if they only contain tags. Table + // and grid cells need to be locatable for pdf accessibility, but + // the introspection tags interfere with the layouting. + fn is_empty_frame(frame: &Frame) -> bool { + frame.items().all(|(_, item)| matches!(item, FrameItem::Tag(_))) + } + // Skip the first region if one cell in it is empty. Then, // remeasure. if let Some([first, rest @ ..]) = frames.get(measurement_data.frames_in_previous_regions..) && can_skip && breakable - && first.is_empty() - && rest.iter().any(|frame| !frame.is_empty()) + && is_empty_frame(first) + && rest.iter().any(|frame| !is_empty_frame(frame)) { return Ok(None); } diff --git a/crates/typst-layout/src/grid/mod.rs b/crates/typst-layout/src/grid/mod.rs index 45fba260d0..572af184fe 100644 --- a/crates/typst-layout/src/grid/mod.rs +++ b/crates/typst-layout/src/grid/mod.rs @@ -7,11 +7,11 @@ pub use self::layouter::GridLayouter; use typst_library::diag::SourceResult; use typst_library::engine::Engine; -use typst_library::foundations::{Packed, StyleChain}; -use typst_library::introspection::Locator; +use typst_library::foundations::{NativeElement, Packed, StyleChain}; +use typst_library::introspection::{Locator, SplitLocator, Tag}; use typst_library::layout::grid::resolve::{Cell, grid_to_cellgrid, table_to_cellgrid}; -use typst_library::layout::{Fragment, GridElem, Regions}; -use typst_library::model::TableElem; +use typst_library::layout::{Fragment, FrameItem, GridCell, GridElem, Point, Regions}; +use typst_library::model::{TableCell, TableElem}; use self::layouter::RowPiece; use self::lines::{ @@ -32,7 +32,45 @@ pub fn layout_cell( styles: StyleChain, regions: Regions, ) -> SourceResult { - crate::layout_fragment(engine, &cell.body, locator, styles, regions) + // HACK: manually generate tags for table and grid cells. Ideally table and + // grid cells could just be marked as locatable, but the tags are somehow + // considered significant for layouting. This hack together with a check in + // the grid layouter makes the test suite pass. + let mut locator = locator.split(); + let tags = if let Some(table_cell) = cell.body.to_packed::() { + Some(generate_tags(table_cell.clone(), &mut locator, engine)) + } else if let Some(grid_cell) = cell.body.to_packed::() { + Some(generate_tags(grid_cell.clone(), &mut locator, engine)) + } else { + None + }; + + let locator = locator.next(&cell.body.span()); + let fragment = crate::layout_fragment(engine, &cell.body, locator, styles, regions)?; + + // Manually insert tags. + let mut frames = fragment.into_frames(); + if let Some((start, end)) = tags { + if let Some(first) = frames.first_mut() { + first.prepend(Point::zero(), FrameItem::Tag(start)); + } + if let Some(last) = frames.last_mut() { + last.push(Point::zero(), FrameItem::Tag(end)); + } + } + + Ok(Fragment::frames(frames)) +} + +fn generate_tags( + mut cell: Packed, + locator: &mut SplitLocator, + engine: &mut Engine, +) -> (Tag, Tag) { + let key = typst_utils::hash128(&cell); + let loc = locator.next_location(engine.introspector, key); + cell.set_location(loc); + (Tag::Start(cell.pack()), Tag::End(loc, key)) } /// Layout the grid. diff --git a/crates/typst-library/src/layout/grid/mod.rs b/crates/typst-library/src/layout/grid/mod.rs index 647608c9bc..9bd2654053 100644 --- a/crates/typst-library/src/layout/grid/mod.rs +++ b/crates/typst-library/src/layout/grid/mod.rs @@ -738,7 +738,7 @@ pub struct GridVLine { /// which allows you, for example, to apply styles based on a cell's position. /// Refer to the examples of the [`table.cell`] element to learn more about /// this. -#[elem(name = "cell", title = "Grid Cell", Locatable)] +#[elem(name = "cell", title = "Grid Cell")] pub struct GridCell { /// The cell's body. #[required] diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs index 1cd90f2e38..52d380b014 100644 --- a/crates/typst-library/src/model/table.rs +++ b/crates/typst-library/src/model/table.rs @@ -682,7 +682,7 @@ pub struct TableVLine { /// [Vikram], [49], [Perseverance], /// ) /// ``` -#[elem(name = "cell", title = "Table Cell", Locatable)] +#[elem(name = "cell", title = "Table Cell")] pub struct TableCell { /// The cell's body. #[required] From 9b2effc2223e73a01bf0f1ac918a397e94f52fe3 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Wed, 24 Sep 2025 13:07:08 +0200 Subject: [PATCH 400/558] Generate tags for outlines - TOCs are nested according to the heading level - Links inside outline entries are wrapped in a reference tag --- crates/typst-layout/src/rules.rs | 42 ++++--- crates/typst-library/src/model/outline.rs | 83 ++++++++------ crates/typst-pdf/src/tags/mod.rs | 127 +++++++++++++++++++++- 3 files changed, 201 insertions(+), 51 deletions(-) diff --git a/crates/typst-layout/src/rules.rs b/crates/typst-layout/src/rules.rs index f7980e29a0..3654e05ef9 100644 --- a/crates/typst-layout/src/rules.rs +++ b/crates/typst-layout/src/rules.rs @@ -1,5 +1,5 @@ use comemo::Track; -use ecow::EcoVec; +use ecow::{EcoVec, eco_format}; use smallvec::smallvec; use typst_library::diag::{At, SourceResult, bail}; use typst_library::foundations::{ @@ -10,23 +10,24 @@ use typst_library::introspection::{Counter, Locator, LocatorLink}; use typst_library::layout::{ Abs, AlignElem, Alignment, Axes, BlockBody, BlockElem, ColumnsElem, Em, FixedAlignment, GridCell, GridChild, GridElem, GridItem, HAlignment, HElem, HideElem, - InlineElem, LayoutElem, Length, MoveElem, OuterVAlignment, PadElem, PlaceElem, - PlacementScope, Region, Rel, RepeatElem, RotateElem, ScaleElem, Sides, Size, Sizing, - SkewElem, Spacing, StackChild, StackElem, TrackSizings, VElem, + InlineElem, LayoutElem, Length, MoveElem, OuterVAlignment, PadElem, PageElem, + PlaceElem, PlacementScope, Region, Rel, RepeatElem, RotateElem, ScaleElem, Sides, + Size, Sizing, SkewElem, Spacing, StackChild, StackElem, TrackSizings, VElem, }; use typst_library::math::EquationElem; use typst_library::model::{ Attribution, BibliographyElem, CiteElem, CiteGroup, CslIndentElem, CslLightElem, Destination, DirectLinkElem, EmphElem, EnumElem, FigureCaption, FigureElem, FootnoteElem, FootnoteEntry, HeadingElem, LinkElem, LinkMarker, ListElem, - OutlineElem, OutlineEntry, ParElem, ParbreakElem, QuoteElem, RefElem, StrongElem, - TableCell, TableElem, TermsElem, TitleElem, Works, + OutlineBody, OutlineElem, OutlineEntry, ParElem, ParbreakElem, QuoteElem, RefElem, + StrongElem, TableCell, TableElem, TermsElem, TitleElem, Works, }; use typst_library::pdf::{ArtifactElem, AttachElem}; use typst_library::text::{ - DecoLine, Decoration, HighlightElem, ItalicToggle, LinebreakElem, OverlineElem, - RawElem, RawLine, ScriptKind, ShiftSettings, Smallcaps, SmallcapsElem, SpaceElem, - StrikeElem, SubElem, SuperElem, TextElem, TextSize, UnderlineElem, WeightDelta, + DecoLine, Decoration, HighlightElem, ItalicToggle, LinebreakElem, LocalName, + OverlineElem, RawElem, RawLine, ScriptKind, ShiftSettings, Smallcaps, SmallcapsElem, + SpaceElem, StrikeElem, SubElem, SuperElem, TextElem, TextSize, UnderlineElem, + WeightDelta, }; use typst_library::visualize::{ CircleElem, CurveElem, EllipseElem, ImageElem, LineElem, PathElem, PolygonElem, @@ -55,6 +56,7 @@ pub fn register(rules: &mut NativeRuleMap) { rules.register(Paged, FOOTNOTE_RULE); rules.register(Paged, FOOTNOTE_ENTRY_RULE); rules.register(Paged, OUTLINE_RULE); + rules.register(Paged, OUTLINE_BODY_RULE); rules.register(Paged, OUTLINE_ENTRY_RULE); rules.register(Paged, REF_RULE); rules.register(Paged, CITE_GROUP_RULE); @@ -407,18 +409,29 @@ const FOOTNOTE_ENTRY_RULE: ShowFn = |elem, engine, styles| { const OUTLINE_RULE: ShowFn = |elem, engine, styles| { let title = elem.realize_title(styles); let entries = elem.realize_flat(engine, styles)?; - Ok(Content::sequence( - title.into_iter().chain(entries.into_iter().map(|entry| entry.pack())), - )) + let entries = entries.into_iter().map(|entry| entry.pack()); + let body = OutlineBody::new(Content::sequence(entries)).pack(); + Ok(Content::sequence(title.into_iter().chain(Some(body)))) }; +const OUTLINE_BODY_RULE: ShowFn = |elem, _, _| Ok(elem.body.clone()); + const OUTLINE_ENTRY_RULE: ShowFn = |elem, engine, styles| { let span = elem.span(); let context = Context::new(None, Some(styles)); let context = context.track(); let prefix = elem.prefix(engine, context, span)?; - let inner = elem.inner(engine, context, span)?; + let body = elem.body().at(span)?; + let page = elem.page(engine, context, span)?; + let alt = { + let prefix = prefix.as_ref().map(|p| p.plain_text()).unwrap_or_default(); + let body = body.plain_text(); + let page_str = PageElem::local_name_in(styles); + let page_nr = page.plain_text(); + eco_format!("{prefix} \"{body}\", {page_str} {page_nr}") + }; + let inner = elem.build_inner(context, span, body, page)?; let block = if elem.element.is::() { // Equation has no body and no levels, so indenting makes no sense. let body = prefix.unwrap_or_default() + inner; @@ -431,8 +444,7 @@ const OUTLINE_ENTRY_RULE: ShowFn = |elem, engine, styles| { }; let loc = elem.element_location().at(span)?; - // TODO: generate alt text - Ok(block.linked(Destination::Location(loc), None)) + Ok(block.linked(Destination::Location(loc), Some(alt))) }; const REF_RULE: ShowFn = |elem, engine, styles| elem.realize(engine, styles); diff --git a/crates/typst-library/src/model/outline.rs b/crates/typst-library/src/model/outline.rs index a1dd96ee0d..add3f0862a 100644 --- a/crates/typst-library/src/model/outline.rs +++ b/crates/typst-library/src/model/outline.rs @@ -409,6 +409,13 @@ impl LocalName for Packed { const KEY: &'static str = "outline"; } +/// Only used to delimit the outline in tagged PDF. +#[elem(Locatable)] +pub struct OutlineBody { + #[required] + pub body: Content, +} + /// Defines how an outline is indented. #[derive(Debug, Clone, PartialEq, Hash)] pub enum OutlineIndent { @@ -648,6 +655,48 @@ impl OutlineEntry { engine: &mut Engine, context: Tracked, span: Span, + ) -> SourceResult { + let body = self.body().at(span)?; + let page = self.page(engine, context, span)?; + self.build_inner(context, span, body, page) + } + + /// The content which is displayed in place of the referred element at its + /// entry in the outline. For a heading, this is its + /// [`body`]($heading.body); for a figure a caption and for equations, it is + /// empty. + #[func] + pub fn body(&self) -> StrResult { + Ok(self.outlinable()?.body()) + } + + /// The page number of this entry's element, formatted with the numbering + /// set for the referenced page. + #[func(contextual)] + pub fn page( + &self, + engine: &mut Engine, + context: Tracked, + span: Span, + ) -> SourceResult { + let loc = self.element_location().at(span)?; + let styles = context.styles().at(span)?; + let numbering = engine + .introspector + .page_numbering(loc) + .cloned() + .unwrap_or_else(|| NumberingPattern::from_str("1").unwrap().into()); + Counter::new(CounterKey::Page).display_at_loc(engine, loc, styles, &numbering) + } +} + +impl OutlineEntry { + pub fn build_inner( + &self, + context: Tracked, + span: Span, + body: Content, + page: Content, ) -> SourceResult { let styles = context.styles().at(span)?; @@ -668,7 +717,7 @@ impl OutlineEntry { seq.push(TextElem::packed("\u{202B}")); } - seq.push(self.body().at(span)?); + seq.push(body); if rtl { // "Pop Directional Formatting" @@ -693,41 +742,11 @@ impl OutlineEntry { // Add the page number. The word joiner in front ensures that the page // number doesn't stand alone in its line. seq.push(TextElem::packed("\u{2060}")); - seq.push(self.page(engine, context, span)?); + seq.push(page); Ok(Content::sequence(seq)) } - /// The content which is displayed in place of the referred element at its - /// entry in the outline. For a heading, this is its - /// [`body`]($heading.body); for a figure a caption and for equations, it is - /// empty. - #[func] - pub fn body(&self) -> StrResult { - Ok(self.outlinable()?.body()) - } - - /// The page number of this entry's element, formatted with the numbering - /// set for the referenced page. - #[func(contextual)] - pub fn page( - &self, - engine: &mut Engine, - context: Tracked, - span: Span, - ) -> SourceResult { - let loc = self.element_location().at(span)?; - let styles = context.styles().at(span)?; - let numbering = engine - .introspector - .page_numbering(loc) - .cloned() - .unwrap_or_else(|| NumberingPattern::from_str("1").unwrap().into()); - Counter::new(CounterKey::Page).display_at_loc(engine, loc, styles, &numbering) - } -} - -impl OutlineEntry { fn outlinable(&self) -> StrResult<&dyn Outlinable> { self.element .with::() diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index 65507991af..89f3f5e7cb 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -12,7 +12,8 @@ use typst_library::foundations::{Content, Packed, StyleChain}; use typst_library::introspection::Location; use typst_library::layout::RepeatElem; use typst_library::model::{ - FigureCaption, FigureElem, HeadingElem, LinkMarker, Outlinable, TableCell, TableElem, + FigureCaption, FigureElem, HeadingElem, LinkMarker, Outlinable, OutlineBody, + OutlineEntry, TableCell, TableElem, }; use typst_library::pdf::{ArtifactElem, ArtifactKind}; use typst_library::visualize::ImageElem; @@ -42,17 +43,95 @@ pub struct StackEntry { #[derive(Debug)] pub enum StackEntryKind { Standard(TagKind), + Outline(OutlineCtx), + OutlineEntry(Packed), Table(TableCtx), TableCell(Packed), Link(LinkId, Packed), } impl StackEntryKind { + pub fn as_outline_mut(&mut self) -> Option<&mut OutlineCtx> { + if let Self::Outline(v) = self { Some(v) } else { None } + } + + pub fn as_outline_entry_mut(&mut self) -> Option<&mut OutlineEntry> { + if let Self::OutlineEntry(v) = self { Some(v) } else { None } + } + pub fn as_link(&self) -> Option<(LinkId, &Packed)> { if let Self::Link(id, link) = self { Some((*id, link)) } else { None } } } +#[derive(Debug)] +pub struct OutlineCtx { + stack: Vec, +} + +#[derive(Debug)] +pub struct OutlineSection { + entries: Vec, +} + +impl OutlineSection { + const fn new() -> Self { + OutlineSection { entries: Vec::new() } + } + + fn push(&mut self, entry: TagNode) { + self.entries.push(entry); + } + + fn into_tag(self) -> TagNode { + TagNode::Group(Tag::TOC.into(), self.entries) + } +} + +impl OutlineCtx { + fn new() -> Self { + Self { stack: Vec::new() } + } + + fn insert( + &mut self, + outline_nodes: &mut Vec, + entry: Packed, + nodes: Vec, + ) { + let expected_len = entry.level.get() - 1; + if self.stack.len() < expected_len { + self.stack.resize_with(expected_len, || OutlineSection::new()); + } else { + while self.stack.len() > expected_len { + self.finish_section(outline_nodes); + } + } + + let section_entry = TagNode::group(Tag::TOCI, nodes); + self.push(outline_nodes, section_entry); + } + + fn finish_section(&mut self, outline_nodes: &mut Vec) { + let sub_section = self.stack.pop().unwrap().into_tag(); + self.push(outline_nodes, sub_section); + } + + fn push(&mut self, outline_nodes: &mut Vec, entry: TagNode) { + match self.stack.last_mut() { + Some(section) => section.push(entry), + None => outline_nodes.push(entry), + } + } + + fn build_outline(mut self, mut outline_nodes: Vec) -> Vec { + while self.stack.len() > 0 { + self.finish_section(&mut outline_nodes); + } + outline_nodes + } +} + #[derive(Debug)] pub struct TableCtx { table: Packed, @@ -60,6 +139,10 @@ pub struct TableCtx { } impl TableCtx { + fn new(table: Packed) -> Self { + Self { table: table.clone(), rows: Vec::new() } + } + fn insert(&mut self, cell: Packed, nodes: Vec) { let x = cell.x.get(StyleChain::default()).unwrap_or_else(|| unreachable!()); let y = cell.y.get(StyleChain::default()).unwrap_or_else(|| unreachable!()); @@ -171,6 +254,10 @@ impl Tags { self.stack.last_mut().map(|e| &mut e.kind) } + pub fn parent_outline_entry(&mut self) -> Option<&mut OutlineEntry> { + self.parent()?.as_outline_entry_mut() + } + pub fn find_parent_link(&self) -> Option<(LinkId, &Packed)> { self.stack.iter().rev().find_map(|entry| entry.kind.as_link()) } @@ -310,6 +397,12 @@ pub fn handle_start(gc: &mut GlobalContext, elem: &Content) { let level = heading.level().try_into().unwrap_or(NonZeroU16::MAX); let name = heading.body.plain_text().to_string(); Tag::Hn(level, Some(name)).into() + } else if let Some(_) = elem.to_packed::() { + push_stack(gc, loc, StackEntryKind::Outline(OutlineCtx::new())); + return; + } else if let Some(entry) = elem.to_packed::() { + push_stack(gc, loc, StackEntryKind::OutlineEntry(entry.clone())); + return; } else if let Some(figure) = elem.to_packed::() { let alt = figure.alt.get_cloned(StyleChain::default()).map(|s| s.to_string()); Tag::Figure(alt).into() @@ -328,8 +421,7 @@ pub fn handle_start(gc: &mut GlobalContext, elem: &Content) { } else if let Some(_) = elem.to_packed::() { Tag::Caption.into() } else if let Some(table) = elem.to_packed::() { - let ctx = TableCtx { table: table.clone(), rows: Vec::new() }; - push_stack(gc, loc, StackEntryKind::Table(ctx)); + push_stack(gc, loc, StackEntryKind::Table(TableCtx::new(table.clone()))); return; } else if let Some(cell) = elem.to_packed::() { push_stack(gc, loc, StackEntryKind::TableCell(cell.clone())); @@ -363,6 +455,26 @@ pub fn handle_end(gc: &mut GlobalContext, loc: Location) { let node = match entry.kind { StackEntryKind::Standard(tag) => TagNode::group(tag, entry.nodes), + StackEntryKind::Outline(ctx) => { + let nodes = ctx.build_outline(entry.nodes); + TagNode::group(Tag::TOC, nodes) + } + StackEntryKind::OutlineEntry(outline_entry) => { + let parent = gc.tags.stack.last_mut().and_then(|parent| { + let ctx = parent.kind.as_outline_mut()?; + Some((&mut parent.nodes, ctx)) + }); + let Some((parent_nodes, outline_ctx)) = parent else { + // PDF/UA compliance of the structure hierarchy is checked + // elsewhere. While this doesn't make a lot of sense, just + // avoid crashing here. + gc.tags.push(TagNode::group(Tag::TOCI, entry.nodes)); + return; + }; + + outline_ctx.insert(parent_nodes, outline_entry, entry.nodes); + return; + } StackEntryKind::Table(ctx) => { let summary = ctx .table @@ -383,7 +495,14 @@ pub fn handle_end(gc: &mut GlobalContext, loc: Location) { return; } - StackEntryKind::Link(_, _) => TagNode::group(Tag::Link, entry.nodes), + StackEntryKind::Link(_, _) => { + let mut node = TagNode::group(Tag::Link, entry.nodes); + // Wrap link in reference tag if inside an outline entry. + if gc.tags.parent_outline_entry().is_some() { + node = TagNode::group(Tag::Reference, vec![node]); + } + node + } }; gc.tags.push(node); From 3cc32850a1536cc2740a29aeab1c68c75f151bf1 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Thu, 3 Jul 2025 11:22:22 +0200 Subject: [PATCH 401/558] Split up PDF tagging code into multiple modules --- crates/typst-pdf/src/tags/mod.rs | 477 ++++++++++----------------- crates/typst-pdf/src/tags/outline.rs | 76 +++++ crates/typst-pdf/src/tags/table.rs | 71 ++++ 3 files changed, 321 insertions(+), 303 deletions(-) create mode 100644 crates/typst-pdf/src/tags/outline.rs create mode 100644 crates/typst-pdf/src/tags/table.rs diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index 89f3f5e7cb..b5c9e5f2b4 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -1,12 +1,11 @@ use std::cell::OnceCell; -use std::num::{NonZeroU16, NonZeroU32}; +use std::num::NonZeroU16; use ecow::EcoString; use krilla::page::Page; use krilla::surface::Surface; use krilla::tagging::{ - ArtifactType, ContentTag, Identifier, Node, SpanTag, TableHeaderScope, Tag, TagGroup, - TagKind, TagTree, + ArtifactType, ContentTag, Identifier, Node, SpanTag, Tag, TagGroup, TagKind, TagTree, }; use typst_library::foundations::{Content, Packed, StyleChain}; use typst_library::introspection::Location; @@ -20,203 +19,157 @@ use typst_library::visualize::ImageElem; use crate::convert::GlobalContext; use crate::link::LinkAnnotation; +use crate::tags::outline::OutlineCtx; +use crate::tags::table::TableCtx; -pub struct Tags { - /// The intermediary stack of nested tag groups. - pub stack: Vec, - /// A list of placeholders corresponding to a [`TagNode::Placeholder`]. - pub placeholders: Vec>, - pub in_artifact: Option<(Location, ArtifactKind)>, - pub link_id: LinkId, - - /// The output. - pub tree: Vec, -} - -#[derive(Debug)] -pub struct StackEntry { - pub loc: Location, - pub kind: StackEntryKind, - pub nodes: Vec, -} - -#[derive(Debug)] -pub enum StackEntryKind { - Standard(TagKind), - Outline(OutlineCtx), - OutlineEntry(Packed), - Table(TableCtx), - TableCell(Packed), - Link(LinkId, Packed), -} - -impl StackEntryKind { - pub fn as_outline_mut(&mut self) -> Option<&mut OutlineCtx> { - if let Self::Outline(v) = self { Some(v) } else { None } - } - - pub fn as_outline_entry_mut(&mut self) -> Option<&mut OutlineEntry> { - if let Self::OutlineEntry(v) = self { Some(v) } else { None } - } - - pub fn as_link(&self) -> Option<(LinkId, &Packed)> { - if let Self::Link(id, link) = self { Some((*id, link)) } else { None } - } -} - -#[derive(Debug)] -pub struct OutlineCtx { - stack: Vec, -} - -#[derive(Debug)] -pub struct OutlineSection { - entries: Vec, -} +mod outline; +mod table; -impl OutlineSection { - const fn new() -> Self { - OutlineSection { entries: Vec::new() } +pub fn handle_start(gc: &mut GlobalContext, elem: &Content) { + if gc.tags.in_artifact.is_some() { + // Don't nest artifacts + return; } - fn push(&mut self, entry: TagNode) { - self.entries.push(entry); - } + let loc = elem.location().expect("elem to be locatable"); - fn into_tag(self) -> TagNode { - TagNode::Group(Tag::TOC.into(), self.entries) + if let Some(artifact) = elem.to_packed::() { + let kind = artifact.kind.get(StyleChain::default()); + start_artifact(gc, loc, kind); + return; + } else if let Some(_) = elem.to_packed::() { + start_artifact(gc, loc, ArtifactKind::Other); + return; } -} -impl OutlineCtx { - fn new() -> Self { - Self { stack: Vec::new() } - } + let tag = if let Some(heading) = elem.to_packed::() { + let level = heading.level().try_into().unwrap_or(NonZeroU16::MAX); + let name = heading.body.plain_text().to_string(); + Tag::Hn(level, Some(name)).into() + } else if let Some(_) = elem.to_packed::() { + push_stack(gc, loc, StackEntryKind::Outline(OutlineCtx::new())); + return; + } else if let Some(entry) = elem.to_packed::() { + push_stack(gc, loc, StackEntryKind::OutlineEntry(entry.clone())); + return; + } else if let Some(figure) = elem.to_packed::() { + let alt = figure.alt.get_cloned(StyleChain::default()).map(|s| s.to_string()); + Tag::Figure(alt).into() + } else if let Some(image) = elem.to_packed::() { + let alt = image.alt.get_cloned(StyleChain::default()).map(|s| s.to_string()); - fn insert( - &mut self, - outline_nodes: &mut Vec, - entry: Packed, - nodes: Vec, - ) { - let expected_len = entry.level.get() - 1; - if self.stack.len() < expected_len { - self.stack.resize_with(expected_len, || OutlineSection::new()); - } else { - while self.stack.len() > expected_len { - self.finish_section(outline_nodes); + if let Some(StackEntryKind::Standard(TagKind::Figure(tag))) = gc.tags.parent() { + // Set alt text of outer figure tag, if not present. + if tag.alt_text().is_none() { + tag.set_alt_text(alt); } + return; + } else { + Tag::Figure(alt).into() } + } else if let Some(_) = elem.to_packed::() { + Tag::Caption.into() + } else if let Some(table) = elem.to_packed::() { + push_stack(gc, loc, StackEntryKind::Table(TableCtx::new(table.clone()))); + return; + } else if let Some(cell) = elem.to_packed::() { + push_stack(gc, loc, StackEntryKind::TableCell(cell.clone())); + return; + } else if let Some(link) = elem.to_packed::() { + let link_id = gc.tags.next_link_id(); + push_stack(gc, loc, StackEntryKind::Link(link_id, link.clone())); + return; + } else { + return; + }; - let section_entry = TagNode::group(Tag::TOCI, nodes); - self.push(outline_nodes, section_entry); - } - - fn finish_section(&mut self, outline_nodes: &mut Vec) { - let sub_section = self.stack.pop().unwrap().into_tag(); - self.push(outline_nodes, sub_section); - } - - fn push(&mut self, outline_nodes: &mut Vec, entry: TagNode) { - match self.stack.last_mut() { - Some(section) => section.push(entry), - None => outline_nodes.push(entry), - } - } - - fn build_outline(mut self, mut outline_nodes: Vec) -> Vec { - while self.stack.len() > 0 { - self.finish_section(&mut outline_nodes); - } - outline_nodes - } + push_stack(gc, loc, StackEntryKind::Standard(tag)); } -#[derive(Debug)] -pub struct TableCtx { - table: Packed, - rows: Vec, TagKind, Vec)>>>, +fn push_stack(gc: &mut GlobalContext, loc: Location, kind: StackEntryKind) { + gc.tags.stack.push(StackEntry { loc, kind, nodes: Vec::new() }); } -impl TableCtx { - fn new(table: Packed) -> Self { - Self { table: table.clone(), rows: Vec::new() } +pub fn handle_end(gc: &mut GlobalContext, loc: Location) { + if let Some((l, _)) = gc.tags.in_artifact { + if l == loc { + gc.tags.in_artifact = None; + } + return; } - fn insert(&mut self, cell: Packed, nodes: Vec) { - let x = cell.x.get(StyleChain::default()).unwrap_or_else(|| unreachable!()); - let y = cell.y.get(StyleChain::default()).unwrap_or_else(|| unreachable!()); - let rowspan = cell.rowspan.get(StyleChain::default()).get(); - let colspan = cell.colspan.get(StyleChain::default()).get(); - - let tag = { - // TODO: possibly set internal field on TableCell when resolving - // the cell grid. - let is_header = false; - let rowspan = - (rowspan != 1).then_some(NonZeroU32::new(rowspan as u32).unwrap()); - let colspan = - (colspan != 1).then_some(NonZeroU32::new(colspan as u32).unwrap()); - if is_header { - let scope = TableHeaderScope::Column; // TODO - Tag::TH(scope).with_row_span(rowspan).with_col_span(colspan).into() - } else { - Tag::TD.with_row_span(rowspan).with_col_span(colspan).into() - } - }; + let Some(entry) = gc.tags.stack.pop_if(|e| e.loc == loc) else { + return; + }; - let required_height = y + rowspan; - if self.rows.len() < required_height { - self.rows.resize_with(required_height, Vec::new); + let node = match entry.kind { + StackEntryKind::Standard(tag) => TagNode::group(tag, entry.nodes), + StackEntryKind::Outline(ctx) => { + let nodes = ctx.build_outline(entry.nodes); + TagNode::group(Tag::TOC, nodes) } + StackEntryKind::OutlineEntry(outline_entry) => { + let parent = gc.tags.stack.last_mut().and_then(|parent| { + let ctx = parent.kind.as_outline_mut()?; + Some((&mut parent.nodes, ctx)) + }); + let Some((parent_nodes, outline_ctx)) = parent else { + // PDF/UA compliance of the structure hierarchy is checked + // elsewhere. While this doesn't make a lot of sense, just + // avoid crashing here. + gc.tags.push(TagNode::group(Tag::TOCI, entry.nodes)); + return; + }; - let required_width = x + colspan; - let row = &mut self.rows[y]; - if row.len() < required_width { - row.resize_with(required_width, || None); + outline_ctx.insert(parent_nodes, outline_entry, entry.nodes); + return; } + StackEntryKind::Table(ctx) => { + let summary = ctx + .table + .summary + .get_ref(StyleChain::default()) + .as_ref() + .map(EcoString::to_string); + let nodes = ctx.build_table(entry.nodes); + TagNode::group(Tag::Table.with_summary(summary), nodes) + } + StackEntryKind::TableCell(cell) => { + let parent = gc.tags.stack.last_mut().expect("table"); + let StackEntryKind::Table(table_ctx) = &mut parent.kind else { + unreachable!("expected table") + }; - row[x] = Some((cell, tag, nodes)); - } + table_ctx.insert(cell, entry.nodes); - fn build_table(self, mut nodes: Vec) -> Vec { - // Table layouting ensures that there are no overlapping cells, and that - // any gaps left by the user are filled with empty cells. - for row in self.rows.into_iter() { - let mut row_nodes = Vec::new(); - for (_, tag, nodes) in row.into_iter().flatten() { - row_nodes.push(TagNode::group(tag, nodes)); + return; + } + StackEntryKind::Link(_, _) => { + let mut node = TagNode::group(Tag::Link, entry.nodes); + // Wrap link in reference tag if inside an outline entry. + if gc.tags.parent_outline_entry().is_some() { + node = TagNode::group(Tag::Reference, vec![node]); } - - // TODO: generate `THead`, `TBody`, and `TFoot` - nodes.push(TagNode::group(Tag::TR, row_nodes)); + node } + }; - nodes - } + gc.tags.push(node); } -#[derive(Debug)] -pub enum TagNode { - Group(TagKind, Vec), - Leaf(Identifier), - /// Allows inserting a placeholder into the tag tree. - /// Currently used for [`krilla::page::Page::add_tagged_annotation`]. - Placeholder(Placeholder), -} +pub struct Tags { + /// The intermediary stack of nested tag groups. + pub stack: Vec, + /// A list of placeholders corresponding to a [`TagNode::Placeholder`]. + pub placeholders: Vec>, + pub in_artifact: Option<(Location, ArtifactKind)>, + /// Used to group multiple link annotations using quad points. + pub link_id: LinkId, -impl TagNode { - pub fn group(tag: impl Into, children: Vec) -> Self { - TagNode::Group(tag.into(), children) - } + /// The output. + pub tree: Vec, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct LinkId(u32); - -#[derive(Clone, Copy, Debug)] -pub struct Placeholder(usize); - impl Tags { pub fn new() -> Self { Self { @@ -248,8 +201,6 @@ impl Tags { .expect("initialized placeholder node") } - /// Returns the current parent's list of children and the structure type ([Tag]). - /// In case of the document root the structure type will be `None`. pub fn parent(&mut self) -> Option<&mut StackEntryKind> { self.stack.last_mut().map(|e| &mut e.kind) } @@ -301,6 +252,58 @@ impl Tags { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LinkId(u32); + +#[derive(Debug)] +pub struct StackEntry { + pub loc: Location, + pub kind: StackEntryKind, + pub nodes: Vec, +} + +#[derive(Debug)] +pub enum StackEntryKind { + Standard(TagKind), + Outline(OutlineCtx), + OutlineEntry(Packed), + Table(TableCtx), + TableCell(Packed), + Link(LinkId, Packed), +} + +impl StackEntryKind { + pub fn as_outline_mut(&mut self) -> Option<&mut OutlineCtx> { + if let Self::Outline(v) = self { Some(v) } else { None } + } + + pub fn as_outline_entry_mut(&mut self) -> Option<&mut OutlineEntry> { + if let Self::OutlineEntry(v) = self { Some(v) } else { None } + } + + pub fn as_link(&self) -> Option<(LinkId, &Packed)> { + if let Self::Link(id, link) = self { Some((*id, link)) } else { None } + } +} + +#[derive(Clone, Debug)] +pub enum TagNode { + Group(TagKind, Vec), + Leaf(Identifier), + /// Allows inserting a placeholder into the tag tree. + /// Currently used for [`krilla::page::Page::add_tagged_annotation`]. + Placeholder(Placeholder), +} + +impl TagNode { + pub fn group(tag: impl Into, children: Vec) -> Self { + TagNode::Group(tag.into(), children) + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Placeholder(usize); + /// Automatically calls [`Surface::end_tagged`] when dropped. pub struct TagHandle<'a, 'b> { surface: &'b mut Surface<'a>, @@ -376,138 +379,6 @@ pub fn add_link_annotations( } } -pub fn handle_start(gc: &mut GlobalContext, elem: &Content) { - if gc.tags.in_artifact.is_some() { - // Don't nest artifacts - return; - } - - let loc = elem.location().expect("elem to be locatable"); - - if let Some(artifact) = elem.to_packed::() { - let kind = artifact.kind.get(StyleChain::default()); - start_artifact(gc, loc, kind); - return; - } else if let Some(_) = elem.to_packed::() { - start_artifact(gc, loc, ArtifactKind::Other); - return; - } - - let tag = if let Some(heading) = elem.to_packed::() { - let level = heading.level().try_into().unwrap_or(NonZeroU16::MAX); - let name = heading.body.plain_text().to_string(); - Tag::Hn(level, Some(name)).into() - } else if let Some(_) = elem.to_packed::() { - push_stack(gc, loc, StackEntryKind::Outline(OutlineCtx::new())); - return; - } else if let Some(entry) = elem.to_packed::() { - push_stack(gc, loc, StackEntryKind::OutlineEntry(entry.clone())); - return; - } else if let Some(figure) = elem.to_packed::() { - let alt = figure.alt.get_cloned(StyleChain::default()).map(|s| s.to_string()); - Tag::Figure(alt).into() - } else if let Some(image) = elem.to_packed::() { - let alt = image.alt.get_cloned(StyleChain::default()).map(|s| s.to_string()); - - if let Some(StackEntryKind::Standard(TagKind::Figure(tag))) = gc.tags.parent() { - // Set alt text of outer figure tag, if not present. - if tag.alt_text().is_none() { - tag.set_alt_text(alt); - } - return; - } else { - Tag::Figure(alt).into() - } - } else if let Some(_) = elem.to_packed::() { - Tag::Caption.into() - } else if let Some(table) = elem.to_packed::() { - push_stack(gc, loc, StackEntryKind::Table(TableCtx::new(table.clone()))); - return; - } else if let Some(cell) = elem.to_packed::() { - push_stack(gc, loc, StackEntryKind::TableCell(cell.clone())); - return; - } else if let Some(link) = elem.to_packed::() { - let link_id = gc.tags.next_link_id(); - push_stack(gc, loc, StackEntryKind::Link(link_id, link.clone())); - return; - } else { - return; - }; - - push_stack(gc, loc, StackEntryKind::Standard(tag)); -} - -fn push_stack(gc: &mut GlobalContext, loc: Location, kind: StackEntryKind) { - gc.tags.stack.push(StackEntry { loc, kind, nodes: Vec::new() }); -} - -pub fn handle_end(gc: &mut GlobalContext, loc: Location) { - if let Some((l, _)) = gc.tags.in_artifact { - if l == loc { - gc.tags.in_artifact = None; - } - return; - } - - let Some(entry) = gc.tags.stack.pop_if(|e| e.loc == loc) else { - return; - }; - - let node = match entry.kind { - StackEntryKind::Standard(tag) => TagNode::group(tag, entry.nodes), - StackEntryKind::Outline(ctx) => { - let nodes = ctx.build_outline(entry.nodes); - TagNode::group(Tag::TOC, nodes) - } - StackEntryKind::OutlineEntry(outline_entry) => { - let parent = gc.tags.stack.last_mut().and_then(|parent| { - let ctx = parent.kind.as_outline_mut()?; - Some((&mut parent.nodes, ctx)) - }); - let Some((parent_nodes, outline_ctx)) = parent else { - // PDF/UA compliance of the structure hierarchy is checked - // elsewhere. While this doesn't make a lot of sense, just - // avoid crashing here. - gc.tags.push(TagNode::group(Tag::TOCI, entry.nodes)); - return; - }; - - outline_ctx.insert(parent_nodes, outline_entry, entry.nodes); - return; - } - StackEntryKind::Table(ctx) => { - let summary = ctx - .table - .summary - .get_ref(StyleChain::default()) - .as_ref() - .map(EcoString::to_string); - let nodes = ctx.build_table(entry.nodes); - TagNode::group(Tag::Table.with_summary(summary), nodes) - } - StackEntryKind::TableCell(cell) => { - let parent = gc.tags.stack.last_mut().expect("table"); - let StackEntryKind::Table(table_ctx) = &mut parent.kind else { - unreachable!("expected table") - }; - - table_ctx.insert(cell, entry.nodes); - - return; - } - StackEntryKind::Link(_, _) => { - let mut node = TagNode::group(Tag::Link, entry.nodes); - // Wrap link in reference tag if inside an outline entry. - if gc.tags.parent_outline_entry().is_some() { - node = TagNode::group(Tag::Reference, vec![node]); - } - node - } - }; - - gc.tags.push(node); -} - fn start_artifact(gc: &mut GlobalContext, loc: Location, kind: ArtifactKind) { gc.tags.in_artifact = Some((loc, kind)); } diff --git a/crates/typst-pdf/src/tags/outline.rs b/crates/typst-pdf/src/tags/outline.rs new file mode 100644 index 0000000000..23c6f84bdf --- /dev/null +++ b/crates/typst-pdf/src/tags/outline.rs @@ -0,0 +1,76 @@ +use krilla::tagging::Tag; +use typst_library::foundations::Packed; +use typst_library::model::OutlineEntry; + +use crate::tags::TagNode; + +#[derive(Debug)] +pub struct OutlineCtx { + stack: Vec, +} + +impl OutlineCtx { + pub fn new() -> Self { + Self { stack: Vec::new() } + } + + pub fn insert( + &mut self, + outline_nodes: &mut Vec, + entry: Packed, + nodes: Vec, + ) { + let expected_len = entry.level.get() - 1; + if self.stack.len() < expected_len { + self.stack.resize_with(expected_len, OutlineSection::new); + } else { + while self.stack.len() > expected_len { + self.finish_section(outline_nodes); + } + } + + let section_entry = TagNode::group(Tag::TOCI, nodes); + self.push(outline_nodes, section_entry); + } + + fn finish_section(&mut self, outline_nodes: &mut Vec) { + let sub_section = self.stack.pop().unwrap().into_tag(); + self.push(outline_nodes, sub_section); + } + + fn push(&mut self, outline_nodes: &mut Vec, entry: TagNode) { + match self.stack.last_mut() { + Some(section) => section.push(entry), + None => outline_nodes.push(entry), + } + } + + pub fn build_outline( + mut self, + mut outline_nodes: Vec, + ) -> Vec { + while !self.stack.is_empty() { + self.finish_section(&mut outline_nodes); + } + outline_nodes + } +} + +#[derive(Debug)] +pub struct OutlineSection { + entries: Vec, +} + +impl OutlineSection { + const fn new() -> Self { + OutlineSection { entries: Vec::new() } + } + + fn push(&mut self, entry: TagNode) { + self.entries.push(entry); + } + + fn into_tag(self) -> TagNode { + TagNode::group(Tag::TOC, self.entries) + } +} diff --git a/crates/typst-pdf/src/tags/table.rs b/crates/typst-pdf/src/tags/table.rs new file mode 100644 index 0000000000..a71103ccaa --- /dev/null +++ b/crates/typst-pdf/src/tags/table.rs @@ -0,0 +1,71 @@ +use std::num::NonZeroU32; + +use krilla::tagging::{TableHeaderScope, Tag, TagKind}; +use typst_library::foundations::{Packed, StyleChain}; +use typst_library::model::{TableCell, TableElem}; + +use crate::tags::TagNode; + +#[derive(Debug)] +pub struct TableCtx { + pub table: Packed, + rows: Vec, TagKind, Vec)>>>, +} + +impl TableCtx { + pub fn new(table: Packed) -> Self { + Self { table: table.clone(), rows: Vec::new() } + } + + pub fn insert(&mut self, cell: Packed, nodes: Vec) { + let x = cell.x.get(StyleChain::default()).unwrap_or_else(|| unreachable!()); + let y = cell.y.get(StyleChain::default()).unwrap_or_else(|| unreachable!()); + let rowspan = cell.rowspan.get(StyleChain::default()).get(); + let colspan = cell.colspan.get(StyleChain::default()).get(); + + let tag = { + // TODO: possibly set internal field on TableCell when resolving + // the cell grid. + let is_header = false; + let rowspan = + (rowspan != 1).then_some(NonZeroU32::new(rowspan as u32).unwrap()); + let colspan = + (colspan != 1).then_some(NonZeroU32::new(colspan as u32).unwrap()); + if is_header { + let scope = TableHeaderScope::Column; // TODO + Tag::TH(scope).with_row_span(rowspan).with_col_span(colspan).into() + } else { + Tag::TD.with_row_span(rowspan).with_col_span(colspan).into() + } + }; + + let required_height = y + rowspan; + if self.rows.len() < required_height { + self.rows.resize_with(required_height, Vec::new); + } + + let required_width = x + colspan; + let row = &mut self.rows[y]; + if row.len() < required_width { + row.resize_with(required_width, || None); + } + + row[x] = Some((cell, tag, nodes)); + } + + pub fn build_table(self, mut nodes: Vec) -> Vec { + // Table layouting ensures that there are no overlapping cells, and that + // any gaps left by the user are filled with empty cells. + for row in self.rows.into_iter() { + let mut row_nodes = Vec::new(); + for (_, tag, nodes) in row.into_iter().flatten() { + row_nodes.push(TagNode::group(tag, nodes)); + } + + // TODO: generate `THead`, `TBody`, and `TFoot` + nodes.push(TagNode::group(Tag::TR, row_nodes)); + } + + nodes + } +} From cb19cc72eedd71b908a1ca34fe0e4fa58257e541 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Sat, 28 Jun 2025 18:22:30 +0200 Subject: [PATCH 402/558] Tag table headers and footers - Mark repeated headers/footers as artifacts - Generate row groupings (`THead`, `TBody`, `TFoot`) - Generate cell `Headers` attribute - Use human readable table cell IDs: in almost all real-world cases these IDs require less memory than the binary IDs used before, and they are also require less storage in PDF files, since binary data is encoded in hex escape sequences, taking up 4 bytes per byte of data. - Add `pdf.header-cell` and `pdf.data-cell` APIs to manually tag cells as `TD` or `TH` and additionally set the header scope --- Cargo.lock | 2 + crates/typst-layout/src/grid/layouter.rs | 51 +- crates/typst-layout/src/grid/mod.rs | 5 +- crates/typst-layout/src/grid/repeated.rs | 11 +- crates/typst-layout/src/grid/rowspans.rs | 7 +- .../typst-library/src/layout/grid/resolve.rs | 52 +- crates/typst-library/src/model/table.rs | 9 + crates/typst-library/src/pdf/accessibility.rs | 73 ++- crates/typst-library/src/pdf/mod.rs | 2 + crates/typst-pdf/Cargo.toml | 2 + crates/typst-pdf/src/tags/mod.rs | 75 ++- crates/typst-pdf/src/tags/outline.rs | 7 +- crates/typst-pdf/src/tags/table.rs | 584 ++++++++++++++++-- 13 files changed, 787 insertions(+), 93 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bdf4dc2693..7bf1774acb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3249,6 +3249,7 @@ dependencies = [ name = "typst-pdf" version = "0.13.1" dependencies = [ + "az", "bytemuck", "comemo", "ecow", @@ -3258,6 +3259,7 @@ dependencies = [ "krilla-svg", "rustc-hash", "serde", + "smallvec", "typst-assets", "typst-library", "typst-macros", diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index fe61a5568f..786762bb06 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -183,6 +183,11 @@ pub(super) struct RowState { /// orphan snapshot and will flush pending headers, as there is no risk /// that they will be orphans anymore. pub(super) in_active_repeatable: bool, + /// This is `false` if a header row is laid out the first time, and `false` + /// for any other time. For footers it's the opposite, this will be `true` + /// the last time the footer is laid out, and `false` for all previous + /// occurrences. + pub(super) is_being_repeated: bool, } /// Data about laid out repeated header rows for a specific finished region. @@ -337,7 +342,7 @@ impl<'a> GridLayouter<'a> { && y >= footer.start { if y == footer.start { - self.layout_footer(footer, engine, self.finished.len())?; + self.layout_footer(footer, engine, self.finished.len(), false)?; self.flush_orphans(); } y = footer.end; @@ -1073,8 +1078,15 @@ impl<'a> GridLayouter<'a> { let size = Size::new(available, height); let pod = Region::new(size, Axes::splat(false)); let locator = self.cell_locator(parent, 0); - let frame = layout_cell(cell, engine, locator, self.styles, pod.into())? - .into_frame(); + let frame = layout_cell( + cell, + engine, + locator, + self.styles, + pod.into(), + self.row_state.is_being_repeated, + )? + .into_frame(); resolved.set_max(frame.width() - already_covered_width); } @@ -1313,8 +1325,15 @@ impl<'a> GridLayouter<'a> { }; let locator = self.cell_locator(parent, disambiguator); - let frames = - layout_cell(cell, engine, locator, self.styles, pod)?.into_frames(); + let frames = layout_cell( + cell, + engine, + locator, + self.styles, + pod, + self.row_state.is_being_repeated, + )? + .into_frames(); // HACK: Also consider frames empty if they only contain tags. Table // and grid cells need to be locatable for pdf accessibility, but @@ -1479,8 +1498,15 @@ impl<'a> GridLayouter<'a> { pod.full = self.regions.full; } let locator = self.cell_locator(Axes::new(x, y), disambiguator); - let frame = layout_cell(cell, engine, locator, self.styles, pod)? - .into_frame(); + let frame = layout_cell( + cell, + engine, + locator, + self.styles, + pod, + self.row_state.is_being_repeated, + )? + .into_frame(); let mut pos = offset; if self.is_rtl { // In RTL cells expand to the left, thus the position @@ -1528,7 +1554,14 @@ impl<'a> GridLayouter<'a> { // Push the layouted frames into the individual output frames. let locator = self.cell_locator(Axes::new(x, y), disambiguator); - let fragment = layout_cell(cell, engine, locator, self.styles, pod)?; + let fragment = layout_cell( + cell, + engine, + locator, + self.styles, + pod, + self.row_state.is_being_repeated, + )?; for (output, frame) in outputs.iter_mut().zip(fragment) { let mut pos = offset; if self.is_rtl { @@ -1625,7 +1658,7 @@ impl<'a> GridLayouter<'a> { && self.current.lrows.iter().all(|row| row.index() < footer.start) { laid_out_footer_start = Some(footer.start); - self.layout_footer(footer, engine, self.finished.len())?; + self.layout_footer(footer, engine, self.finished.len(), true)?; } } diff --git a/crates/typst-layout/src/grid/mod.rs b/crates/typst-layout/src/grid/mod.rs index 572af184fe..3ddf40ab5d 100644 --- a/crates/typst-layout/src/grid/mod.rs +++ b/crates/typst-layout/src/grid/mod.rs @@ -31,6 +31,7 @@ pub fn layout_cell( locator: Locator, styles: StyleChain, regions: Regions, + is_repeated: bool, ) -> SourceResult { // HACK: manually generate tags for table and grid cells. Ideally table and // grid cells could just be marked as locatable, but the tags are somehow @@ -38,7 +39,9 @@ pub fn layout_cell( // the grid layouter makes the test suite pass. let mut locator = locator.split(); let tags = if let Some(table_cell) = cell.body.to_packed::() { - Some(generate_tags(table_cell.clone(), &mut locator, engine)) + let mut table_cell = table_cell.clone(); + table_cell.is_repeated.set(is_repeated); + Some(generate_tags(table_cell, &mut locator, engine)) } else if let Some(grid_cell) = cell.body.to_packed::() { Some(generate_tags(grid_cell.clone(), &mut locator, engine)) } else { diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 62f82f56ff..94cb5c5e47 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -132,6 +132,7 @@ impl<'a> GridLayouter<'a> { engine: &mut Engine, disambiguator: usize, as_short_lived: bool, + is_being_repeated: bool, ) -> SourceResult { let mut header_height = Abs::zero(); for y in header.range.clone() { @@ -143,6 +144,7 @@ impl<'a> GridLayouter<'a> { RowState { current_row_height: Some(Abs::zero()), in_active_repeatable: !as_short_lived, + is_being_repeated, }, )? .current_row_height @@ -293,7 +295,7 @@ impl<'a> GridLayouter<'a> { let mut i = 0; while let Some(&header) = self.repeating_headers.get(i) { let header_height = - self.layout_header_rows(header, engine, disambiguator, false)?; + self.layout_header_rows(header, engine, disambiguator, false, true)?; self.current.repeating_header_height += header_height; // We assume that this vector will be sorted according @@ -329,7 +331,7 @@ impl<'a> GridLayouter<'a> { has_non_repeated_pending_header = true; } let header_height = - self.layout_header_rows(header, engine, disambiguator, false)?; + self.layout_header_rows(header, engine, disambiguator, false, false)?; if header.repeated { self.current.repeating_header_height += header_height; self.current.repeating_header_heights.push(header_height); @@ -404,7 +406,8 @@ impl<'a> GridLayouter<'a> { total_header_row_count(headers.iter().map(Repeatable::deref)); for header in headers { - let header_height = self.layout_header_rows(header, engine, 0, false)?; + let header_height = + self.layout_header_rows(header, engine, 0, false, false)?; // Only store this header height if it is actually going to // become a pending header. Otherwise, pretend it's not a @@ -515,6 +518,7 @@ impl<'a> GridLayouter<'a> { footer: &Footer, engine: &mut Engine, disambiguator: usize, + is_being_repeated: bool, ) -> SourceResult<()> { // Ensure footer rows have their own height available. // Won't change much as we're creating an unbreakable row group @@ -532,6 +536,7 @@ impl<'a> GridLayouter<'a> { disambiguator, RowState { in_active_repeatable: repeats, + is_being_repeated, ..Default::default() }, )?; diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs index 1faa315504..f1d2a5cea7 100644 --- a/crates/typst-layout/src/grid/rowspans.rs +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -43,6 +43,8 @@ pub struct Rowspan { /// /// This is `None` if no spanned rows were resolved in `finish_region` yet. pub max_resolved_row: Option, + /// See [`RowState::is_being_repeated`](super::layouter::RowState::is_being_repeated). + pub is_being_repeated: bool, } /// The output of the simulation of an unbreakable row group. @@ -115,6 +117,7 @@ impl GridLayouter<'_> { first_region, region_full, heights, + is_being_repeated, .. } = rowspan_data; let [first_height, backlog @ ..] = heights.as_slice() else { @@ -146,7 +149,8 @@ impl GridLayouter<'_> { // Push the layouted frames directly into the finished frames. let locator = self.cell_locator(Axes::new(x, y), disambiguator); - let fragment = layout_cell(cell, engine, locator, self.styles, pod)?; + let fragment = + layout_cell(cell, engine, locator, self.styles, pod, is_being_repeated)?; let (current_region, current_header_row_height) = current_region_data.unzip(); // Clever trick to process finished header rows: @@ -220,6 +224,7 @@ impl GridLayouter<'_> { region_full: Abs::zero(), heights: vec![], max_resolved_row: None, + is_being_repeated: self.row_state.is_being_repeated, }); } } diff --git a/crates/typst-library/src/layout/grid/resolve.rs b/crates/typst-library/src/layout/grid/resolve.rs index 6a2472897c..47bbb6e366 100644 --- a/crates/typst-library/src/layout/grid/resolve.rs +++ b/crates/typst-library/src/layout/grid/resolve.rs @@ -20,6 +20,8 @@ use typst_library::visualize::{Paint, Stroke}; use typst_syntax::Span; use typst_utils::{NonZeroExt, SmallBitSet}; +use crate::pdf::{TableCellKind, TableHeaderScope}; + /// Convert a grid to a cell grid. #[typst_macros::time(span = elem.span())] pub fn grid_to_cellgrid( @@ -209,6 +211,7 @@ impl ResolvableCell for Packed { stroke: Sides>>>>, breakable: bool, styles: StyleChain, + kind: Smart, ) -> Cell { let cell = &mut *self; let colspan = cell.colspan.get(styles); @@ -216,6 +219,8 @@ impl ResolvableCell for Packed { let breakable = cell.breakable.get(styles).unwrap_or(breakable); let fill = cell.fill.get_cloned(styles).unwrap_or_else(|| fill.clone()); + let kind = cell.kind.get(styles).or(kind); + let cell_stroke = cell.stroke.resolve(styles); let stroke_overridden = cell_stroke.as_ref().map(|side| matches!(side, Some(Some(_)))); @@ -259,6 +264,7 @@ impl ResolvableCell for Packed { }), ); cell.breakable.set(Smart::Custom(breakable)); + cell.kind.set(kind); Cell { body: self.pack(), fill, @@ -302,6 +308,7 @@ impl ResolvableCell for Packed { stroke: Sides>>>>, breakable: bool, styles: StyleChain, + _: Smart, ) -> Cell { let cell = &mut *self; let colspan = cell.colspan.get(styles); @@ -506,6 +513,7 @@ pub trait ResolvableCell { stroke: Sides>>>>, breakable: bool, styles: StyleChain, + kind: Smart, ) -> Cell; /// Returns this cell's column override. @@ -1179,8 +1187,14 @@ impl CellGridResolver<'_, '_> { // a non-empty row. let mut first_available_row = 0; + // The cell kind is currently only used for tagged PDF. + let cell_kind; + let (header_footer_items, simple_item) = match child { - ResolvableGridChild::Header { repeat, level, span, items, .. } => { + ResolvableGridChild::Header { repeat, level, span, items } => { + cell_kind = + Smart::Custom(TableCellKind::Header(level, TableHeaderScope::Column)); + row_group_data = Some(RowGroupData { range: None, span, @@ -1207,11 +1221,13 @@ impl CellGridResolver<'_, '_> { (Some(items), None) } - ResolvableGridChild::Footer { repeat, span, items, .. } => { + ResolvableGridChild::Footer { repeat, span, items } => { if footer.is_some() { bail!(span, "cannot have more than one footer"); } + cell_kind = Smart::Custom(TableCellKind::Footer); + row_group_data = Some(RowGroupData { range: None, span, @@ -1230,6 +1246,8 @@ impl CellGridResolver<'_, '_> { (Some(items), None) } ResolvableGridChild::Item(item) => { + cell_kind = Smart::Custom(TableCellKind::Data); + if matches!(item, ResolvableGridItem::Cell(_)) { *at_least_one_cell = true; } @@ -1423,7 +1441,7 @@ impl CellGridResolver<'_, '_> { // Let's resolve the cell so it can determine its own fields // based on its final position. - let cell = self.resolve_cell(cell, x, y, rowspan)?; + let cell = self.resolve_cell(cell, x, y, rowspan, cell_kind)?; if largest_index >= resolved_cells.len() { // Ensure the length of the vector of resolved cells is @@ -1520,9 +1538,21 @@ impl CellGridResolver<'_, '_> { // and footers without having to loop through them each time. // Cells themselves, unfortunately, still have to. assert!(resolved_cells[*local_auto_index].is_none()); - resolved_cells[*local_auto_index] = Some(Entry::Cell( - self.resolve_cell(T::default(), 0, first_available_row, 1)?, - )); + let kind = match row_group.kind { + RowGroupKind::Header => TableCellKind::Header( + NonZeroU32::ONE, + TableHeaderScope::default(), + ), + RowGroupKind::Footer => TableCellKind::Footer, + }; + resolved_cells[*local_auto_index] = + Some(Entry::Cell(self.resolve_cell( + T::default(), + 0, + first_available_row, + 1, + Smart::Custom(kind), + )?)); group_start..group_end } @@ -1634,7 +1664,13 @@ impl CellGridResolver<'_, '_> { let x = i % columns; let y = i / columns; - Ok(Entry::Cell(self.resolve_cell(T::default(), x, y, 1)?)) + Ok(Entry::Cell(self.resolve_cell( + T::default(), + x, + y, + 1, + Smart::Auto, + )?)) } }) .collect::>>() @@ -1886,6 +1922,7 @@ impl CellGridResolver<'_, '_> { x: usize, y: usize, rowspan: usize, + kind: Smart, ) -> SourceResult where T: ResolvableCell + Default, @@ -1921,6 +1958,7 @@ impl CellGridResolver<'_, '_> { self.stroke.resolve(self.engine, self.styles, x, y)?, breakable, self.styles, + kind, )) } } diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs index 52d380b014..3c687dec86 100644 --- a/crates/typst-library/src/model/table.rs +++ b/crates/typst-library/src/model/table.rs @@ -12,6 +12,7 @@ use crate::layout::{ Length, OuterHAlignment, OuterVAlignment, Rel, Sides, TrackSizings, }; use crate::model::Figurable; +use crate::pdf::TableCellKind; use crate::text::LocalName; use crate::visualize::{Paint, Stroke}; @@ -722,6 +723,14 @@ pub struct TableCell { /// unbreakable, while a cell spanning at least one `{auto}`-sized row is /// breakable. pub breakable: Smart, + + #[internal] + #[parse(Some(Smart::Auto))] + pub kind: Smart, + + #[internal] + #[parse(Some(false))] + pub is_repeated: bool, } cast! { diff --git a/crates/typst-library/src/pdf/accessibility.rs b/crates/typst-library/src/pdf/accessibility.rs index 98f16dd90e..b38f3d6e3e 100644 --- a/crates/typst-library/src/pdf/accessibility.rs +++ b/crates/typst-library/src/pdf/accessibility.rs @@ -1,7 +1,11 @@ -use typst_macros::{Cast, elem}; +use std::num::NonZeroU32; -use crate::foundations::Content; +use typst_macros::{Cast, elem, func}; +use typst_utils::NonZeroExt; + +use crate::foundations::{Content, NativeElement, Smart}; use crate::introspection::Locatable; +use crate::model::TableCell; /// Mark content as a PDF artifact. // TODO: maybe generalize this and use it to mark html elements with `aria-hidden="true"`? @@ -29,3 +33,68 @@ pub enum ArtifactKind { #[default] Other, } + +// TODO: feature gate +/// Explicitly define this cell as a header cell. +#[func] +pub fn header_cell( + #[named] + #[default(NonZeroU32::ONE)] + level: NonZeroU32, + #[named] + #[default] + scope: TableHeaderScope, + /// The table cell. + cell: TableCell, +) -> Content { + cell.with_kind(Smart::Custom(TableCellKind::Header(level, scope))) + .pack() +} + +// TODO: feature gate +/// Explicitly define this cell as a data cell. +#[func] +pub fn data_cell( + /// The table cell. + cell: TableCell, +) -> Content { + cell.with_kind(Smart::Custom(TableCellKind::Data)).pack() +} + +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)] +pub enum TableCellKind { + Header(NonZeroU32, TableHeaderScope), + Footer, + #[default] + Data, +} + +/// The scope of a table header cell. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum TableHeaderScope { + /// The header cell refers to both the row and the column. + Both, + /// The header cell refers to the column. + #[default] + Column, + /// The header cell refers to the row. + Row, +} + +impl TableHeaderScope { + pub fn refers_to_column(&self) -> bool { + match self { + TableHeaderScope::Both => true, + TableHeaderScope::Column => true, + TableHeaderScope::Row => false, + } + } + + pub fn refers_to_row(&self) -> bool { + match self { + TableHeaderScope::Both => true, + TableHeaderScope::Column => false, + TableHeaderScope::Row => true, + } + } +} diff --git a/crates/typst-library/src/pdf/mod.rs b/crates/typst-library/src/pdf/mod.rs index aae9e57bbc..360c32252b 100644 --- a/crates/typst-library/src/pdf/mod.rs +++ b/crates/typst-library/src/pdf/mod.rs @@ -20,5 +20,7 @@ pub fn module() -> Module { .with_until("0.15.0"), ); pdf.define_elem::(); + pdf.define_func::(); + pdf.define_func::(); Module::new("pdf", pdf) } diff --git a/crates/typst-pdf/Cargo.toml b/crates/typst-pdf/Cargo.toml index b652da5e88..866a19ea03 100644 --- a/crates/typst-pdf/Cargo.toml +++ b/crates/typst-pdf/Cargo.toml @@ -19,6 +19,7 @@ typst-macros = { workspace = true } typst-syntax = { workspace = true } typst-timing = { workspace = true } typst-utils = { workspace = true } +az = { workspace = true } bytemuck = { workspace = true } comemo = { workspace = true } ecow = { workspace = true } @@ -28,6 +29,7 @@ krilla = { workspace = true } krilla-svg = { workspace = true } rustc-hash = { workspace = true } serde = { workspace = true } +smallvec = { workspace = true } [lints] workspace = true diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index b5c9e5f2b4..ade0d0dee5 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -70,10 +70,29 @@ pub fn handle_start(gc: &mut GlobalContext, elem: &Content) { } else if let Some(_) = elem.to_packed::() { Tag::Caption.into() } else if let Some(table) = elem.to_packed::() { - push_stack(gc, loc, StackEntryKind::Table(TableCtx::new(table.clone()))); + let table_id = gc.tags.next_table_id(); + let summary = table + .summary + .get_ref(StyleChain::default()) + .as_ref() + .map(EcoString::to_string); + let ctx = TableCtx::new(table_id, summary); + push_stack(gc, loc, StackEntryKind::Table(ctx)); return; } else if let Some(cell) = elem.to_packed::() { - push_stack(gc, loc, StackEntryKind::TableCell(cell.clone())); + let table_ctx = gc.tags.parent_table(); + + // Only repeated table headers and footer cells are laid out multiple + // times. Mark duplicate headers as artifacts, since they have no + // semantic meaning in the tag tree, which doesn't use page breaks for + // it's semantic structure. + if cell.is_repeated.get(StyleChain::default()) + || table_ctx.is_some_and(|ctx| ctx.contains(cell)) + { + start_artifact(gc, loc, ArtifactKind::Other); + } else { + push_stack(gc, loc, StackEntryKind::TableCell(cell.clone())); + } return; } else if let Some(link) = elem.to_packed::() { let link_id = gc.tags.next_link_id(); @@ -104,10 +123,7 @@ pub fn handle_end(gc: &mut GlobalContext, loc: Location) { let node = match entry.kind { StackEntryKind::Standard(tag) => TagNode::group(tag, entry.nodes), - StackEntryKind::Outline(ctx) => { - let nodes = ctx.build_outline(entry.nodes); - TagNode::group(Tag::TOC, nodes) - } + StackEntryKind::Outline(ctx) => ctx.build_outline(entry.nodes), StackEntryKind::OutlineEntry(outline_entry) => { let parent = gc.tags.stack.last_mut().and_then(|parent| { let ctx = parent.kind.as_outline_mut()?; @@ -124,24 +140,17 @@ pub fn handle_end(gc: &mut GlobalContext, loc: Location) { outline_ctx.insert(parent_nodes, outline_entry, entry.nodes); return; } - StackEntryKind::Table(ctx) => { - let summary = ctx - .table - .summary - .get_ref(StyleChain::default()) - .as_ref() - .map(EcoString::to_string); - let nodes = ctx.build_table(entry.nodes); - TagNode::group(Tag::Table.with_summary(summary), nodes) - } + StackEntryKind::Table(ctx) => ctx.build_table(entry.nodes), StackEntryKind::TableCell(cell) => { - let parent = gc.tags.stack.last_mut().expect("table"); - let StackEntryKind::Table(table_ctx) = &mut parent.kind else { - unreachable!("expected table") + let Some(table_ctx) = gc.tags.parent_table() else { + // PDF/UA compliance of the structure hierarchy is checked + // elsewhere. While this doesn't make a lot of sense, just + // avoid crashing here. + gc.tags.push(TagNode::group(Tag::TD, entry.nodes)); + return; }; - table_ctx.insert(cell, entry.nodes); - + table_ctx.insert(&cell, entry.nodes); return; } StackEntryKind::Link(_, _) => { @@ -165,6 +174,9 @@ pub struct Tags { pub in_artifact: Option<(Location, ArtifactKind)>, /// Used to group multiple link annotations using quad points. pub link_id: LinkId, + /// Used to generate IDs referenced in table `Headers` attributes. + /// The IDs must be document wide unique. + pub table_id: TableId, /// The output. pub tree: Vec, @@ -179,6 +191,7 @@ impl Tags { tree: Vec::new(), link_id: LinkId(0), + table_id: TableId(0), } } @@ -205,6 +218,10 @@ impl Tags { self.stack.last_mut().map(|e| &mut e.kind) } + pub fn parent_table(&mut self) -> Option<&mut TableCtx> { + self.parent()?.as_table_mut() + } + pub fn parent_outline_entry(&mut self) -> Option<&mut OutlineEntry> { self.parent()?.as_outline_entry_mut() } @@ -250,8 +267,16 @@ impl Tags { self.link_id.0 += 1; self.link_id } + + fn next_table_id(&mut self) -> TableId { + self.table_id.0 += 1; + self.table_id + } } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct TableId(u32); + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct LinkId(u32); @@ -281,12 +306,16 @@ impl StackEntryKind { if let Self::OutlineEntry(v) = self { Some(v) } else { None } } + pub fn as_table_mut(&mut self) -> Option<&mut TableCtx> { + if let Self::Table(v) = self { Some(v) } else { None } + } + pub fn as_link(&self) -> Option<(LinkId, &Packed)> { if let Self::Link(id, link) = self { Some((*id, link)) } else { None } } } -#[derive(Clone, Debug)] +#[derive(Debug, Clone, PartialEq)] pub enum TagNode { Group(TagKind, Vec), Leaf(Identifier), @@ -301,7 +330,7 @@ impl TagNode { } } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Placeholder(usize); /// Automatically calls [`Surface::end_tagged`] when dropped. diff --git a/crates/typst-pdf/src/tags/outline.rs b/crates/typst-pdf/src/tags/outline.rs index 23c6f84bdf..36e68749c4 100644 --- a/crates/typst-pdf/src/tags/outline.rs +++ b/crates/typst-pdf/src/tags/outline.rs @@ -45,14 +45,11 @@ impl OutlineCtx { } } - pub fn build_outline( - mut self, - mut outline_nodes: Vec, - ) -> Vec { + pub fn build_outline(mut self, mut outline_nodes: Vec) -> TagNode { while !self.stack.is_empty() { self.finish_section(&mut outline_nodes); } - outline_nodes + TagNode::group(Tag::TOC, outline_nodes) } } diff --git a/crates/typst-pdf/src/tags/table.rs b/crates/typst-pdf/src/tags/table.rs index a71103ccaa..c931da7ce4 100644 --- a/crates/typst-pdf/src/tags/table.rs +++ b/crates/typst-pdf/src/tags/table.rs @@ -1,71 +1,571 @@ +use std::io::Write as _; use std::num::NonZeroU32; -use krilla::tagging::{TableHeaderScope, Tag, TagKind}; -use typst_library::foundations::{Packed, StyleChain}; -use typst_library::model::{TableCell, TableElem}; +use az::SaturatingAs; +use krilla::tagging::{Tag, TagId}; +use smallvec::SmallVec; +use typst_library::foundations::{Packed, Smart, StyleChain}; +use typst_library::model::TableCell; +use typst_library::pdf::{TableCellKind, TableHeaderScope}; -use crate::tags::TagNode; +use crate::tags::{TableId, TagNode}; #[derive(Debug)] pub struct TableCtx { - pub table: Packed, - rows: Vec, TagKind, Vec)>>>, + pub id: TableId, + pub summary: Option, + rows: Vec>, + min_width: usize, } impl TableCtx { - pub fn new(table: Packed) -> Self { - Self { table: table.clone(), rows: Vec::new() } + pub fn new(id: TableId, summary: Option) -> Self { + Self { id, summary, rows: Vec::new(), min_width: 0 } } - pub fn insert(&mut self, cell: Packed, nodes: Vec) { + fn get(&self, x: usize, y: usize) -> Option<&TableCtxCell> { + let cell = self.rows.get(y)?.get(x)?; + self.resolve_cell(cell) + } + + fn get_mut(&mut self, x: usize, y: usize) -> Option<&mut TableCtxCell> { + let cell = self.rows.get_mut(y)?.get_mut(x)?; + match cell { + // Reborrow here, so the borrow of `cell` doesn't get returned from + // the function. Otherwise the borrow checker assumes `cell` borrows + // `self.rows` for the entirety of the function, not just this match + // arm, and doesn't allow the second mutable borrow in the match arm + // below. + GridCell::Cell(_) => self.rows[y][x].as_cell_mut(), + &mut GridCell::Spanned(x, y) => self.rows[y][x].as_cell_mut(), + GridCell::Missing => None, + } + } + + pub fn contains(&self, cell: &Packed) -> bool { let x = cell.x.get(StyleChain::default()).unwrap_or_else(|| unreachable!()); let y = cell.y.get(StyleChain::default()).unwrap_or_else(|| unreachable!()); - let rowspan = cell.rowspan.get(StyleChain::default()).get(); - let colspan = cell.colspan.get(StyleChain::default()).get(); - - let tag = { - // TODO: possibly set internal field on TableCell when resolving - // the cell grid. - let is_header = false; - let rowspan = - (rowspan != 1).then_some(NonZeroU32::new(rowspan as u32).unwrap()); - let colspan = - (colspan != 1).then_some(NonZeroU32::new(colspan as u32).unwrap()); - if is_header { - let scope = TableHeaderScope::Column; // TODO - Tag::TH(scope).with_row_span(rowspan).with_col_span(colspan).into() - } else { - Tag::TD.with_row_span(rowspan).with_col_span(colspan).into() - } - }; + self.get(x, y).is_some() + } - let required_height = y + rowspan; + fn resolve_cell<'a>(&'a self, cell: &'a GridCell) -> Option<&'a TableCtxCell> { + match cell { + GridCell::Cell(cell) => Some(cell), + &GridCell::Spanned(x, y) => self.rows[y][x].as_cell(), + GridCell::Missing => None, + } + } + + pub fn insert(&mut self, cell: &TableCell, nodes: Vec) { + let x = cell.x.get(StyleChain::default()).unwrap_or_else(|| unreachable!()); + let y = cell.y.get(StyleChain::default()).unwrap_or_else(|| unreachable!()); + let rowspan = cell.rowspan.get(StyleChain::default()); + let colspan = cell.colspan.get(StyleChain::default()); + let kind = cell.kind.get(StyleChain::default()); + + // Extend the table grid to fit this cell. + let required_height = y + rowspan.get(); + self.min_width = self.min_width.max(x + colspan.get()); if self.rows.len() < required_height { - self.rows.resize_with(required_height, Vec::new); + self.rows + .resize(required_height, vec![GridCell::Missing; self.min_width]); + } + for row in self.rows.iter_mut() { + if row.len() < self.min_width { + row.resize_with(self.min_width, || GridCell::Missing); + } } - let required_width = x + colspan; - let row = &mut self.rows[y]; - if row.len() < required_width { - row.resize_with(required_width, || None); + // Store references to the cell for all spanned cells. + for i in y..y + rowspan.get() { + for j in x..x + colspan.get() { + self.rows[i][j] = GridCell::Spanned(x, y); + } } - row[x] = Some((cell, tag, nodes)); + self.rows[y][x] = GridCell::Cell(TableCtxCell { + x: x.saturating_as(), + y: y.saturating_as(), + rowspan: rowspan.try_into().unwrap_or(NonZeroU32::MAX), + colspan: colspan.try_into().unwrap_or(NonZeroU32::MAX), + kind, + headers: SmallVec::new(), + nodes, + }); } - pub fn build_table(self, mut nodes: Vec) -> Vec { + pub fn build_table(mut self, mut nodes: Vec) -> TagNode { // Table layouting ensures that there are no overlapping cells, and that // any gaps left by the user are filled with empty cells. - for row in self.rows.into_iter() { - let mut row_nodes = Vec::new(); - for (_, tag, nodes) in row.into_iter().flatten() { - row_nodes.push(TagNode::group(tag, nodes)); + if self.rows.is_empty() { + return TagNode::group(Tag::Table.with_summary(self.summary), nodes); + } + let height = self.rows.len(); + let width = self.rows[0].len(); + + // Only generate row groups such as `THead`, `TFoot`, and `TBody` if + // there are no rows with mixed cell kinds. + let mut gen_row_groups = true; + let row_kinds = (self.rows.iter()) + .map(|row| { + row.iter() + .filter_map(|cell| self.resolve_cell(cell)) + .map(|cell| cell.kind) + .fold(Smart::Auto, |a, b| { + if let Smart::Custom(TableCellKind::Header(_, scope)) = b { + gen_row_groups &= scope == TableHeaderScope::Column; + } + if let (Smart::Custom(a), Smart::Custom(b)) = (a, b) { + gen_row_groups &= a == b; + } + a.or(b) + }) + .unwrap_or(TableCellKind::Data) + }) + .collect::>(); + + // Fixup all missing cell kinds. + for (row, row_kind) in self.rows.iter_mut().zip(row_kinds.iter().copied()) { + let default_kind = + if gen_row_groups { row_kind } else { TableCellKind::Data }; + for cell in row.iter_mut() { + let Some(cell) = cell.as_cell_mut() else { continue }; + cell.kind = cell.kind.or(Smart::Custom(default_kind)); } + } - // TODO: generate `THead`, `TBody`, and `TFoot` - nodes.push(TagNode::group(Tag::TR, row_nodes)); + // Explicitly set the headers attribute for cells. + for x in 0..width { + let mut column_header = Vec::new(); + for y in 0..height { + self.resolve_cell_headers( + (x, y), + &mut column_header, + TableHeaderScope::refers_to_column, + ); + } + } + for y in 0..height { + let mut row_header = Vec::new(); + for x in 0..width { + self.resolve_cell_headers( + (x, y), + &mut row_header, + TableHeaderScope::refers_to_row, + ); + } } - nodes + let mut chunk_kind = row_kinds[0]; + let mut row_chunk = Vec::new(); + for (row, row_kind) in self.rows.into_iter().zip(row_kinds) { + let row_nodes = row + .into_iter() + .filter_map(|cell| { + let cell = cell.into_cell()?; + let rowspan = (cell.rowspan.get() != 1).then_some(cell.rowspan); + let colspan = (cell.colspan.get() != 1).then_some(cell.colspan); + let tag = match cell.unwrap_kind() { + TableCellKind::Header(_, scope) => { + let id = table_cell_id(self.id, cell.x, cell.y); + let scope = table_header_scope(scope); + Tag::TH(scope) + .with_id(Some(id)) + .with_headers(Some(cell.headers)) + .with_row_span(rowspan) + .with_col_span(colspan) + .into() + } + TableCellKind::Footer | TableCellKind::Data => Tag::TD + .with_headers(Some(cell.headers)) + .with_row_span(rowspan) + .with_col_span(colspan) + .into(), + }; + + Some(TagNode::Group(tag, cell.nodes)) + }) + .collect(); + + let row = TagNode::group(Tag::TR, row_nodes); + + // Push the `TR` tags directly. + if !gen_row_groups { + nodes.push(row); + continue; + } + + // Generate row groups. + if !should_group_rows(chunk_kind, row_kind) { + let tag = match chunk_kind { + TableCellKind::Header(..) => Tag::THead.into(), + TableCellKind::Footer => Tag::TFoot.into(), + TableCellKind::Data => Tag::TBody.into(), + }; + nodes.push(TagNode::Group(tag, std::mem::take(&mut row_chunk))); + + chunk_kind = row_kind; + } + row_chunk.push(row); + } + + if !row_chunk.is_empty() { + let tag = match chunk_kind { + TableCellKind::Header(..) => Tag::THead.into(), + TableCellKind::Footer => Tag::TFoot.into(), + TableCellKind::Data => Tag::TBody.into(), + }; + nodes.push(TagNode::Group(tag, row_chunk)); + } + + TagNode::group(Tag::Table.with_summary(self.summary), nodes) + } + + fn resolve_cell_headers( + &mut self, + (x, y): (usize, usize), + current_header: &mut Vec<(NonZeroU32, TagId)>, + refers_to_dir: F, + ) where + F: Fn(&TableHeaderScope) -> bool, + { + let table_id = self.id; + let Some(cell) = self.get_mut(x, y) else { return }; + + let mut new_header = None; + if let TableCellKind::Header(level, scope) = cell.unwrap_kind() { + if refers_to_dir(&scope) { + // Remove all headers that are the same or a lower level. + while current_header.pop_if(|(l, _)| *l >= level).is_some() {} + + let tag_id = table_cell_id(table_id, cell.x, cell.y); + new_header = Some((level, tag_id)); + } + } + + if let Some((_, cell_id)) = current_header.last() { + if !cell.headers.contains(cell_id) { + cell.headers.push(cell_id.clone()); + } + } + + current_header.extend(new_header); + } +} + +#[derive(Clone, Debug, Default)] +enum GridCell { + Cell(TableCtxCell), + Spanned(usize, usize), + #[default] + Missing, +} + +impl GridCell { + fn as_cell(&self) -> Option<&TableCtxCell> { + if let Self::Cell(v) = self { Some(v) } else { None } + } + + fn as_cell_mut(&mut self) -> Option<&mut TableCtxCell> { + if let Self::Cell(v) = self { Some(v) } else { None } + } + + fn into_cell(self) -> Option { + if let Self::Cell(v) = self { Some(v) } else { None } + } +} + +#[derive(Clone, Debug)] +struct TableCtxCell { + x: u32, + y: u32, + rowspan: NonZeroU32, + colspan: NonZeroU32, + kind: Smart, + headers: SmallVec<[TagId; 1]>, + nodes: Vec, +} + +impl TableCtxCell { + fn unwrap_kind(&self) -> TableCellKind { + self.kind.unwrap_or_else(|| unreachable!()) + } +} + +fn should_group_rows(a: TableCellKind, b: TableCellKind) -> bool { + match (a, b) { + (TableCellKind::Header(..), TableCellKind::Header(..)) => true, + (TableCellKind::Footer, TableCellKind::Footer) => true, + (TableCellKind::Data, TableCellKind::Data) => true, + (_, _) => false, + } +} + +fn table_cell_id(table_id: TableId, x: u32, y: u32) -> TagId { + let mut buf = SmallVec::<[u8; 32]>::new(); + _ = write!(&mut buf, "{}x{x}y{y}", table_id.0); + TagId::from(buf) +} + +fn table_header_scope(scope: TableHeaderScope) -> krilla::tagging::TableHeaderScope { + match scope { + TableHeaderScope::Both => krilla::tagging::TableHeaderScope::Both, + TableHeaderScope::Column => krilla::tagging::TableHeaderScope::Column, + TableHeaderScope::Row => krilla::tagging::TableHeaderScope::Row, + } +} + +#[cfg(test)] +mod tests { + use typst_library::foundations::Content; + + use super::*; + + #[track_caller] + fn test(table: TableCtx, exp_tag: TagNode) { + let tag = table.build_table(Vec::new()); + assert_eq!(exp_tag, tag); + } + + #[track_caller] + fn table(cells: [TableCell; SIZE]) -> TableCtx { + let mut table = TableCtx::new(TableId(324), Some("summary".into())); + for cell in cells { + table.insert(&cell, Vec::new()); + } + table + } + + #[track_caller] + fn header_cell( + (x, y): (usize, usize), + level: u32, + scope: TableHeaderScope, + ) -> TableCell { + TableCell::new(Content::default()) + .with_x(Smart::Custom(x)) + .with_y(Smart::Custom(y)) + .with_kind(Smart::Custom(TableCellKind::Header( + NonZeroU32::new(level).unwrap(), + scope, + ))) + } + + #[track_caller] + fn footer_cell(x: usize, y: usize) -> TableCell { + TableCell::new(Content::default()) + .with_x(Smart::Custom(x)) + .with_y(Smart::Custom(y)) + .with_kind(Smart::Custom(TableCellKind::Footer)) + } + + fn cell(x: usize, y: usize) -> TableCell { + TableCell::new(Content::default()) + .with_x(Smart::Custom(x)) + .with_y(Smart::Custom(y)) + .with_kind(Smart::Custom(TableCellKind::Data)) + } + + fn empty_cell(x: usize, y: usize) -> TableCell { + TableCell::new(Content::default()) + .with_x(Smart::Custom(x)) + .with_y(Smart::Custom(y)) + .with_kind(Smart::Auto) + } + + fn table_tag(nodes: [TagNode; SIZE]) -> TagNode { + let tag = Tag::Table.with_summary(Some("summary".into())); + TagNode::group(tag, nodes.into()) + } + + fn thead(nodes: [TagNode; SIZE]) -> TagNode { + TagNode::group(Tag::THead, nodes.into()) + } + + fn tbody(nodes: [TagNode; SIZE]) -> TagNode { + TagNode::group(Tag::TBody, nodes.into()) + } + + fn tfoot(nodes: [TagNode; SIZE]) -> TagNode { + TagNode::group(Tag::TFoot, nodes.into()) + } + + fn trow(nodes: [TagNode; SIZE]) -> TagNode { + TagNode::group(Tag::TR, nodes.into()) + } + + fn th( + (x, y): (u32, u32), + scope: TableHeaderScope, + headers: [(u32, u32); SIZE], + ) -> TagNode { + let scope = table_header_scope(scope); + let id = table_cell_id(TableId(324), x, y); + let ids = headers.map(|(x, y)| table_cell_id(TableId(324), x, y)).into_iter(); + TagNode::group( + Tag::TH(scope).with_id(Some(id)).with_headers(Some(ids)), + Vec::new(), + ) + } + + fn td(headers: [(u32, u32); SIZE]) -> TagNode { + let ids = headers.map(|(x, y)| table_cell_id(TableId(324), x, y)).into_iter(); + TagNode::group(Tag::TD.with_headers(Some(ids)), Vec::new()) + } + + #[test] + fn simple_table() { + #[rustfmt::skip] + let table = table([ + header_cell((0, 0), 1, TableHeaderScope::Column), + header_cell((1, 0), 1, TableHeaderScope::Column), + header_cell((2, 0), 1, TableHeaderScope::Column), + + cell(0, 1), + cell(1, 1), + cell(2, 1), + + cell(0, 2), + cell(1, 2), + cell(2, 2), + ]); + + #[rustfmt::skip] + let tag = table_tag([ + thead([trow([ + th((0, 0), TableHeaderScope::Column, []), + th((1, 0), TableHeaderScope::Column, []), + th((2, 0), TableHeaderScope::Column, []), + ])]), + tbody([ + trow([ + td([(0, 0)]), + td([(1, 0)]), + td([(2, 0)]), + ]), + trow([ + td([(0, 0)]), + td([(1, 0)]), + td([(2, 0)]), + ]), + ]), + ]); + + test(table, tag); + } + + #[test] + fn header_row_and_column() { + #[rustfmt::skip] + let table = table([ + header_cell((0, 0), 1, TableHeaderScope::Column), + header_cell((1, 0), 1, TableHeaderScope::Column), + header_cell((2, 0), 1, TableHeaderScope::Column), + + header_cell((0, 1), 1, TableHeaderScope::Row), + cell(1, 1), + cell(2, 1), + + header_cell((0, 2), 1, TableHeaderScope::Row), + cell(1, 2), + cell(2, 2), + ]); + + #[rustfmt::skip] + let tag = table_tag([ + trow([ + th((0, 0), TableHeaderScope::Column, []), + th((1, 0), TableHeaderScope::Column, []), + th((2, 0), TableHeaderScope::Column, []), + ]), + trow([ + th((0, 1), TableHeaderScope::Row, [(0, 0)]), + td([(1, 0), (0, 1)]), + td([(2, 0), (0, 1)]), + ]), + trow([ + th((0, 2), TableHeaderScope::Row, [(0, 0)]), + td([(1, 0), (0, 2)]), + td([(2, 0), (0, 2)]), + ]), + ]); + + test(table, tag); + } + + #[test] + fn complex_tables() { + #[rustfmt::skip] + let table = table([ + header_cell((0, 0), 1, TableHeaderScope::Column), + header_cell((1, 0), 1, TableHeaderScope::Column), + header_cell((2, 0), 1, TableHeaderScope::Column), + + header_cell((0, 1), 2, TableHeaderScope::Column), + header_cell((1, 1), 2, TableHeaderScope::Column), + header_cell((2, 1), 2, TableHeaderScope::Column), + + cell(0, 2), + empty_cell(1, 2), // the type of empty cells is inferred from the row + cell(2, 2), + + header_cell((0, 3), 2, TableHeaderScope::Column), + header_cell((1, 3), 2, TableHeaderScope::Column), + empty_cell(2, 3), // the type of empty cells is inferred from the row + + cell(0, 4), + cell(1, 4), + empty_cell(2, 4), + + empty_cell(0, 5), // the type of empty cells is inferred from the row + footer_cell(1, 5), + footer_cell(2, 5), + ]); + + #[rustfmt::skip] + let tag = table_tag([ + thead([ + trow([ + th((0, 0), TableHeaderScope::Column, []), + th((1, 0), TableHeaderScope::Column, []), + th((2, 0), TableHeaderScope::Column, []), + ]), + trow([ + th((0, 1), TableHeaderScope::Column, [(0, 0)]), + th((1, 1), TableHeaderScope::Column, [(1, 0)]), + th((2, 1), TableHeaderScope::Column, [(2, 0)]), + ]), + ]), + tbody([ + trow([ + td([(0, 1)]), + td([(1, 1)]), + td([(2, 1)]), + ]), + ]), + thead([ + trow([ + th((0, 3), TableHeaderScope::Column, [(0, 0)]), + th((1, 3), TableHeaderScope::Column, [(1, 0)]), + th((2, 3), TableHeaderScope::Column, [(2, 0)]), + ]), + ]), + tbody([ + trow([ + td([(0, 3)]), + td([(1, 3)]), + td([(2, 3)]), + ]), + ]), + tfoot([ + trow([ + td([(0, 3)]), + td([(1, 3)]), + td([(2, 3)]), + ]), + ]), + ]); + + test(table, tag); } } From 069388945258cbeda0b370b32e001075894ec1b8 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Wed, 1 Oct 2025 10:25:38 +0200 Subject: [PATCH 403/558] Generate alt text for link annotations --- crates/typst-layout/src/rules.rs | 22 ++- .../typst-library/src/model/bibliography.rs | 8 +- crates/typst-library/src/model/footnote.rs | 16 ++- crates/typst-library/src/model/link.rs | 130 +++++++++++++++--- crates/typst-library/src/model/reference.rs | 8 +- crates/typst-library/translations/ar.txt | 3 + crates/typst-library/translations/bg.txt | 3 + crates/typst-library/translations/ca.txt | 3 + crates/typst-library/translations/cs.txt | 3 + crates/typst-library/translations/da.txt | 3 + crates/typst-library/translations/de.txt | 3 + crates/typst-library/translations/el.txt | 3 + crates/typst-library/translations/en.txt | 3 + crates/typst-library/translations/es.txt | 3 + crates/typst-library/translations/et.txt | 3 + crates/typst-library/translations/eu.txt | 3 + crates/typst-library/translations/fi.txt | 3 + crates/typst-library/translations/fr.txt | 3 + crates/typst-library/translations/gl.txt | 3 + crates/typst-library/translations/he.txt | 3 + crates/typst-library/translations/hr.txt | 3 + crates/typst-library/translations/hu.txt | 3 + crates/typst-library/translations/id.txt | 3 + crates/typst-library/translations/is.txt | 3 + crates/typst-library/translations/it.txt | 3 + crates/typst-library/translations/ja.txt | 3 + crates/typst-library/translations/la.txt | 3 + crates/typst-library/translations/lt.txt | 2 + crates/typst-library/translations/lv.txt | 3 + crates/typst-library/translations/nb.txt | 3 + crates/typst-library/translations/nl.txt | 3 + crates/typst-library/translations/nn.txt | 3 + crates/typst-library/translations/pl.txt | 3 + crates/typst-library/translations/pt-PT.txt | 3 + crates/typst-library/translations/pt.txt | 3 + crates/typst-library/translations/ro.txt | 3 + crates/typst-library/translations/ru.txt | 3 + crates/typst-library/translations/sl.txt | 3 + crates/typst-library/translations/sq.txt | 3 + crates/typst-library/translations/sr.txt | 3 + crates/typst-library/translations/sv.txt | 3 + crates/typst-library/translations/tl.txt | 3 + crates/typst-library/translations/tr.txt | 3 + crates/typst-library/translations/uk.txt | 3 + crates/typst-library/translations/vi.txt | 3 + crates/typst-library/translations/zh-TW.txt | 3 + crates/typst-library/translations/zh.txt | 3 + 47 files changed, 278 insertions(+), 31 deletions(-) diff --git a/crates/typst-layout/src/rules.rs b/crates/typst-layout/src/rules.rs index 3654e05ef9..acb3281e6f 100644 --- a/crates/typst-layout/src/rules.rs +++ b/crates/typst-layout/src/rules.rs @@ -26,8 +26,8 @@ use typst_library::pdf::{ArtifactElem, AttachElem}; use typst_library::text::{ DecoLine, Decoration, HighlightElem, ItalicToggle, LinebreakElem, LocalName, OverlineElem, RawElem, RawLine, ScriptKind, ShiftSettings, Smallcaps, SmallcapsElem, - SpaceElem, StrikeElem, SubElem, SuperElem, TextElem, TextSize, UnderlineElem, - WeightDelta, + SmartQuoteElem, SmartQuotes, SpaceElem, StrikeElem, SubElem, SuperElem, TextElem, + TextSize, UnderlineElem, WeightDelta, }; use typst_library::visualize::{ CircleElem, CurveElem, EllipseElem, ImageElem, LineElem, PathElem, PolygonElem, @@ -221,8 +221,8 @@ const LINK_MARKER_RULE: ShowFn = |elem, _, _| Ok(elem.body.clone()); const LINK_RULE: ShowFn = |elem, engine, styles| { let body = elem.body.clone(); let dest = elem.dest.resolve(engine.introspector).at(elem.span())?; - let alt = elem.alt.get_cloned(styles); - Ok(body.linked(dest, alt)) + let alt = dest.alt_text(engine, styles)?; + Ok(body.linked(dest, Some(alt))) }; const DIRECT_LINK_RULE: ShowFn = |elem, _, _| { @@ -390,9 +390,9 @@ const QUOTE_RULE: ShowFn = |elem, _, styles| { const FOOTNOTE_RULE: ShowFn = |elem, engine, styles| { let span = elem.span(); let (dest, num) = elem.realize(engine, styles)?; + let alt = FootnoteElem::alt_text(styles, &num.plain_text()); let sup = SuperElem::new(num).pack().spanned(span); - // TODO: generate alt text - Ok(HElem::hole().clone() + sup.linked(dest, None)) + Ok(HElem::hole().clone() + sup.linked(dest, Some(alt))) }; const FOOTNOTE_ENTRY_RULE: ShowFn = |elem, engine, styles| { @@ -429,7 +429,15 @@ const OUTLINE_ENTRY_RULE: ShowFn = |elem, engine, styles| { let body = body.plain_text(); let page_str = PageElem::local_name_in(styles); let page_nr = page.plain_text(); - eco_format!("{prefix} \"{body}\", {page_str} {page_nr}") + let quotes = SmartQuotes::get( + styles.get_ref(SmartQuoteElem::quotes), + styles.get(TextElem::lang), + styles.get(TextElem::region), + styles.get(SmartQuoteElem::alternative), + ); + let open = quotes.double_open; + let close = quotes.double_close; + eco_format!("{prefix} {open}{body}{close} {page_str} {page_nr}",) }; let inner = elem.build_inner(context, span, body, page)?; let block = if elem.element.is::() { diff --git a/crates/typst-library/src/model/bibliography.rs b/crates/typst-library/src/model/bibliography.rs index 5e782d126c..1b0eda8bc9 100644 --- a/crates/typst-library/src/model/bibliography.rs +++ b/crates/typst-library/src/model/bibliography.rs @@ -882,8 +882,8 @@ impl<'a> Generator<'a> { let prefix = prefix.map(|content| { if let Some(location) = first_occurrences.get(item.key.as_str()) { - // TODO: generate alt text - DirectLinkElem::new(*location, content, None).pack() + let alt = content.plain_text(); + DirectLinkElem::new(*location, content, Some(alt)).pack() } else { content } @@ -1004,8 +1004,8 @@ impl ElemRenderer<'_> { if let Some(hayagriva::ElemMeta::Entry(i)) = elem.meta && let Some(location) = (self.link)(i) { - // TODO: generate alt text - content = DirectLinkElem::new(location, content, None).pack(); + let alt = content.plain_text(); + content = DirectLinkElem::new(location, content, Some(alt)).pack(); } Ok(content) diff --git a/crates/typst-library/src/model/footnote.rs b/crates/typst-library/src/model/footnote.rs index fdf3a097d7..d4a404c6e4 100644 --- a/crates/typst-library/src/model/footnote.rs +++ b/crates/typst-library/src/model/footnote.rs @@ -1,6 +1,7 @@ use std::num::NonZeroUsize; use std::str::FromStr; +use ecow::{EcoString, eco_format}; use typst_utils::NonZeroExt; use crate::diag::{At, SourceResult, StrResult, bail}; @@ -12,7 +13,7 @@ use crate::foundations::{ use crate::introspection::{Count, Counter, CounterUpdate, Locatable, Location}; use crate::layout::{Abs, Em, Length, Ratio}; use crate::model::{Destination, DirectLinkElem, Numbering, NumberingPattern, ParElem}; -use crate::text::{SuperElem, TextElem, TextSize}; +use crate::text::{LocalName, SuperElem, TextElem, TextSize}; use crate::visualize::{LineElem, Stroke}; /// A footnote. @@ -83,7 +84,16 @@ impl FootnoteElem { type FootnoteEntry; } +impl LocalName for Packed { + const KEY: &'static str = "footnote"; +} + impl FootnoteElem { + pub fn alt_text(styles: StyleChain, num: &str) -> EcoString { + let local_name = Packed::::local_name_in(styles); + eco_format!("{local_name} {num}") + } + /// Creates a new footnote that the passed content as its body. pub fn with_content(content: Content) -> Self { Self::new(FootnoteBody::Content(content)) @@ -293,9 +303,9 @@ impl Packed { }; let num = counter.display_at_loc(engine, loc, styles, numbering)?; + let alt = num.plain_text(); let sup = SuperElem::new(num).pack().spanned(span); - // TODO: generate alt text - let prefix = DirectLinkElem::new(loc, sup, None).pack().spanned(span); + let prefix = DirectLinkElem::new(loc, sup, Some(alt)).pack().spanned(span); let body = self.note.body_content().unwrap().clone(); Ok((prefix, body)) diff --git a/crates/typst-library/src/model/link.rs b/crates/typst-library/src/model/link.rs index 5256ce01f2..1a2d422512 100644 --- a/crates/typst-library/src/model/link.rs +++ b/crates/typst-library/src/model/link.rs @@ -1,4 +1,5 @@ use std::ops::Deref; +use std::str::FromStr; use comemo::Tracked; use ecow::{EcoString, eco_format}; @@ -6,12 +7,13 @@ use ecow::{EcoString, eco_format}; use crate::diag::{SourceResult, StrResult, bail}; use crate::engine::Engine; use crate::foundations::{ - Args, Construct, Content, Label, Packed, Repr, ShowSet, Smart, StyleChain, Styles, - cast, elem, + Args, Construct, Content, Label, Packed, Repr, Selector, ShowSet, Smart, StyleChain, + Styles, cast, elem, }; -use crate::introspection::{Introspector, Locatable, Location}; -use crate::layout::Position; -use crate::text::TextElem; +use crate::introspection::{Counter, CounterKey, Introspector, Locatable, Location}; +use crate::layout::{PageElem, Position}; +use crate::model::{NumberingPattern, Refable}; +use crate::text::{LocalName, TextElem}; /// Links to a URL or a location in the document. /// @@ -87,9 +89,6 @@ use crate::text::TextElem; /// generated. #[elem(Locatable)] pub struct LinkElem { - /// A text describing the link. - pub alt: Option, - /// The destination the link points to. /// /// - To link to web pages, `dest` should be a valid URL string. If the URL @@ -161,13 +160,9 @@ impl ShowSet for Packed { } } -fn body_from_url(url: &Url) -> Content { - let text = ["mailto:", "tel:"] - .into_iter() - .find_map(|prefix| url.strip_prefix(prefix)) - .unwrap_or(url); - let shorter = text.len() < url.len(); - TextElem::packed(if shorter { text.into() } else { (**url).clone() }) +pub(crate) fn body_from_url(url: &Url) -> Content { + let stripped = url.strip_contact_scheme().map(|(_, s)| s.into()); + TextElem::packed(stripped.unwrap_or_else(|| url.clone().into_inner())) } /// A target where a link can go. @@ -217,7 +212,65 @@ pub enum Destination { Location(Location), } -impl Destination {} +impl Destination { + pub fn alt_text( + &self, + engine: &mut Engine, + styles: StyleChain, + ) -> SourceResult { + match self { + Destination::Url(url) => { + let contact = url.strip_contact_scheme().map(|(scheme, stripped)| { + eco_format!("{} {stripped}", scheme.local_name_in(styles)) + }); + Ok(contact.unwrap_or_else(|| url.clone().into_inner())) + } + Destination::Position(pos) => { + let page_nr = eco_format!("{}", pos.page.get()); + let page_str = PageElem::local_name_in(styles); + Ok(eco_format!("{page_str} {page_nr}")) + } + &Destination::Location(loc) => { + let fallback = |engine: &mut Engine| { + // Fall back to a generating a page reference. + let numbering = loc.page_numbering(engine).unwrap_or_else(|| { + NumberingPattern::from_str("1").unwrap().into() + }); + let page_nr = Counter::new(CounterKey::Page) + .display_at_loc(engine, loc, styles, &numbering)? + .plain_text(); + let page_str = PageElem::local_name_in(styles); + Ok(eco_format!("{page_str} {page_nr}")) + }; + + // Try to generate more meaningful alt text if the location is a + // refable element. + let loc_selector = Selector::Location(loc); + if let Some(elem) = engine.introspector.query_first(&loc_selector) + && let Some(refable) = elem.with::() + { + let counter = refable.counter(); + let supplement = refable.supplement().plain_text(); + + if let Some(numbering) = refable.numbering() { + let numbers = counter.display_at_loc( + engine, + loc, + styles, + &numbering.clone().trimmed(), + )?; + return Ok(eco_format!("{supplement} {}", numbers.plain_text())); + } else { + let page_ref = fallback(engine)?; + return Ok(eco_format!("{supplement}, {page_ref}")); + } + } + + fallback(engine) + } + } + } +} impl Repr for Destination { fn repr(&self) -> EcoString { @@ -255,6 +308,15 @@ impl Url { pub fn into_inner(self) -> EcoString { self.0 } + + pub fn strip_contact_scheme(&self) -> Option<(UrlContactScheme, &str)> { + [UrlContactScheme::Mailto, UrlContactScheme::Tel] + .into_iter() + .find_map(|scheme| { + let stripped = self.strip_prefix(scheme.as_str())?; + Some((scheme, stripped)) + }) + } } impl Deref for Url { @@ -319,3 +381,39 @@ impl Construct for LinkMarker { bail!(args.span, "cannot be constructed manually"); } } + +#[derive(Copy, Clone)] +pub enum UrlContactScheme { + /// The `mailto:` prefix. + Mailto, + /// The `tel:` prefix. + Tel, +} + +impl UrlContactScheme { + pub fn as_str(self) -> &'static str { + match self { + Self::Mailto => "mailto:", + Self::Tel => "tel:", + } + } + + pub fn local_name_in(self, styles: StyleChain) -> &'static str { + match self { + UrlContactScheme::Mailto => Email::local_name_in(styles), + UrlContactScheme::Tel => Telephone::local_name_in(styles), + } + } +} + +#[derive(Copy, Clone)] +pub struct Email; +impl LocalName for Email { + const KEY: &'static str = "email"; +} + +#[derive(Copy, Clone)] +pub struct Telephone; +impl LocalName for Telephone { + const KEY: &'static str = "telephone"; +} diff --git a/crates/typst-library/src/model/reference.rs b/crates/typst-library/src/model/reference.rs index 74c378f1dc..67888d24fc 100644 --- a/crates/typst-library/src/model/reference.rs +++ b/crates/typst-library/src/model/reference.rs @@ -341,12 +341,18 @@ fn realize_reference( Smart::Custom(Some(supplement)) => supplement.resolve(engine, styles, [elem])?, }; + let alt = { + let supplement = supplement.plain_text(); + let numbering = numbers.plain_text(); + eco_format!("{supplement} {numbering}",) + }; + let mut content = numbers; if !supplement.is_empty() { content = supplement + TextElem::packed("\u{a0}") + content; } - Ok(DirectLinkElem::new(loc, content, None).pack()) + Ok(DirectLinkElem::new(loc, content, Some(alt)).pack()) } /// Turn a reference into a citation. diff --git a/crates/typst-library/translations/ar.txt b/crates/typst-library/translations/ar.txt index 7af2aaa91c..f2d8df7c8a 100644 --- a/crates/typst-library/translations/ar.txt +++ b/crates/typst-library/translations/ar.txt @@ -6,3 +6,6 @@ heading = الفصل outline = المحتويات raw = قائمة page = صفحة +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/bg.txt b/crates/typst-library/translations/bg.txt index e377af3988..aaa334dd90 100644 --- a/crates/typst-library/translations/bg.txt +++ b/crates/typst-library/translations/bg.txt @@ -6,3 +6,6 @@ heading = Раздел outline = Съдържание raw = Приложение page = стр. +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/ca.txt b/crates/typst-library/translations/ca.txt index f02473293d..211478bdcd 100644 --- a/crates/typst-library/translations/ca.txt +++ b/crates/typst-library/translations/ca.txt @@ -6,3 +6,6 @@ heading = Secció outline = Índex raw = Llistat page = pàgina +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/cs.txt b/crates/typst-library/translations/cs.txt index 417f1ab2ec..369685b620 100644 --- a/crates/typst-library/translations/cs.txt +++ b/crates/typst-library/translations/cs.txt @@ -6,3 +6,6 @@ heading = Kapitola outline = Obsah raw = Výpis page = strana +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/da.txt b/crates/typst-library/translations/da.txt index 4ceeda065c..45e23e44c2 100644 --- a/crates/typst-library/translations/da.txt +++ b/crates/typst-library/translations/da.txt @@ -6,3 +6,6 @@ heading = Afsnit outline = Indhold raw = Liste page = side +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/de.txt b/crates/typst-library/translations/de.txt index a9da1adb4d..e2b1ba7682 100644 --- a/crates/typst-library/translations/de.txt +++ b/crates/typst-library/translations/de.txt @@ -6,3 +6,6 @@ heading = Abschnitt outline = Inhaltsverzeichnis raw = Listing page = Seite +footnote = Fußnote +email = Email +telephone = Telefon diff --git a/crates/typst-library/translations/el.txt b/crates/typst-library/translations/el.txt index 3853a45bba..22b9cc1c10 100644 --- a/crates/typst-library/translations/el.txt +++ b/crates/typst-library/translations/el.txt @@ -5,3 +5,6 @@ bibliography = Βιβλιογραφία heading = Κεφάλαιο outline = Περιεχόμενα raw = Παράθεση +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/en.txt b/crates/typst-library/translations/en.txt index fa2d65b91b..eaa5fc1648 100644 --- a/crates/typst-library/translations/en.txt +++ b/crates/typst-library/translations/en.txt @@ -6,3 +6,6 @@ heading = Section outline = Contents raw = Listing page = page +footnote = Footnote +email = Email +telephone = Telephone diff --git a/crates/typst-library/translations/es.txt b/crates/typst-library/translations/es.txt index 8fe9929d84..97e7cccec1 100644 --- a/crates/typst-library/translations/es.txt +++ b/crates/typst-library/translations/es.txt @@ -6,3 +6,6 @@ heading = Sección outline = Índice raw = Listado page = página +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/et.txt b/crates/typst-library/translations/et.txt index 588929052f..be243f4897 100644 --- a/crates/typst-library/translations/et.txt +++ b/crates/typst-library/translations/et.txt @@ -6,3 +6,6 @@ heading = Peatükk outline = Sisukord raw = List page = lk. +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/eu.txt b/crates/typst-library/translations/eu.txt index d89f89b6f1..cef1a56a00 100644 --- a/crates/typst-library/translations/eu.txt +++ b/crates/typst-library/translations/eu.txt @@ -6,3 +6,6 @@ heading = Atala outline = Aurkibidea raw = Kodea page = orria +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/fi.txt b/crates/typst-library/translations/fi.txt index edb88de8d8..c769ee5b7d 100644 --- a/crates/typst-library/translations/fi.txt +++ b/crates/typst-library/translations/fi.txt @@ -6,3 +6,6 @@ heading = Osio outline = Sisällys raw = Esimerkki page = sivu +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/fr.txt b/crates/typst-library/translations/fr.txt index f8e27c9c0e..046d3c66f5 100644 --- a/crates/typst-library/translations/fr.txt +++ b/crates/typst-library/translations/fr.txt @@ -6,3 +6,6 @@ heading = Chapitre outline = Table des matières raw = Liste page = page +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/gl.txt b/crates/typst-library/translations/gl.txt index 49bf01b749..5857c3f373 100644 --- a/crates/typst-library/translations/gl.txt +++ b/crates/typst-library/translations/gl.txt @@ -6,3 +6,6 @@ heading = Sección outline = Índice raw = Listado page = páxina +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/he.txt b/crates/typst-library/translations/he.txt index 5317c92786..5fd052b5a3 100644 --- a/crates/typst-library/translations/he.txt +++ b/crates/typst-library/translations/he.txt @@ -6,3 +6,6 @@ heading = חלק outline = תוכן עניינים raw = קטע מקור page = עמוד +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/hr.txt b/crates/typst-library/translations/hr.txt index ea07545921..e84ad82d2a 100644 --- a/crates/typst-library/translations/hr.txt +++ b/crates/typst-library/translations/hr.txt @@ -6,3 +6,6 @@ heading = Odjeljak outline = Sadržaj raw = Kôd page = str. +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/hu.txt b/crates/typst-library/translations/hu.txt index a88da3e54e..a42482acc5 100644 --- a/crates/typst-library/translations/hu.txt +++ b/crates/typst-library/translations/hu.txt @@ -6,3 +6,6 @@ heading = Fejezet outline = Tartalomjegyzék # raw = page = oldal +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/id.txt b/crates/typst-library/translations/id.txt index bea5ee18cc..60ba7ced8c 100644 --- a/crates/typst-library/translations/id.txt +++ b/crates/typst-library/translations/id.txt @@ -6,3 +6,6 @@ heading = Bagian outline = Daftar Isi raw = Kode page = halaman +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/is.txt b/crates/typst-library/translations/is.txt index 756c977007..e4dedeb4b3 100644 --- a/crates/typst-library/translations/is.txt +++ b/crates/typst-library/translations/is.txt @@ -6,3 +6,6 @@ heading = Kafli outline = Efnisyfirlit raw = Sýnishorn page = blaðsíða +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/it.txt b/crates/typst-library/translations/it.txt index 9f3c352db0..a530a34649 100644 --- a/crates/typst-library/translations/it.txt +++ b/crates/typst-library/translations/it.txt @@ -6,3 +6,6 @@ heading = Sezione outline = Indice raw = Codice page = pag. +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/ja.txt b/crates/typst-library/translations/ja.txt index 484b20a627..89e4729a01 100644 --- a/crates/typst-library/translations/ja.txt +++ b/crates/typst-library/translations/ja.txt @@ -6,3 +6,6 @@ heading = 節 outline = 目次 raw = リスト page = ページ +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/la.txt b/crates/typst-library/translations/la.txt index d25517c2f4..d7d4b1191a 100644 --- a/crates/typst-library/translations/la.txt +++ b/crates/typst-library/translations/la.txt @@ -6,3 +6,6 @@ heading = Caput outline = Index capitum raw = Exemplum page = charta +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/lt.txt b/crates/typst-library/translations/lt.txt index 1df3ca91ba..760ee3ce8a 100644 --- a/crates/typst-library/translations/lt.txt +++ b/crates/typst-library/translations/lt.txt @@ -6,3 +6,5 @@ heading = Skyrius outline = Turinys raw = Kodo fragmentas page = Psl. +# email = +# telephone = diff --git a/crates/typst-library/translations/lv.txt b/crates/typst-library/translations/lv.txt index 4c6b86841b..1162e6e872 100644 --- a/crates/typst-library/translations/lv.txt +++ b/crates/typst-library/translations/lv.txt @@ -6,3 +6,6 @@ heading = Sadaļa outline = Saturs raw = Saraksts page = lpp. +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/nb.txt b/crates/typst-library/translations/nb.txt index edf66b53f8..0757e1d7de 100644 --- a/crates/typst-library/translations/nb.txt +++ b/crates/typst-library/translations/nb.txt @@ -6,3 +6,6 @@ heading = Kapittel outline = Innhold raw = Utskrift page = side +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/nl.txt b/crates/typst-library/translations/nl.txt index 24b8315f0d..58599928fe 100644 --- a/crates/typst-library/translations/nl.txt +++ b/crates/typst-library/translations/nl.txt @@ -6,3 +6,6 @@ heading = Hoofdstuk outline = Inhoudsopgave raw = Listing page = pagina +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/nn.txt b/crates/typst-library/translations/nn.txt index 2c2a27a769..072cc02e63 100644 --- a/crates/typst-library/translations/nn.txt +++ b/crates/typst-library/translations/nn.txt @@ -6,3 +6,6 @@ heading = Kapittel outline = Innhald raw = Utskrift page = side +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/pl.txt b/crates/typst-library/translations/pl.txt index cc8f4b36b4..8043e986ee 100644 --- a/crates/typst-library/translations/pl.txt +++ b/crates/typst-library/translations/pl.txt @@ -6,3 +6,6 @@ heading = Sekcja outline = Spis treści raw = Program page = strona +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/pt-PT.txt b/crates/typst-library/translations/pt-PT.txt index 1d68ab8583..c0fb8499d5 100644 --- a/crates/typst-library/translations/pt-PT.txt +++ b/crates/typst-library/translations/pt-PT.txt @@ -6,3 +6,6 @@ heading = Secção outline = Índice # raw = page = página +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/pt.txt b/crates/typst-library/translations/pt.txt index 398a75f375..f542b3b0a1 100644 --- a/crates/typst-library/translations/pt.txt +++ b/crates/typst-library/translations/pt.txt @@ -6,3 +6,6 @@ heading = Seção outline = Sumário raw = Listagem page = página +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/ro.txt b/crates/typst-library/translations/ro.txt index f5d44f726c..3c3b095694 100644 --- a/crates/typst-library/translations/ro.txt +++ b/crates/typst-library/translations/ro.txt @@ -7,3 +7,6 @@ outline = Cuprins # may be wrong raw = Listă page = pagina +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/ru.txt b/crates/typst-library/translations/ru.txt index 49cb34cb1a..efe8d1cee6 100644 --- a/crates/typst-library/translations/ru.txt +++ b/crates/typst-library/translations/ru.txt @@ -6,3 +6,6 @@ heading = Раздел outline = Содержание raw = Листинг page = с. +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/sl.txt b/crates/typst-library/translations/sl.txt index 4c8a568ceb..b7b6f3768a 100644 --- a/crates/typst-library/translations/sl.txt +++ b/crates/typst-library/translations/sl.txt @@ -6,3 +6,6 @@ heading = Poglavje outline = Kazalo raw = Program page = stran +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/sq.txt b/crates/typst-library/translations/sq.txt index 11ba532125..5d36485959 100644 --- a/crates/typst-library/translations/sq.txt +++ b/crates/typst-library/translations/sq.txt @@ -6,3 +6,6 @@ heading = Kapitull outline = Përmbajtja raw = List page = faqe +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/sr.txt b/crates/typst-library/translations/sr.txt index e4e8f12725..3b0b43a44f 100644 --- a/crates/typst-library/translations/sr.txt +++ b/crates/typst-library/translations/sr.txt @@ -6,3 +6,6 @@ heading = Поглавље outline = Садржај raw = Програм page = страна +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/sv.txt b/crates/typst-library/translations/sv.txt index 538f466b05..6a7146f8e4 100644 --- a/crates/typst-library/translations/sv.txt +++ b/crates/typst-library/translations/sv.txt @@ -6,3 +6,6 @@ heading = Avsnitt outline = Innehåll raw = Kodlistning page = sida +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/tl.txt b/crates/typst-library/translations/tl.txt index 39cff5e361..241217bc4d 100644 --- a/crates/typst-library/translations/tl.txt +++ b/crates/typst-library/translations/tl.txt @@ -6,3 +6,6 @@ heading = Seksyon outline = Talaan ng mga Nilalaman raw = Listahan # page = +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/tr.txt b/crates/typst-library/translations/tr.txt index f6e2cfe29e..6b0206c58e 100644 --- a/crates/typst-library/translations/tr.txt +++ b/crates/typst-library/translations/tr.txt @@ -6,3 +6,6 @@ heading = Bölüm outline = İçindekiler raw = Liste page = sayfa +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/uk.txt b/crates/typst-library/translations/uk.txt index 4794c3311b..cb29a79b48 100644 --- a/crates/typst-library/translations/uk.txt +++ b/crates/typst-library/translations/uk.txt @@ -6,3 +6,6 @@ heading = Розділ outline = Зміст raw = Лістинг page = c. +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/vi.txt b/crates/typst-library/translations/vi.txt index 8ccfdf02f1..2c9b4897f5 100644 --- a/crates/typst-library/translations/vi.txt +++ b/crates/typst-library/translations/vi.txt @@ -7,3 +7,6 @@ outline = Mục lục # may be wrong raw = Chương trình page = trang +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/zh-TW.txt b/crates/typst-library/translations/zh-TW.txt index 4407f323e5..0a13a93c5d 100644 --- a/crates/typst-library/translations/zh-TW.txt +++ b/crates/typst-library/translations/zh-TW.txt @@ -6,3 +6,6 @@ heading = 小節 outline = 目錄 raw = 程式 # page = +# footnote = +# email = +# telephone = diff --git a/crates/typst-library/translations/zh.txt b/crates/typst-library/translations/zh.txt index 32dc40107b..009e4745b0 100644 --- a/crates/typst-library/translations/zh.txt +++ b/crates/typst-library/translations/zh.txt @@ -6,3 +6,6 @@ heading = 小节 outline = 目录 raw = 代码 # page = +# footnote = +# email = +# telephone = From 7cee83534c9de765d6e899320866d808d78b2445 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Wed, 9 Jul 2025 01:08:34 +0200 Subject: [PATCH 404/558] Improve handling of artifacts - Group all contiguos artifacts on one page into one - Mark all shapes as artifacts --- crates/typst-pdf/src/convert.rs | 8 +- crates/typst-pdf/src/shape.rs | 3 +- crates/typst-pdf/src/tags/mod.rs | 123 +++++++++++++++++++------------ 3 files changed, 84 insertions(+), 50 deletions(-) diff --git a/crates/typst-pdf/src/convert.rs b/crates/typst-pdf/src/convert.rs index 29ee13b91c..8d028478c5 100644 --- a/crates/typst-pdf/src/convert.rs +++ b/crates/typst-pdf/src/convert.rs @@ -108,6 +108,8 @@ fn convert_pages(gc: &mut GlobalContext, document: &mut Document) -> SourceResul let mut surface = page.surface(); let mut fc = FrameContext::new(typst_page.frame.size()); + tags::page_start(gc, &mut surface); + handle_frame( &mut fc, &typst_page.frame, @@ -116,6 +118,8 @@ fn convert_pages(gc: &mut GlobalContext, document: &mut Document) -> SourceResul gc, )?; + tags::page_end(gc, &mut surface); + surface.finish(); tags::add_link_annotations(gc, &mut page, fc.link_annotations); @@ -291,8 +295,8 @@ pub(crate) fn handle_frame( handle_image(gc, fc, image, *size, surface, *span)? } FrameItem::Link(dest, size) => handle_link(fc, gc, dest, *size), - FrameItem::Tag(Tag::Start(elem)) => tags::handle_start(gc, elem), - FrameItem::Tag(Tag::End(loc, _)) => tags::handle_end(gc, *loc), + FrameItem::Tag(Tag::Start(elem)) => tags::handle_start(gc, surface, elem), + FrameItem::Tag(Tag::End(loc, _)) => tags::handle_end(gc, surface, *loc), } fc.pop(); diff --git a/crates/typst-pdf/src/shape.rs b/crates/typst-pdf/src/shape.rs index 56453cfaf0..def44c7bfb 100644 --- a/crates/typst-pdf/src/shape.rs +++ b/crates/typst-pdf/src/shape.rs @@ -1,6 +1,7 @@ use krilla::geom::{Path, PathBuilder, Rect}; use krilla::surface::Surface; use typst_library::diag::SourceResult; +use typst_library::pdf::ArtifactKind; use typst_library::visualize::{Geometry, Shape}; use typst_syntax::Span; @@ -16,7 +17,7 @@ pub(crate) fn handle_shape( gc: &mut GlobalContext, span: Span, ) -> SourceResult<()> { - let mut handle = tags::start_marked(gc, surface); + let mut handle = tags::start_artifact(gc, surface, ArtifactKind::Other); let surface = handle.surface(); surface.set_location(span.into_raw()); diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index ade0d0dee5..8a4a7639ae 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -25,7 +25,7 @@ use crate::tags::table::TableCtx; mod outline; mod table; -pub fn handle_start(gc: &mut GlobalContext, elem: &Content) { +pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Content) { if gc.tags.in_artifact.is_some() { // Don't nest artifacts return; @@ -35,10 +35,10 @@ pub fn handle_start(gc: &mut GlobalContext, elem: &Content) { if let Some(artifact) = elem.to_packed::() { let kind = artifact.kind.get(StyleChain::default()); - start_artifact(gc, loc, kind); + push_artifact(gc, surface, loc, kind); return; } else if let Some(_) = elem.to_packed::() { - start_artifact(gc, loc, ArtifactKind::Other); + push_artifact(gc, surface, loc, ArtifactKind::Other); return; } @@ -89,7 +89,7 @@ pub fn handle_start(gc: &mut GlobalContext, elem: &Content) { if cell.is_repeated.get(StyleChain::default()) || table_ctx.is_some_and(|ctx| ctx.contains(cell)) { - start_artifact(gc, loc, ArtifactKind::Other); + push_artifact(gc, surface, loc, ArtifactKind::Other); } else { push_stack(gc, loc, StackEntryKind::TableCell(cell.clone())); } @@ -105,14 +105,10 @@ pub fn handle_start(gc: &mut GlobalContext, elem: &Content) { push_stack(gc, loc, StackEntryKind::Standard(tag)); } -fn push_stack(gc: &mut GlobalContext, loc: Location, kind: StackEntryKind) { - gc.tags.stack.push(StackEntry { loc, kind, nodes: Vec::new() }); -} - -pub fn handle_end(gc: &mut GlobalContext, loc: Location) { +pub fn handle_end(gc: &mut GlobalContext, surface: &mut Surface, loc: Location) { if let Some((l, _)) = gc.tags.in_artifact { if l == loc { - gc.tags.in_artifact = None; + pop_artifact(gc, surface); } return; } @@ -166,6 +162,60 @@ pub fn handle_end(gc: &mut GlobalContext, loc: Location) { gc.tags.push(node); } +fn push_stack(gc: &mut GlobalContext, loc: Location, kind: StackEntryKind) { + gc.tags.stack.push(StackEntry { loc, kind, nodes: Vec::new() }); +} + +fn push_artifact( + gc: &mut GlobalContext, + surface: &mut Surface, + loc: Location, + kind: ArtifactKind, +) { + let ty = artifact_type(kind); + let id = surface.start_tagged(ContentTag::Artifact(ty)); + gc.tags.push(TagNode::Leaf(id)); + gc.tags.in_artifact = Some((loc, kind)); +} + +fn pop_artifact(gc: &mut GlobalContext, surface: &mut Surface) { + surface.end_tagged(); + gc.tags.in_artifact = None; +} + +pub fn page_start(gc: &mut GlobalContext, surface: &mut Surface) { + if let Some((_, kind)) = gc.tags.in_artifact { + let ty = artifact_type(kind); + let id = surface.start_tagged(ContentTag::Artifact(ty)); + gc.tags.push(TagNode::Leaf(id)); + } +} + +pub fn page_end(gc: &mut GlobalContext, surface: &mut Surface) { + if gc.tags.in_artifact.is_some() { + surface.end_tagged(); + } +} + +/// Add all annotations that were found in the page frame. +pub fn add_link_annotations( + gc: &mut GlobalContext, + page: &mut Page, + annotations: Vec, +) { + for a in annotations.into_iter() { + let annotation = krilla::annotation::Annotation::new_link( + krilla::annotation::LinkAnnotation::new_with_quad_points( + a.quad_points, + a.target, + ), + a.alt, + ); + let annot_id = page.add_tagged_annotation(annotation); + gc.tags.init_placeholder(a.placeholder, Node::Leaf(annot_id)); + } +} + pub struct Tags { /// The intermediary stack of nested tag groups. pub stack: Vec, @@ -336,11 +386,16 @@ pub struct Placeholder(usize); /// Automatically calls [`Surface::end_tagged`] when dropped. pub struct TagHandle<'a, 'b> { surface: &'b mut Surface<'a>, + /// Whether this tag handle started the marked content sequence, and should + /// thus end it when it is dropped. + started: bool, } impl Drop for TagHandle<'_, '_> { fn drop(&mut self) { - self.surface.end_tagged(); + if self.started { + self.surface.end_tagged(); + } } } @@ -352,21 +407,23 @@ impl<'a> TagHandle<'a, '_> { /// Returns a [`TagHandle`] that automatically calls [`Surface::end_tagged`] /// when dropped. -pub fn start_marked<'a, 'b>( +pub fn start_span<'a, 'b>( gc: &mut GlobalContext, surface: &'b mut Surface<'a>, + span: SpanTag, ) -> TagHandle<'a, 'b> { - start_content(gc, surface, ContentTag::Other) + start_content(gc, surface, ContentTag::Span(span)) } /// Returns a [`TagHandle`] that automatically calls [`Surface::end_tagged`] /// when dropped. -pub fn start_span<'a, 'b>( +pub fn start_artifact<'a, 'b>( gc: &mut GlobalContext, surface: &'b mut Surface<'a>, - span: SpanTag, + kind: ArtifactKind, ) -> TagHandle<'a, 'b> { - start_content(gc, surface, ContentTag::Span(span)) + let ty = artifact_type(kind); + start_content(gc, surface, ContentTag::Artifact(ty)) } fn start_content<'a, 'b>( @@ -374,42 +431,14 @@ fn start_content<'a, 'b>( surface: &'b mut Surface<'a>, content: ContentTag, ) -> TagHandle<'a, 'b> { - let content = if let Some((_, kind)) = gc.tags.in_artifact { - let ty = artifact_type(kind); - ContentTag::Artifact(ty) - } else if let Some(StackEntryKind::Table(_)) = gc.tags.stack.last().map(|e| &e.kind) { - // Mark any direct child of a table as an aritfact. Any real content - // will be wrapped inside a `TableCell`. - ContentTag::Artifact(ArtifactType::Other) + let content = if gc.tags.in_artifact.is_some() { + return TagHandle { surface, started: false }; } else { content }; let id = surface.start_tagged(content); gc.tags.push(TagNode::Leaf(id)); - TagHandle { surface } -} - -/// Add all annotations that were found in the page frame. -pub fn add_link_annotations( - gc: &mut GlobalContext, - page: &mut Page, - annotations: Vec, -) { - for a in annotations.into_iter() { - let annotation = krilla::annotation::Annotation::new_link( - krilla::annotation::LinkAnnotation::new_with_quad_points( - a.quad_points, - a.target, - ), - a.alt, - ); - let annot_id = page.add_tagged_annotation(annotation); - gc.tags.init_placeholder(a.placeholder, Node::Leaf(annot_id)); - } -} - -fn start_artifact(gc: &mut GlobalContext, loc: Location, kind: ArtifactKind) { - gc.tags.in_artifact = Some((loc, kind)); + TagHandle { surface, started: true } } fn artifact_type(kind: ArtifactKind) -> ArtifactType { From 2f2848875c192039b34e9fe41a5c3e11661930e4 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Sun, 13 Jul 2025 17:27:02 +0200 Subject: [PATCH 405/558] Generate tags for lists, enums, and terms --- crates/typst-layout/src/lists.rs | 12 ++- crates/typst-layout/src/rules.rs | 20 ++--- crates/typst-library/src/model/outline.rs | 7 -- crates/typst-library/src/pdf/accessibility.rs | 41 +++++++++ crates/typst-pdf/src/tags/list.rs | 85 +++++++++++++++++++ crates/typst-pdf/src/tags/mod.rs | 71 +++++++++++++--- 6 files changed, 204 insertions(+), 32 deletions(-) create mode 100644 crates/typst-pdf/src/tags/list.rs diff --git a/crates/typst-layout/src/lists.rs b/crates/typst-layout/src/lists.rs index b116836cb6..c58781acb5 100644 --- a/crates/typst-layout/src/lists.rs +++ b/crates/typst-layout/src/lists.rs @@ -7,6 +7,7 @@ use typst_library::introspection::Locator; use typst_library::layout::grid::resolve::{Cell, CellGrid}; use typst_library::layout::{Axes, Fragment, HAlignment, Regions, Sizing, VAlignment}; use typst_library::model::{EnumElem, ListElem, Numbering, ParElem, ParbreakElem}; +use typst_library::pdf::PdfMarkerTag; use typst_library::text::TextElem; use crate::grid::GridLayouter; @@ -42,11 +43,12 @@ pub fn layout_list( if !tight { body += ParbreakElem::shared(); } + let body = body.set(ListElem::depth, Depth(1)); cells.push(Cell::new(Content::empty())); - cells.push(Cell::new(marker.clone())); + cells.push(Cell::new(PdfMarkerTag::ListItemLabel(marker.clone()))); cells.push(Cell::new(Content::empty())); - cells.push(Cell::new(body.set(ListElem::depth, Depth(1)))); + cells.push(Cell::new(PdfMarkerTag::ListItemBody(body))); } let grid = CellGrid::new( @@ -125,10 +127,12 @@ pub fn layout_enum( body += ParbreakElem::shared(); } + let body = body.set(EnumElem::parents, smallvec![number]); + cells.push(Cell::new(Content::empty())); - cells.push(Cell::new(resolved)); + cells.push(Cell::new(PdfMarkerTag::ListItemLabel(resolved))); cells.push(Cell::new(Content::empty())); - cells.push(Cell::new(body.set(EnumElem::parents, smallvec![number]))); + cells.push(Cell::new(PdfMarkerTag::ListItemBody(body))); number = if reversed { number.saturating_sub(1) } else { number.saturating_add(1) }; } diff --git a/crates/typst-layout/src/rules.rs b/crates/typst-layout/src/rules.rs index acb3281e6f..2427955590 100644 --- a/crates/typst-layout/src/rules.rs +++ b/crates/typst-layout/src/rules.rs @@ -19,10 +19,10 @@ use typst_library::model::{ Attribution, BibliographyElem, CiteElem, CiteGroup, CslIndentElem, CslLightElem, Destination, DirectLinkElem, EmphElem, EnumElem, FigureCaption, FigureElem, FootnoteElem, FootnoteEntry, HeadingElem, LinkElem, LinkMarker, ListElem, - OutlineBody, OutlineElem, OutlineEntry, ParElem, ParbreakElem, QuoteElem, RefElem, - StrongElem, TableCell, TableElem, TermsElem, TitleElem, Works, + OutlineElem, OutlineEntry, ParElem, ParbreakElem, QuoteElem, RefElem, StrongElem, + TableCell, TableElem, TermsElem, TitleElem, Works, }; -use typst_library::pdf::{ArtifactElem, AttachElem}; +use typst_library::pdf::{ArtifactElem, ArtifactKind, AttachElem, PdfMarkerTag}; use typst_library::text::{ DecoLine, Decoration, HighlightElem, ItalicToggle, LinebreakElem, LocalName, OverlineElem, RawElem, RawLine, ScriptKind, ShiftSettings, Smallcaps, SmallcapsElem, @@ -56,7 +56,6 @@ pub fn register(rules: &mut NativeRuleMap) { rules.register(Paged, FOOTNOTE_RULE); rules.register(Paged, FOOTNOTE_ENTRY_RULE); rules.register(Paged, OUTLINE_RULE); - rules.register(Paged, OUTLINE_BODY_RULE); rules.register(Paged, OUTLINE_ENTRY_RULE); rules.register(Paged, REF_RULE); rules.register(Paged, CITE_GROUP_RULE); @@ -109,6 +108,7 @@ pub fn register(rules: &mut NativeRuleMap) { // PDF. rules.register(Paged, ATTACH_RULE); rules.register(Paged, ARTIFACT_RULE); + rules.register(Paged, PDF_MARKER_TAG_RULE); } const STRONG_RULE: ShowFn = |elem, _, styles| { @@ -178,9 +178,9 @@ const TERMS_RULE: ShowFn = |elem, _, styles| { for child in elem.children.iter() { let mut seq = vec![]; seq.extend(unpad.clone()); - seq.push(child.term.clone().strong()); - seq.push(separator.clone()); - seq.push(child.description.clone()); + seq.push(PdfMarkerTag::ListItemLabel(child.term.clone().strong())); + seq.push(separator.clone().artifact(ArtifactKind::Other)); + seq.push(PdfMarkerTag::ListItemBody(child.description.clone())); // Text in wide term lists shall always turn into paragraphs. if !tight { @@ -410,12 +410,10 @@ const OUTLINE_RULE: ShowFn = |elem, engine, styles| { let title = elem.realize_title(styles); let entries = elem.realize_flat(engine, styles)?; let entries = entries.into_iter().map(|entry| entry.pack()); - let body = OutlineBody::new(Content::sequence(entries)).pack(); + let body = PdfMarkerTag::OutlineBody(Content::sequence(entries)); Ok(Content::sequence(title.into_iter().chain(Some(body)))) }; -const OUTLINE_BODY_RULE: ShowFn = |elem, _, _| Ok(elem.body.clone()); - const OUTLINE_ENTRY_RULE: ShowFn = |elem, engine, styles| { let span = elem.span(); let context = Context::new(None, Some(styles)); @@ -819,3 +817,5 @@ const EQUATION_RULE: ShowFn = |elem, _, styles| { const ATTACH_RULE: ShowFn = |_, _, _| Ok(Content::empty()); const ARTIFACT_RULE: ShowFn = |elem, _, _| Ok(elem.body.clone()); + +const PDF_MARKER_TAG_RULE: ShowFn = |elem, _, _| Ok(elem.body.clone()); diff --git a/crates/typst-library/src/model/outline.rs b/crates/typst-library/src/model/outline.rs index add3f0862a..ed85b4efc7 100644 --- a/crates/typst-library/src/model/outline.rs +++ b/crates/typst-library/src/model/outline.rs @@ -409,13 +409,6 @@ impl LocalName for Packed { const KEY: &'static str = "outline"; } -/// Only used to delimit the outline in tagged PDF. -#[elem(Locatable)] -pub struct OutlineBody { - #[required] - pub body: Content, -} - /// Defines how an outline is indented. #[derive(Debug, Clone, PartialEq, Hash)] pub enum OutlineIndent { diff --git a/crates/typst-library/src/pdf/accessibility.rs b/crates/typst-library/src/pdf/accessibility.rs index b38f3d6e3e..fcf6175e3e 100644 --- a/crates/typst-library/src/pdf/accessibility.rs +++ b/crates/typst-library/src/pdf/accessibility.rs @@ -98,3 +98,44 @@ impl TableHeaderScope { } } } + +/// Used to delimit content for tagged PDF. +#[elem(Locatable)] +pub struct PdfMarkerTag { + #[required] + pub kind: PdfMarkerTagKind, + #[required] + pub body: Content, +} + +macro_rules! pdf_marker_tag { + ($(#[doc = $doc:expr] $variant:ident,)+) => { + #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Cast)] + pub enum PdfMarkerTagKind { + $( + #[doc = $doc] + $variant + ),+ + } + + impl PdfMarkerTag { + $( + #[doc = $doc] + #[allow(non_snake_case)] + pub fn $variant(body: Content) -> Content { + let span = body.span(); + Self::new(PdfMarkerTagKind::$variant, body).pack().spanned(span) + } + )+ + } + } +} + +pdf_marker_tag! { + /// `TOC` + OutlineBody, + /// `Lbl` (marker) of the list item + ListItemLabel, + /// `LBody` of the enum item + ListItemBody, +} diff --git a/crates/typst-pdf/src/tags/list.rs b/crates/typst-pdf/src/tags/list.rs new file mode 100644 index 0000000000..25c7531211 --- /dev/null +++ b/crates/typst-pdf/src/tags/list.rs @@ -0,0 +1,85 @@ +use krilla::tagging::{ListNumbering, Tag, TagKind}; + +use crate::tags::TagNode; + +#[derive(Debug)] +pub struct ListCtx { + numbering: ListNumbering, + items: Vec, +} + +#[derive(Debug)] +struct ListItem { + label: Vec, + body: Option>, + sub_list: Option, +} + +impl ListCtx { + pub fn new(numbering: ListNumbering) -> Self { + Self { numbering, items: Vec::new() } + } + + pub fn push_label(&mut self, nodes: Vec) { + self.items.push(ListItem { label: nodes, body: None, sub_list: None }); + } + + pub fn push_body(&mut self, mut nodes: Vec) { + let item = self.items.last_mut().expect("ListItemLabel"); + + // Nested lists are expected to have the following structure: + // + // Typst code + // ``` + // - a + // - b + // - c + // - d + // - e + // ``` + // + // Structure tree + // ``` + // + //
  • + // `-` + // `a` + //
  • + // `-` + // `b` + // + //
  • + // `-` + // `c` + //
  • + // `-` + // `d` + //
  • + // `-` + // `d` + // ``` + // + // So move the nested list out of the list item. + if let [.., TagNode::Group(TagKind::L(_), _)] = nodes.as_slice() { + item.sub_list = nodes.pop(); + } + + item.body = Some(nodes); + } + + pub fn build_list(self, mut nodes: Vec) -> TagNode { + for item in self.items.into_iter() { + nodes.push(TagNode::group( + Tag::LI, + vec![ + TagNode::group(Tag::Lbl, item.label), + TagNode::group(Tag::LBody, item.body.unwrap_or_default()), + ], + )); + if let Some(sub_list) = item.sub_list { + nodes.push(sub_list); + } + } + TagNode::group(Tag::L(self.numbering), nodes) + } +} diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index 8a4a7639ae..29161bd55f 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -5,23 +5,26 @@ use ecow::EcoString; use krilla::page::Page; use krilla::surface::Surface; use krilla::tagging::{ - ArtifactType, ContentTag, Identifier, Node, SpanTag, Tag, TagGroup, TagKind, TagTree, + ArtifactType, ContentTag, Identifier, ListNumbering, Node, SpanTag, Tag, TagGroup, + TagKind, TagTree, }; use typst_library::foundations::{Content, Packed, StyleChain}; use typst_library::introspection::Location; use typst_library::layout::RepeatElem; use typst_library::model::{ - FigureCaption, FigureElem, HeadingElem, LinkMarker, Outlinable, OutlineBody, - OutlineEntry, TableCell, TableElem, + EnumElem, FigureCaption, FigureElem, HeadingElem, LinkMarker, ListElem, Outlinable, + OutlineEntry, TableCell, TableElem, TermsElem, }; -use typst_library::pdf::{ArtifactElem, ArtifactKind}; +use typst_library::pdf::{ArtifactElem, ArtifactKind, PdfMarkerTag, PdfMarkerTagKind}; use typst_library::visualize::ImageElem; use crate::convert::GlobalContext; use crate::link::LinkAnnotation; +use crate::tags::list::ListCtx; use crate::tags::outline::OutlineCtx; use crate::tags::table::TableCtx; +mod list; mod outline; mod table; @@ -42,19 +45,39 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten return; } - let tag = if let Some(heading) = elem.to_packed::() { - let level = heading.level().try_into().unwrap_or(NonZeroU16::MAX); - let name = heading.body.plain_text().to_string(); - Tag::Hn(level, Some(name)).into() - } else if let Some(_) = elem.to_packed::() { - push_stack(gc, loc, StackEntryKind::Outline(OutlineCtx::new())); - return; + let tag = if let Some(tag) = elem.to_packed::() { + match tag.kind { + PdfMarkerTagKind::OutlineBody => { + push_stack(gc, loc, StackEntryKind::Outline(OutlineCtx::new())); + return; + } + PdfMarkerTagKind::ListItemLabel => { + push_stack(gc, loc, StackEntryKind::ListItemLabel); + return; + } + PdfMarkerTagKind::ListItemBody => { + push_stack(gc, loc, StackEntryKind::ListItemBody); + return; + } + } } else if let Some(entry) = elem.to_packed::() { push_stack(gc, loc, StackEntryKind::OutlineEntry(entry.clone())); return; + } else if let Some(_list) = elem.to_packed::() { + let numbering = ListNumbering::Circle; // TODO: infer numbering from `list.marker` + push_stack(gc, loc, StackEntryKind::List(ListCtx::new(numbering))); + return; + } else if let Some(_enumeration) = elem.to_packed::() { + let numbering = ListNumbering::Decimal; // TODO: infer numbering from `enum.numbering` + push_stack(gc, loc, StackEntryKind::List(ListCtx::new(numbering))); + return; } else if let Some(figure) = elem.to_packed::() { let alt = figure.alt.get_cloned(StyleChain::default()).map(|s| s.to_string()); Tag::Figure(alt).into() + } else if let Some(_terms) = elem.to_packed::() { + let numbering = ListNumbering::None; + push_stack(gc, loc, StackEntryKind::List(ListCtx::new(numbering))); + return; } else if let Some(image) = elem.to_packed::() { let alt = image.alt.get_cloned(StyleChain::default()).map(|s| s.to_string()); @@ -94,6 +117,10 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten push_stack(gc, loc, StackEntryKind::TableCell(cell.clone())); } return; + } else if let Some(heading) = elem.to_packed::() { + let level = heading.level().try_into().unwrap_or(NonZeroU16::MAX); + let name = heading.body.plain_text().to_string(); + Tag::Hn(level, Some(name)).into() } else if let Some(link) = elem.to_packed::() { let link_id = gc.tags.next_link_id(); push_stack(gc, loc, StackEntryKind::Link(link_id, link.clone())); @@ -149,6 +176,17 @@ pub fn handle_end(gc: &mut GlobalContext, surface: &mut Surface, loc: Location) table_ctx.insert(&cell, entry.nodes); return; } + StackEntryKind::List(list) => list.build_list(entry.nodes), + StackEntryKind::ListItemLabel => { + let list_ctx = gc.tags.parent_list().expect("parent list"); + list_ctx.push_label(entry.nodes); + return; + } + StackEntryKind::ListItemBody => { + let list_ctx = gc.tags.parent_list().expect("parent list"); + list_ctx.push_body(entry.nodes); + return; + } StackEntryKind::Link(_, _) => { let mut node = TagNode::group(Tag::Link, entry.nodes); // Wrap link in reference tag if inside an outline entry. @@ -272,6 +310,10 @@ impl Tags { self.parent()?.as_table_mut() } + pub fn parent_list(&mut self) -> Option<&mut ListCtx> { + self.parent()?.as_list_mut() + } + pub fn parent_outline_entry(&mut self) -> Option<&mut OutlineEntry> { self.parent()?.as_outline_entry_mut() } @@ -344,6 +386,9 @@ pub enum StackEntryKind { OutlineEntry(Packed), Table(TableCtx), TableCell(Packed), + List(ListCtx), + ListItemLabel, + ListItemBody, Link(LinkId, Packed), } @@ -360,6 +405,10 @@ impl StackEntryKind { if let Self::Table(v) = self { Some(v) } else { None } } + pub fn as_list_mut(&mut self) -> Option<&mut ListCtx> { + if let Self::List(v) = self { Some(v) } else { None } + } + pub fn as_link(&self) -> Option<(LinkId, &Packed)> { if let Self::Link(id, link) = self { Some((*id, link)) } else { None } } From 5bcb755b7520ca7ce0abae78a5c346e5be90b535 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Mon, 14 Jul 2025 10:31:17 +0200 Subject: [PATCH 406/558] Make figure captions siblings of the figure If the caption is contained within the figure, screen readers might ignore it. --- crates/typst-layout/src/rules.rs | 3 +- crates/typst-library/src/pdf/accessibility.rs | 30 ++++++++++++++----- crates/typst-pdf/src/tags/mod.rs | 18 +++++++---- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/crates/typst-layout/src/rules.rs b/crates/typst-layout/src/rules.rs index 2427955590..0c20419ac8 100644 --- a/crates/typst-layout/src/rules.rs +++ b/crates/typst-layout/src/rules.rs @@ -294,7 +294,8 @@ const HEADING_RULE: ShowFn = |elem, engine, styles| { const FIGURE_RULE: ShowFn = |elem, _, styles| { let span = elem.span(); - let mut realized = elem.body.clone(); + let mut realized = + PdfMarkerTag::FigureBody(elem.alt.get_cloned(styles), elem.body.clone()); // Build the caption, if any. if let Some(caption) = elem.caption.get_cloned(styles) { diff --git a/crates/typst-library/src/pdf/accessibility.rs b/crates/typst-library/src/pdf/accessibility.rs index fcf6175e3e..7811faf3e2 100644 --- a/crates/typst-library/src/pdf/accessibility.rs +++ b/crates/typst-library/src/pdf/accessibility.rs @@ -1,9 +1,13 @@ use std::num::NonZeroU32; +use ecow::EcoString; use typst_macros::{Cast, elem, func}; use typst_utils::NonZeroExt; -use crate::foundations::{Content, NativeElement, Smart}; +use crate::diag::bail; +use crate::diag::SourceResult; +use crate::engine::Engine; +use crate::foundations::{Args, Construct, Content, NativeElement, Smart}; use crate::introspection::Locatable; use crate::model::TableCell; @@ -100,21 +104,28 @@ impl TableHeaderScope { } /// Used to delimit content for tagged PDF. -#[elem(Locatable)] +#[elem(Locatable, Construct)] pub struct PdfMarkerTag { + #[internal] #[required] pub kind: PdfMarkerTagKind, #[required] pub body: Content, } +impl Construct for PdfMarkerTag { + fn construct(_: &mut Engine, args: &mut Args) -> SourceResult { + bail!(args.span, "cannot be constructed manually"); + } +} + macro_rules! pdf_marker_tag { - ($(#[doc = $doc:expr] $variant:ident,)+) => { - #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Cast)] + ($(#[doc = $doc:expr] $variant:ident$(($($name:ident: $ty:ty)+))?,)+) => { + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum PdfMarkerTagKind { $( #[doc = $doc] - $variant + $variant $(($($ty),+))? ),+ } @@ -122,9 +133,12 @@ macro_rules! pdf_marker_tag { $( #[doc = $doc] #[allow(non_snake_case)] - pub fn $variant(body: Content) -> Content { + pub fn $variant($($($name: $ty,)+)? body: Content) -> Content { let span = body.span(); - Self::new(PdfMarkerTagKind::$variant, body).pack().spanned(span) + Self { + kind: PdfMarkerTagKind::$variant $(($($name),+))?, + body, + }.pack().spanned(span) } )+ } @@ -134,6 +148,8 @@ macro_rules! pdf_marker_tag { pdf_marker_tag! { /// `TOC` OutlineBody, + /// `Figure` + FigureBody(alt: Option), /// `Lbl` (marker) of the list item ListItemLabel, /// `LBody` of the enum item diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index 29161bd55f..95e7830aa0 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -46,11 +46,15 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten } let tag = if let Some(tag) = elem.to_packed::() { - match tag.kind { + match &tag.kind { PdfMarkerTagKind::OutlineBody => { push_stack(gc, loc, StackEntryKind::Outline(OutlineCtx::new())); return; } + PdfMarkerTagKind::FigureBody(alt) => { + let alt = alt.as_ref().map(|s| s.to_string()); + Tag::Figure(alt).into() + } PdfMarkerTagKind::ListItemLabel => { push_stack(gc, loc, StackEntryKind::ListItemLabel); return; @@ -71,13 +75,17 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten let numbering = ListNumbering::Decimal; // TODO: infer numbering from `enum.numbering` push_stack(gc, loc, StackEntryKind::List(ListCtx::new(numbering))); return; - } else if let Some(figure) = elem.to_packed::() { - let alt = figure.alt.get_cloned(StyleChain::default()).map(|s| s.to_string()); - Tag::Figure(alt).into() } else if let Some(_terms) = elem.to_packed::() { let numbering = ListNumbering::None; push_stack(gc, loc, StackEntryKind::List(ListCtx::new(numbering))); return; + } else if let Some(_) = elem.to_packed::() { + // Wrap the figure tag and the sibling caption in a container, if the + // caption is contained within the figure like recommended for tables + // screen readers might ignore it. + Tag::NonStruct.into() + } else if let Some(_) = elem.to_packed::() { + Tag::Caption.into() } else if let Some(image) = elem.to_packed::() { let alt = image.alt.get_cloned(StyleChain::default()).map(|s| s.to_string()); @@ -90,8 +98,6 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten } else { Tag::Figure(alt).into() } - } else if let Some(_) = elem.to_packed::() { - Tag::Caption.into() } else if let Some(table) = elem.to_packed::() { let table_id = gc.tags.next_table_id(); let summary = table From 962d0320c967346a9ffa450b556fcba8ea483d00 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Mon, 14 Jul 2025 17:11:02 +0200 Subject: [PATCH 407/558] Mark heading numbering in outline as Lbl --- crates/typst-library/src/model/outline.rs | 3 ++- crates/typst-library/src/pdf/accessibility.rs | 2 ++ crates/typst-pdf/src/tags/mod.rs | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/typst-library/src/model/outline.rs b/crates/typst-library/src/model/outline.rs index ed85b4efc7..7169c3a994 100644 --- a/crates/typst-library/src/model/outline.rs +++ b/crates/typst-library/src/model/outline.rs @@ -20,6 +20,7 @@ use crate::layout::{ RepeatElem, Sides, }; use crate::model::{HeadingElem, NumberingPattern, ParElem, Refable}; +use crate::pdf::PdfMarkerTag; use crate::text::{LocalName, SpaceElem, TextElem}; /// A table of contents, figures, or other elements. @@ -597,7 +598,7 @@ impl OutlineEntry { // ahead so that the inner contents are aligned. seq.extend([ HElem::new((-hanging_indent).into()).pack(), - prefix, + PdfMarkerTag::Label(prefix), HElem::new((hanging_indent - prefix_width).into()).pack(), inner, ]); diff --git a/crates/typst-library/src/pdf/accessibility.rs b/crates/typst-library/src/pdf/accessibility.rs index 7811faf3e2..eaa6aaf83f 100644 --- a/crates/typst-library/src/pdf/accessibility.rs +++ b/crates/typst-library/src/pdf/accessibility.rs @@ -154,4 +154,6 @@ pdf_marker_tag! { ListItemLabel, /// `LBody` of the enum item ListItemBody, + /// A generic `Lbl` + Label, } diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index 95e7830aa0..a7973c79e0 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -63,6 +63,7 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten push_stack(gc, loc, StackEntryKind::ListItemBody); return; } + PdfMarkerTagKind::Label => Tag::Lbl.into(), } } else if let Some(entry) = elem.to_packed::() { push_stack(gc, loc, StackEntryKind::OutlineEntry(entry.clone())); From 62896155f2ccc2e3ffcf7ed14dfc88a41292c8ff Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Tue, 15 Jul 2025 17:23:11 +0200 Subject: [PATCH 408/558] Ensure link annotation are direct children of link tags --- crates/typst-pdf/src/link.rs | 6 +- crates/typst-pdf/src/tags/mod.rs | 148 +++++++++++++++++++------------ 2 files changed, 94 insertions(+), 60 deletions(-) diff --git a/crates/typst-pdf/src/link.rs b/crates/typst-pdf/src/link.rs index 389ea4f8c4..6818751ee0 100644 --- a/crates/typst-pdf/src/link.rs +++ b/crates/typst-pdf/src/link.rs @@ -48,7 +48,7 @@ pub(crate) fn handle_link( } }; - let Some((link_id, link)) = gc.tags.find_parent_link() else { + let Some((link_id, link, link_nodes)) = gc.tags.stack.find_parent_link() else { unreachable!("expected a link parent") }; let alt = link.alt.as_ref().map(EcoString::to_string); @@ -69,8 +69,8 @@ pub(crate) fn handle_link( match fc.get_link_annotation(link_id) { Some(annotation) if join_annotations => annotation.quad_points.push(quad), _ => { - let placeholder = gc.tags.reserve_placeholder(); - gc.tags.push(TagNode::Placeholder(placeholder)); + let placeholder = gc.tags.placeholders.reserve(); + link_nodes.push(TagNode::Placeholder(placeholder)); fc.push_link_annotation(LinkAnnotation { id: link_id, placeholder, diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index a7973c79e0..bbc0288cbe 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -1,5 +1,6 @@ use std::cell::OnceCell; use std::num::NonZeroU16; +use std::ops::{Deref, DerefMut}; use ecow::EcoString; use krilla::page::Page; @@ -90,7 +91,9 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten } else if let Some(image) = elem.to_packed::() { let alt = image.alt.get_cloned(StyleChain::default()).map(|s| s.to_string()); - if let Some(StackEntryKind::Standard(TagKind::Figure(tag))) = gc.tags.parent() { + if let Some(StackEntryKind::Standard(TagKind::Figure(tag))) = + gc.tags.stack.parent() + { // Set alt text of outer figure tag, if not present. if tag.alt_text().is_none() { tag.set_alt_text(alt); @@ -110,7 +113,7 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten push_stack(gc, loc, StackEntryKind::Table(ctx)); return; } else if let Some(cell) = elem.to_packed::() { - let table_ctx = gc.tags.parent_table(); + let table_ctx = gc.tags.stack.parent_table(); // Only repeated table headers and footer cells are laid out multiple // times. Mark duplicate headers as artifacts, since they have no @@ -155,11 +158,8 @@ pub fn handle_end(gc: &mut GlobalContext, surface: &mut Surface, loc: Location) StackEntryKind::Standard(tag) => TagNode::group(tag, entry.nodes), StackEntryKind::Outline(ctx) => ctx.build_outline(entry.nodes), StackEntryKind::OutlineEntry(outline_entry) => { - let parent = gc.tags.stack.last_mut().and_then(|parent| { - let ctx = parent.kind.as_outline_mut()?; - Some((&mut parent.nodes, ctx)) - }); - let Some((parent_nodes, outline_ctx)) = parent else { + let Some((outline_ctx, outline_nodes)) = gc.tags.stack.parent_outline() + else { // PDF/UA compliance of the structure hierarchy is checked // elsewhere. While this doesn't make a lot of sense, just // avoid crashing here. @@ -167,12 +167,12 @@ pub fn handle_end(gc: &mut GlobalContext, surface: &mut Surface, loc: Location) return; }; - outline_ctx.insert(parent_nodes, outline_entry, entry.nodes); + outline_ctx.insert(outline_nodes, outline_entry, entry.nodes); return; } StackEntryKind::Table(ctx) => ctx.build_table(entry.nodes), StackEntryKind::TableCell(cell) => { - let Some(table_ctx) = gc.tags.parent_table() else { + let Some(table_ctx) = gc.tags.stack.parent_table() else { // PDF/UA compliance of the structure hierarchy is checked // elsewhere. While this doesn't make a lot of sense, just // avoid crashing here. @@ -185,19 +185,19 @@ pub fn handle_end(gc: &mut GlobalContext, surface: &mut Surface, loc: Location) } StackEntryKind::List(list) => list.build_list(entry.nodes), StackEntryKind::ListItemLabel => { - let list_ctx = gc.tags.parent_list().expect("parent list"); + let list_ctx = gc.tags.stack.parent_list().expect("parent list"); list_ctx.push_label(entry.nodes); return; } StackEntryKind::ListItemBody => { - let list_ctx = gc.tags.parent_list().expect("parent list"); + let list_ctx = gc.tags.stack.parent_list().expect("parent list"); list_ctx.push_body(entry.nodes); return; } StackEntryKind::Link(_, _) => { let mut node = TagNode::group(Tag::Link, entry.nodes); // Wrap link in reference tag if inside an outline entry. - if gc.tags.parent_outline_entry().is_some() { + if gc.tags.stack.parent_outline_entry().is_some() { node = TagNode::group(Tag::Reference, vec![node]); } node @@ -257,15 +257,15 @@ pub fn add_link_annotations( a.alt, ); let annot_id = page.add_tagged_annotation(annotation); - gc.tags.init_placeholder(a.placeholder, Node::Leaf(annot_id)); + gc.tags.placeholders.init(a.placeholder, Node::Leaf(annot_id)); } } pub struct Tags { /// The intermediary stack of nested tag groups. - pub stack: Vec, + pub stack: TagStack, /// A list of placeholders corresponding to a [`TagNode::Placeholder`]. - pub placeholders: Vec>, + pub placeholders: Placeholders, pub in_artifact: Option<(Location, ArtifactKind)>, /// Used to group multiple link annotations using quad points. pub link_id: LinkId, @@ -280,8 +280,8 @@ pub struct Tags { impl Tags { pub fn new() -> Self { Self { - stack: Vec::new(), - placeholders: Vec::new(), + stack: TagStack(Vec::new()), + placeholders: Placeholders(Vec::new()), in_artifact: None, tree: Vec::new(), @@ -290,45 +290,6 @@ impl Tags { } } - pub fn reserve_placeholder(&mut self) -> Placeholder { - let idx = self.placeholders.len(); - self.placeholders.push(OnceCell::new()); - Placeholder(idx) - } - - pub fn init_placeholder(&mut self, placeholder: Placeholder, node: Node) { - self.placeholders[placeholder.0] - .set(node) - .map_err(|_| ()) - .expect("placeholder to be uninitialized"); - } - - pub fn take_placeholder(&mut self, placeholder: Placeholder) -> Node { - self.placeholders[placeholder.0] - .take() - .expect("initialized placeholder node") - } - - pub fn parent(&mut self) -> Option<&mut StackEntryKind> { - self.stack.last_mut().map(|e| &mut e.kind) - } - - pub fn parent_table(&mut self) -> Option<&mut TableCtx> { - self.parent()?.as_table_mut() - } - - pub fn parent_list(&mut self) -> Option<&mut ListCtx> { - self.parent()?.as_list_mut() - } - - pub fn parent_outline_entry(&mut self) -> Option<&mut OutlineEntry> { - self.parent()?.as_outline_entry_mut() - } - - pub fn find_parent_link(&self) -> Option<(LinkId, &Packed)> { - self.stack.iter().rev().find_map(|entry| entry.kind.as_link()) - } - pub fn push(&mut self, node: TagNode) { if let Some(entry) = self.stack.last_mut() { entry.nodes.push(node); @@ -358,7 +319,7 @@ impl Tags { Node::Group(TagGroup::with_children(tag, children)) } TagNode::Leaf(identifier) => Node::Leaf(identifier), - TagNode::Placeholder(placeholder) => self.take_placeholder(placeholder), + TagNode::Placeholder(placeholder) => self.placeholders.take(placeholder), } } @@ -373,6 +334,79 @@ impl Tags { } } +pub struct TagStack(Vec); + +impl Deref for TagStack { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for TagStack { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl TagStack { + pub fn parent(&mut self) -> Option<&mut StackEntryKind> { + self.0.last_mut().map(|e| &mut e.kind) + } + + pub fn parent_table(&mut self) -> Option<&mut TableCtx> { + self.parent()?.as_table_mut() + } + + pub fn parent_list(&mut self) -> Option<&mut ListCtx> { + self.parent()?.as_list_mut() + } + + pub fn parent_outline( + &mut self, + ) -> Option<(&mut OutlineCtx, &mut Vec)> { + self.0.last_mut().and_then(|e| { + let ctx = e.kind.as_outline_mut()?; + Some((ctx, &mut e.nodes)) + }) + } + + pub fn parent_outline_entry(&mut self) -> Option<&mut OutlineEntry> { + self.parent()?.as_outline_entry_mut() + } + + pub fn find_parent_link( + &mut self, + ) -> Option<(LinkId, &LinkMarker, &mut Vec)> { + self.0.iter_mut().rev().find_map(|e| { + let (link_id, link) = e.kind.as_link()?; + Some((link_id, link.as_ref(), &mut e.nodes)) + }) + } +} + +pub struct Placeholders(Vec>); + +impl Placeholders { + pub fn reserve(&mut self) -> Placeholder { + let idx = self.0.len(); + self.0.push(OnceCell::new()); + Placeholder(idx) + } + + pub fn init(&mut self, placeholder: Placeholder, node: Node) { + self.0[placeholder.0] + .set(node) + .map_err(|_| ()) + .expect("placeholder to be uninitialized"); + } + + pub fn take(&mut self, placeholder: Placeholder) -> Node { + self.0[placeholder.0].take().expect("initialized placeholder node") + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct TableId(u32); From 97126415cd26de96800c7308c4b3bf22e0154151 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Wed, 30 Jul 2025 18:34:45 +0200 Subject: [PATCH 409/558] Add convenience methods to access materialized properties --- crates/typst-pdf/src/tags/mod.rs | 23 +++++-------- crates/typst-pdf/src/tags/table.rs | 17 ++++----- crates/typst-pdf/src/tags/util.rs | 55 ++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 23 deletions(-) create mode 100644 crates/typst-pdf/src/tags/util.rs diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index bbc0288cbe..ad93411e59 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -2,14 +2,13 @@ use std::cell::OnceCell; use std::num::NonZeroU16; use std::ops::{Deref, DerefMut}; -use ecow::EcoString; use krilla::page::Page; use krilla::surface::Surface; use krilla::tagging::{ ArtifactType, ContentTag, Identifier, ListNumbering, Node, SpanTag, Tag, TagGroup, TagKind, TagTree, }; -use typst_library::foundations::{Content, Packed, StyleChain}; +use typst_library::foundations::{Content, Packed}; use typst_library::introspection::Location; use typst_library::layout::RepeatElem; use typst_library::model::{ @@ -24,10 +23,12 @@ use crate::link::LinkAnnotation; use crate::tags::list::ListCtx; use crate::tags::outline::OutlineCtx; use crate::tags::table::TableCtx; +use crate::tags::util::{PropertyOptRef, PropertyValCopied}; mod list; mod outline; mod table; +mod util; pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Content) { if gc.tags.in_artifact.is_some() { @@ -38,7 +39,7 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten let loc = elem.location().expect("elem to be locatable"); if let Some(artifact) = elem.to_packed::() { - let kind = artifact.kind.get(StyleChain::default()); + let kind = artifact.kind.val(); push_artifact(gc, surface, loc, kind); return; } else if let Some(_) = elem.to_packed::() { @@ -89,7 +90,7 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten } else if let Some(_) = elem.to_packed::() { Tag::Caption.into() } else if let Some(image) = elem.to_packed::() { - let alt = image.alt.get_cloned(StyleChain::default()).map(|s| s.to_string()); + let alt = image.alt.opt_ref().map(|s| s.to_string()); if let Some(StackEntryKind::Standard(TagKind::Figure(tag))) = gc.tags.stack.parent() @@ -104,11 +105,7 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten } } else if let Some(table) = elem.to_packed::() { let table_id = gc.tags.next_table_id(); - let summary = table - .summary - .get_ref(StyleChain::default()) - .as_ref() - .map(EcoString::to_string); + let summary = table.summary.opt_ref().map(|s| s.to_string()); let ctx = TableCtx::new(table_id, summary); push_stack(gc, loc, StackEntryKind::Table(ctx)); return; @@ -119,9 +116,7 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten // times. Mark duplicate headers as artifacts, since they have no // semantic meaning in the tag tree, which doesn't use page breaks for // it's semantic structure. - if cell.is_repeated.get(StyleChain::default()) - || table_ctx.is_some_and(|ctx| ctx.contains(cell)) - { + if cell.is_repeated.val() || table_ctx.is_some_and(|ctx| ctx.contains(cell)) { push_artifact(gc, surface, loc, ArtifactKind::Other); } else { push_stack(gc, loc, StackEntryKind::TableCell(cell.clone())); @@ -363,9 +358,7 @@ impl TagStack { self.parent()?.as_list_mut() } - pub fn parent_outline( - &mut self, - ) -> Option<(&mut OutlineCtx, &mut Vec)> { + pub fn parent_outline(&mut self) -> Option<(&mut OutlineCtx, &mut Vec)> { self.0.last_mut().and_then(|e| { let ctx = e.kind.as_outline_mut()?; Some((ctx, &mut e.nodes)) diff --git a/crates/typst-pdf/src/tags/table.rs b/crates/typst-pdf/src/tags/table.rs index c931da7ce4..ab0373c94c 100644 --- a/crates/typst-pdf/src/tags/table.rs +++ b/crates/typst-pdf/src/tags/table.rs @@ -4,10 +4,11 @@ use std::num::NonZeroU32; use az::SaturatingAs; use krilla::tagging::{Tag, TagId}; use smallvec::SmallVec; -use typst_library::foundations::{Packed, Smart, StyleChain}; +use typst_library::foundations::{Packed, Smart}; use typst_library::model::TableCell; use typst_library::pdf::{TableCellKind, TableHeaderScope}; +use crate::tags::util::PropertyValCopied; use crate::tags::{TableId, TagNode}; #[derive(Debug)] @@ -43,8 +44,8 @@ impl TableCtx { } pub fn contains(&self, cell: &Packed) -> bool { - let x = cell.x.get(StyleChain::default()).unwrap_or_else(|| unreachable!()); - let y = cell.y.get(StyleChain::default()).unwrap_or_else(|| unreachable!()); + let x = cell.x.val().unwrap_or_else(|| unreachable!()); + let y = cell.y.val().unwrap_or_else(|| unreachable!()); self.get(x, y).is_some() } @@ -57,11 +58,11 @@ impl TableCtx { } pub fn insert(&mut self, cell: &TableCell, nodes: Vec) { - let x = cell.x.get(StyleChain::default()).unwrap_or_else(|| unreachable!()); - let y = cell.y.get(StyleChain::default()).unwrap_or_else(|| unreachable!()); - let rowspan = cell.rowspan.get(StyleChain::default()); - let colspan = cell.colspan.get(StyleChain::default()); - let kind = cell.kind.get(StyleChain::default()); + let x = cell.x.val().unwrap_or_else(|| unreachable!()); + let y = cell.y.val().unwrap_or_else(|| unreachable!()); + let rowspan = cell.rowspan.val(); + let colspan = cell.colspan.val(); + let kind = cell.kind.val(); // Extend the table grid to fit this cell. let required_height = y + rowspan.get(); diff --git a/crates/typst-pdf/src/tags/util.rs b/crates/typst-pdf/src/tags/util.rs new file mode 100644 index 0000000000..5f216487a4 --- /dev/null +++ b/crates/typst-pdf/src/tags/util.rs @@ -0,0 +1,55 @@ +//! Convenience methods to retrieve a property value by passing the default +//! stylechain. +//! Since in the PDF export all elements are materialized, meaning all of their +//! fields have been copied from the stylechain, there is no point in providing +//! any other stylechain. + +use typst_library::foundations::{ + NativeElement, RefableProperty, Settable, SettableProperty, StyleChain, +}; + +pub trait PropertyValCopied { + /// Get the copied value. + fn val(&self) -> T; +} + +impl PropertyValCopied for Settable +where + E: NativeElement, + E: SettableProperty, +{ + fn val(&self) -> T { + self.get(StyleChain::default()) + } +} + +pub trait PropertyValCloned { + /// Get the cloned value. + fn val_cloned(&self) -> T; +} + +impl PropertyValCloned for Settable +where + E: NativeElement, + E: SettableProperty, +{ + fn val_cloned(&self) -> T { + self.get_cloned(StyleChain::default()) + } +} + +pub trait PropertyOptRef { + fn opt_ref(&self) -> Option<&T>; +} + +impl PropertyOptRef for Settable +where + E: NativeElement, + E: SettableProperty>, + E: RefableProperty, +{ + /// Get an `Option` with a reference to the contained value. + fn opt_ref(&self) -> Option<&T> { + self.get_ref(StyleChain::default()).as_ref() + } +} From f7d8889fd758cca16ada9d7cd498801ce1fb295d Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Tue, 15 Jul 2025 16:54:15 +0200 Subject: [PATCH 410/558] Generate tags for more elements - footnotes: `Note` for the entries - bibliographies: A list cotaining `BibEntry` items - equations: `Formula` with optional alt text - quotes: `Quote` or `BlockQuote` - raw text: `Code` --- crates/typst-layout/src/flow/mod.rs | 5 +- crates/typst-layout/src/rules.rs | 43 +++--- crates/typst-library/src/math/equation.rs | 4 + crates/typst-library/src/model/footnote.rs | 2 +- crates/typst-library/src/pdf/accessibility.rs | 10 +- crates/typst-pdf/src/tags/list.rs | 14 ++ crates/typst-pdf/src/tags/mod.rs | 140 +++++++++++++++++- 7 files changed, 193 insertions(+), 25 deletions(-) diff --git a/crates/typst-layout/src/flow/mod.rs b/crates/typst-layout/src/flow/mod.rs index a32e629dd5..0c0c98524e 100644 --- a/crates/typst-layout/src/flow/mod.rs +++ b/crates/typst-layout/src/flow/mod.rs @@ -26,6 +26,7 @@ use typst_library::layout::{ Regions, Rel, Size, }; use typst_library::model::{FootnoteElem, FootnoteEntry, LineNumberingScope, ParLine}; +use typst_library::pdf::ArtifactKind; use typst_library::routines::{Arenas, FragmentKind, Pair, RealizationKind, Routines}; use typst_library::text::TextElem; use typst_utils::{NonZeroExt, Numeric}; @@ -255,7 +256,9 @@ fn configuration<'x>( ColumnConfig { count, width, gutter, dir } }, footnote: FootnoteConfig { - separator: shared.get_cloned(FootnoteEntry::separator), + separator: shared + .get_cloned(FootnoteEntry::separator) + .artifact(ArtifactKind::Other), clearance: shared.resolve(FootnoteEntry::clearance), gap: shared.resolve(FootnoteEntry::gap), expand: regions.expand.x, diff --git a/crates/typst-layout/src/rules.rs b/crates/typst-layout/src/rules.rs index 0c20419ac8..23c04d491f 100644 --- a/crates/typst-layout/src/rules.rs +++ b/crates/typst-layout/src/rules.rs @@ -392,8 +392,10 @@ const FOOTNOTE_RULE: ShowFn = |elem, engine, styles| { let span = elem.span(); let (dest, num) = elem.realize(engine, styles)?; let alt = FootnoteElem::alt_text(styles, &num.plain_text()); - let sup = SuperElem::new(num).pack().spanned(span); - Ok(HElem::hole().clone() + sup.linked(dest, Some(alt))) + let sup = PdfMarkerTag::Label(SuperElem::new(num).pack().spanned(span)); + let note = HElem::hole().clone() + sup.linked(dest, Some(alt)); + let decl_loc = elem.declaration_location(engine).unwrap(); + Ok(PdfMarkerTag::FootnoteRef(decl_loc, note)) }; const FOOTNOTE_ENTRY_RULE: ShowFn = |elem, engine, styles| { @@ -401,7 +403,7 @@ const FOOTNOTE_ENTRY_RULE: ShowFn = |elem, engine, styles| { let (prefix, body) = elem.realize(engine, styles)?; Ok(Content::sequence([ HElem::new(elem.indent.get(styles).into()).pack(), - prefix, + PdfMarkerTag::Label(prefix), HElem::new(number_gap.into()).with_weak(true).pack(), body, ])) @@ -475,27 +477,31 @@ const BIBLIOGRAPHY_RULE: ShowFn = |elem, engine, styles| { let mut cells = vec![]; for (prefix, reference, loc) in references { + let prefix = PdfMarkerTag::ListItemLabel( + prefix.clone().unwrap_or_default().located(*loc), + ); cells.push(GridChild::Item(GridItem::Cell( - Packed::new(GridCell::new( - prefix.clone().unwrap_or_default().located(*loc), - )) - .spanned(span), + Packed::new(GridCell::new(prefix)).spanned(span), ))); + + let reference = PdfMarkerTag::BibEntry(reference.clone()); cells.push(GridChild::Item(GridItem::Cell( - Packed::new(GridCell::new(reference.clone())).spanned(span), + Packed::new(GridCell::new(reference)).spanned(span), ))); } - seq.push( - GridElem::new(cells) - .with_columns(TrackSizings(smallvec![Sizing::Auto; 2])) - .with_column_gutter(TrackSizings(smallvec![COLUMN_GUTTER.into()])) - .with_row_gutter(TrackSizings(smallvec![row_gutter.into()])) - .pack() - .spanned(span), - ); + + let grid = GridElem::new(cells) + .with_columns(TrackSizings(smallvec![Sizing::Auto; 2])) + .with_column_gutter(TrackSizings(smallvec![COLUMN_GUTTER.into()])) + .with_row_gutter(TrackSizings(smallvec![row_gutter.into()])) + .pack() + .spanned(span); + // TODO(accessibility): infer list numbering from style? + seq.push(PdfMarkerTag::Bibliography(true, grid)); } else { + let mut body = vec![]; for (_, reference, loc) in references { - let realized = reference.clone().located(*loc); + let realized = PdfMarkerTag::BibEntry(reference.clone().located(*loc)); let block = if works.hanging_indent { let body = HElem::new((-INDENT).into()).pack() + realized; let inset = Sides::default() @@ -507,8 +513,9 @@ const BIBLIOGRAPHY_RULE: ShowFn = |elem, engine, styles| { BlockElem::new().with_body(Some(BlockBody::Content(realized))) }; - seq.push(block.pack().spanned(span)); + body.push(block.pack().spanned(span)); } + seq.push(PdfMarkerTag::Bibliography(false, Content::sequence(body))); } Ok(Content::sequence(seq)) diff --git a/crates/typst-library/src/math/equation.rs b/crates/typst-library/src/math/equation.rs index 9587b2858d..c39d6e126b 100644 --- a/crates/typst-library/src/math/equation.rs +++ b/crates/typst-library/src/math/equation.rs @@ -1,6 +1,7 @@ use std::num::NonZeroUsize; use codex::styling::MathVariant; +use ecow::EcoString; use typst_utils::NonZeroExt; use unicode_math_class::MathClass; @@ -100,6 +101,9 @@ pub struct EquationElem { /// ``` pub supplement: Smart>, + /// An alternative description of the mathematical equation. + pub alt: Option, + /// The contents of the equation. #[required] pub body: Content, diff --git a/crates/typst-library/src/model/footnote.rs b/crates/typst-library/src/model/footnote.rs index d4a404c6e4..bd5663c0e8 100644 --- a/crates/typst-library/src/model/footnote.rs +++ b/crates/typst-library/src/model/footnote.rs @@ -142,7 +142,7 @@ impl Packed { } /// Returns the location of the definition of this footnote. - fn declaration_location(&self, engine: &Engine) -> StrResult { + pub fn declaration_location(&self, engine: &Engine) -> StrResult { match self.body { FootnoteBody::Reference(label) => { let element = engine.introspector.query_label(label)?; diff --git a/crates/typst-library/src/pdf/accessibility.rs b/crates/typst-library/src/pdf/accessibility.rs index eaa6aaf83f..d5c9a43ec9 100644 --- a/crates/typst-library/src/pdf/accessibility.rs +++ b/crates/typst-library/src/pdf/accessibility.rs @@ -4,11 +4,11 @@ use ecow::EcoString; use typst_macros::{Cast, elem, func}; use typst_utils::NonZeroExt; -use crate::diag::bail; use crate::diag::SourceResult; +use crate::diag::bail; use crate::engine::Engine; use crate::foundations::{Args, Construct, Content, NativeElement, Smart}; -use crate::introspection::Locatable; +use crate::introspection::{Locatable, Location}; use crate::model::TableCell; /// Mark content as a PDF artifact. @@ -150,6 +150,12 @@ pdf_marker_tag! { OutlineBody, /// `Figure` FigureBody(alt: Option), + /// `Note` footnote reference + FootnoteRef(decl_loc: Location), + /// `L` bibliography list + Bibliography(numbered: bool), + /// `LBody` wrapping `BibEntry` + BibEntry, /// `Lbl` (marker) of the list item ListItemLabel, /// `LBody` of the enum item diff --git a/crates/typst-pdf/src/tags/list.rs b/crates/typst-pdf/src/tags/list.rs index 25c7531211..77763b3be0 100644 --- a/crates/typst-pdf/src/tags/list.rs +++ b/crates/typst-pdf/src/tags/list.rs @@ -67,6 +67,20 @@ impl ListCtx { item.body = Some(nodes); } + pub fn push_bib_entry(&mut self, nodes: Vec) { + let nodes = vec![TagNode::group(Tag::BibEntry, nodes)]; + // Bibliography lists cannot be nested, but may be missing labels. + if let Some(item) = self.items.last_mut().filter(|item| item.body.is_none()) { + item.body = Some(nodes); + } else { + self.items.push(ListItem { + label: Vec::new(), + body: Some(nodes), + sub_list: None, + }); + } + } + pub fn build_list(self, mut nodes: Vec) -> TagNode { for item in self.items.into_iter() { nodes.push(TagNode::group( diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index ad93411e59..36ac8b47ab 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -4,18 +4,22 @@ use std::ops::{Deref, DerefMut}; use krilla::page::Page; use krilla::surface::Surface; +use krilla::tagging as kt; use krilla::tagging::{ ArtifactType, ContentTag, Identifier, ListNumbering, Node, SpanTag, Tag, TagGroup, TagKind, TagTree, }; +use rustc_hash::FxHashMap; use typst_library::foundations::{Content, Packed}; use typst_library::introspection::Location; use typst_library::layout::RepeatElem; +use typst_library::math::EquationElem; use typst_library::model::{ - EnumElem, FigureCaption, FigureElem, HeadingElem, LinkMarker, ListElem, Outlinable, - OutlineEntry, TableCell, TableElem, TermsElem, + EnumElem, FigureCaption, FigureElem, FootnoteEntry, HeadingElem, LinkMarker, + ListElem, Outlinable, OutlineEntry, QuoteElem, TableCell, TableElem, TermsElem, }; use typst_library::pdf::{ArtifactElem, ArtifactKind, PdfMarkerTag, PdfMarkerTagKind}; +use typst_library::text::{RawElem, RawLine}; use typst_library::visualize::ImageElem; use crate::convert::GlobalContext; @@ -57,6 +61,20 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten let alt = alt.as_ref().map(|s| s.to_string()); Tag::Figure(alt).into() } + PdfMarkerTagKind::FootnoteRef(decl_loc) => { + push_stack(gc, loc, StackEntryKind::FootnoteRef(*decl_loc)); + return; + } + PdfMarkerTagKind::Bibliography(numbered) => { + let numbering = + if *numbered { ListNumbering::Decimal } else { ListNumbering::None }; + push_stack(gc, loc, StackEntryKind::List(ListCtx::new(numbering))); + return; + } + PdfMarkerTagKind::BibEntry => { + push_stack(gc, loc, StackEntryKind::BibEntry); + return; + } PdfMarkerTagKind::ListItemLabel => { push_stack(gc, loc, StackEntryKind::ListItemLabel); return; @@ -103,6 +121,17 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten } else { Tag::Figure(alt).into() } + } else if let Some(equation) = elem.to_packed::() { + let alt = equation.alt.opt_ref().map(|s| s.to_string()); + if let Some(StackEntryKind::Standard(TagKind::Figure(tag))) = + gc.tags.stack.parent() + { + // Set alt text of outer figure tag, if not present. + if tag.alt_text().is_none() { + tag.set_alt_text(alt.clone()); + } + } + Tag::Formula(alt).into() } else if let Some(table) = elem.to_packed::() { let table_id = gc.tags.next_table_id(); let summary = table.summary.opt_ref().map(|s| s.to_string()); @@ -130,6 +159,26 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten let link_id = gc.tags.next_link_id(); push_stack(gc, loc, StackEntryKind::Link(link_id, link.clone())); return; + } else if let Some(entry) = elem.to_packed::() { + let footnote_loc = entry.note.location().unwrap(); + push_stack(gc, loc, StackEntryKind::FootnoteEntry(footnote_loc)); + return; + } else if let Some(quote) = elem.to_packed::() { + // TODO: should the attribution be handled somehow? + if quote.block.val() { Tag::BlockQuote.into() } else { Tag::InlineQuote.into() } + } else if let Some(raw) = elem.to_packed::() { + if raw.block.val() { + push_stack(gc, loc, StackEntryKind::CodeBlock); + return; + } else { + Tag::Code.into() + } + } else if let Some(_) = elem.to_packed::() { + // If the raw element is inline, the content can be inserted directly. + if gc.tags.stack.parent().is_some_and(|p| p.is_code_block()) { + push_stack(gc, loc, StackEntryKind::CodeBlockLine); + } + return; } else { return; }; @@ -189,6 +238,11 @@ pub fn handle_end(gc: &mut GlobalContext, surface: &mut Surface, loc: Location) list_ctx.push_body(entry.nodes); return; } + StackEntryKind::BibEntry => { + let list_ctx = gc.tags.stack.parent_list().expect("parent list"); + list_ctx.push_bib_entry(entry.nodes); + return; + } StackEntryKind::Link(_, _) => { let mut node = TagNode::group(Tag::Link, entry.nodes); // Wrap link in reference tag if inside an outline entry. @@ -197,6 +251,36 @@ pub fn handle_end(gc: &mut GlobalContext, surface: &mut Surface, loc: Location) } node } + StackEntryKind::FootnoteRef(decl_loc) => { + // transparently insert all children. + gc.tags.extend(entry.nodes); + + let ctx = gc.tags.footnotes.entry(decl_loc).or_insert(FootnoteCtx::new()); + + // Only insert the footnote entry once after the first reference. + if !ctx.is_referenced { + ctx.is_referenced = true; + gc.tags.push(TagNode::FootnoteEntry(decl_loc)); + } + return; + } + StackEntryKind::FootnoteEntry(footnote_loc) => { + // Store footnotes separately so they can be inserted directly after + // the footnote reference in the reading order. + let tag = TagNode::group(Tag::Note, entry.nodes); + let ctx = gc.tags.footnotes.entry(footnote_loc).or_insert(FootnoteCtx::new()); + ctx.entry = Some(tag); + return; + } + StackEntryKind::CodeBlock => TagNode::group( + Tag::Code.with_placement(Some(kt::Placement::Block)), + entry.nodes, + ), + StackEntryKind::CodeBlockLine => { + // If the raw element is a block, wrap each line in a BLSE, so the + // individual lines are properly wrapped and indented when reflowed. + TagNode::group(Tag::P, entry.nodes) + } }; gc.tags.push(node); @@ -261,6 +345,11 @@ pub struct Tags { pub stack: TagStack, /// A list of placeholders corresponding to a [`TagNode::Placeholder`]. pub placeholders: Placeholders, + /// Footnotes are inserted directly after the footenote reference in the + /// reading order. Because of some layouting bugs, the entry might appear + /// before the reference in the text, so we only resolve them once tags + /// for the whole document are generated. + pub footnotes: FxHashMap, pub in_artifact: Option<(Location, ArtifactKind)>, /// Used to group multiple link annotations using quad points. pub link_id: LinkId, @@ -277,11 +366,13 @@ impl Tags { Self { stack: TagStack(Vec::new()), placeholders: Placeholders(Vec::new()), + footnotes: FxHashMap::default(), in_artifact: None, - tree: Vec::new(), link_id: LinkId(0), table_id: TableId(0), + + tree: Vec::new(), } } @@ -293,6 +384,14 @@ impl Tags { } } + pub fn extend(&mut self, nodes: impl IntoIterator) { + if let Some(entry) = self.stack.last_mut() { + entry.nodes.extend(nodes); + } else { + self.tree.extend(nodes); + } + } + pub fn build_tree(&mut self) -> TagTree { assert!(self.stack.is_empty(), "tags weren't properly closed"); @@ -315,6 +414,12 @@ impl Tags { } TagNode::Leaf(identifier) => Node::Leaf(identifier), TagNode::Placeholder(placeholder) => self.placeholders.take(placeholder), + TagNode::FootnoteEntry(loc) => { + let node = (self.footnotes.remove(&loc)) + .and_then(|ctx| ctx.entry) + .expect("footnote"); + self.resolve_node(node) + } } } @@ -423,7 +528,15 @@ pub enum StackEntryKind { List(ListCtx), ListItemLabel, ListItemBody, + BibEntry, Link(LinkId, Packed), + /// The footnote reference in the text, contains the declaration location. + FootnoteRef(Location), + /// The footnote entry at the end of the page. Contains the [`Location`] of + /// the [`FootnoteElem`](typst_library::model::FootnoteElem). + FootnoteEntry(Location), + CodeBlock, + CodeBlockLine, } impl StackEntryKind { @@ -446,6 +559,26 @@ impl StackEntryKind { pub fn as_link(&self) -> Option<(LinkId, &Packed)> { if let Self::Link(id, link) = self { Some((*id, link)) } else { None } } + + pub fn is_code_block(&self) -> bool { + matches!(self, Self::CodeBlock) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct FootnoteCtx { + /// Whether this footenote has been referenced inside the document. The + /// entry will be inserted inside the reading order after the first + /// reference. All other references will still have links to the footnote. + is_referenced: bool, + /// The nodes that make up the footnote entry. + entry: Option, +} + +impl FootnoteCtx { + pub const fn new() -> Self { + Self { is_referenced: false, entry: None } + } } #[derive(Debug, Clone, PartialEq)] @@ -455,6 +588,7 @@ pub enum TagNode { /// Allows inserting a placeholder into the tag tree. /// Currently used for [`krilla::page::Page::add_tagged_annotation`]. Placeholder(Placeholder), + FootnoteEntry(Location), } impl TagNode { From fc53948ff4b1864926ec41b5f0ade8bebe0d4ca5 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Fri, 18 Jul 2025 15:39:19 +0200 Subject: [PATCH 411/558] Attach spans to krilla tags and annotations --- crates/typst-layout/src/rules.rs | 8 +++++++- crates/typst-pdf/src/convert.rs | 24 ++++++++++++++---------- crates/typst-pdf/src/link.rs | 15 +++++++++------ crates/typst-pdf/src/tags/mod.rs | 16 ++++++++++------ crates/typst-pdf/src/tags/table.rs | 21 +++++++++++++++++---- 5 files changed, 57 insertions(+), 27 deletions(-) diff --git a/crates/typst-layout/src/rules.rs b/crates/typst-layout/src/rules.rs index 23c04d491f..b2d6031d53 100644 --- a/crates/typst-layout/src/rules.rs +++ b/crates/typst-layout/src/rules.rs @@ -219,10 +219,16 @@ const TERMS_RULE: ShowFn = |elem, _, styles| { const LINK_MARKER_RULE: ShowFn = |elem, _, _| Ok(elem.body.clone()); const LINK_RULE: ShowFn = |elem, engine, styles| { + let span = elem.span(); let body = elem.body.clone(); let dest = elem.dest.resolve(engine.introspector).at(elem.span())?; let alt = dest.alt_text(engine, styles)?; - Ok(body.linked(dest, Some(alt))) + // Manually construct link marker that spans the whole link elem, not just + // the body. + Ok(LinkMarker::new(body, Some(alt)) + .pack() + .spanned(span) + .set(LinkElem::current, Some(dest))) }; const DIRECT_LINK_RULE: ShowFn = |elem, _, _| { diff --git a/crates/typst-pdf/src/convert.rs b/crates/typst-pdf/src/convert.rs index 8d028478c5..5ac9e7e3c1 100644 --- a/crates/typst-pdf/src/convert.rs +++ b/crates/typst-pdf/src/convert.rs @@ -596,16 +596,20 @@ fn convert_error( } // The below errors cannot occur yet, only once Typst supports full PDF/A // and PDF/UA. But let's still add a message just to be on the safe side. - ValidationError::MissingAnnotationAltText(_) => error!( - Span::detached(), - "{prefix} missing annotation alt text"; - hint: "please report this as a bug" - ), - ValidationError::MissingAltText(_) => error!( - Span::detached(), - "{prefix} missing alt text"; - hint: "make sure your images and equations have alt text" - ), + ValidationError::MissingAnnotationAltText(loc) => { + let span = to_span(*loc); + error!( + span, "{prefix} missing annotation alt text"; + hint: "please report this as a bug" + ) + } + ValidationError::MissingAltText(loc) => { + let span = to_span(*loc); + error!( + span, "{prefix} missing alt text"; + hint: "make sure your images and equations have alt text" + ) + } ValidationError::NoDocumentLanguage => error!( Span::detached(), "{prefix} missing document language"; diff --git a/crates/typst-pdf/src/link.rs b/crates/typst-pdf/src/link.rs index 6818751ee0..f7fbcdde3c 100644 --- a/crates/typst-pdf/src/link.rs +++ b/crates/typst-pdf/src/link.rs @@ -6,17 +6,19 @@ use krilla::destination::XyzDestination; use krilla::geom as kg; use typst_library::layout::{Abs, Point, Position, Size}; use typst_library::model::Destination; +use typst_syntax::Span; use crate::convert::{FrameContext, GlobalContext, PageIndexConverter}; use crate::tags::{LinkId, Placeholder, TagNode}; use crate::util::{AbsExt, PointExt}; -pub struct LinkAnnotation { - pub id: LinkId, - pub placeholder: Placeholder, - pub alt: Option, - pub quad_points: Vec, - pub target: Target, +pub(crate) struct LinkAnnotation { + pub(crate) id: LinkId, + pub(crate) placeholder: Placeholder, + pub(crate) alt: Option, + pub(crate) quad_points: Vec, + pub(crate) target: Target, + pub(crate) span: Span, } pub(crate) fn handle_link( @@ -77,6 +79,7 @@ pub(crate) fn handle_link( quad_points: vec![quad], alt, target, + span: link.span(), }); } } diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index 36ac8b47ab..b031c753fe 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -51,7 +51,7 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten return; } - let tag = if let Some(tag) = elem.to_packed::() { + let tag: TagKind = if let Some(tag) = elem.to_packed::() { match &tag.kind { PdfMarkerTagKind::OutlineBody => { push_stack(gc, loc, StackEntryKind::Outline(OutlineCtx::new())); @@ -183,6 +183,7 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten return; }; + let tag = tag.with_location(Some(elem.span().into_raw())); push_stack(gc, loc, StackEntryKind::Standard(tag)); } @@ -207,7 +208,8 @@ pub fn handle_end(gc: &mut GlobalContext, surface: &mut Surface, loc: Location) // PDF/UA compliance of the structure hierarchy is checked // elsewhere. While this doesn't make a lot of sense, just // avoid crashing here. - gc.tags.push(TagNode::group(Tag::TOCI, entry.nodes)); + let tag = Tag::TOCI.with_location(Some(outline_entry.span().into_raw())); + gc.tags.push(TagNode::group(tag, entry.nodes)); return; }; @@ -220,7 +222,8 @@ pub fn handle_end(gc: &mut GlobalContext, surface: &mut Surface, loc: Location) // PDF/UA compliance of the structure hierarchy is checked // elsewhere. While this doesn't make a lot of sense, just // avoid crashing here. - gc.tags.push(TagNode::group(Tag::TD, entry.nodes)); + let tag = Tag::TD.with_location(Some(cell.span().into_raw())); + gc.tags.push(TagNode::group(tag, entry.nodes)); return; }; @@ -334,7 +337,8 @@ pub fn add_link_annotations( a.target, ), a.alt, - ); + ) + .with_location(Some(a.span.into_raw())); let annot_id = page.add_tagged_annotation(annotation); gc.tags.placeholders.init(a.placeholder, Node::Leaf(annot_id)); } @@ -476,10 +480,10 @@ impl TagStack { pub fn find_parent_link( &mut self, - ) -> Option<(LinkId, &LinkMarker, &mut Vec)> { + ) -> Option<(LinkId, &Packed, &mut Vec)> { self.0.iter_mut().rev().find_map(|e| { let (link_id, link) = e.kind.as_link()?; - Some((link_id, link.as_ref(), &mut e.nodes)) + Some((link_id, link, &mut e.nodes)) }) } } diff --git a/crates/typst-pdf/src/tags/table.rs b/crates/typst-pdf/src/tags/table.rs index ab0373c94c..74169b4a92 100644 --- a/crates/typst-pdf/src/tags/table.rs +++ b/crates/typst-pdf/src/tags/table.rs @@ -7,6 +7,7 @@ use smallvec::SmallVec; use typst_library::foundations::{Packed, Smart}; use typst_library::model::TableCell; use typst_library::pdf::{TableCellKind, TableHeaderScope}; +use typst_syntax::Span; use crate::tags::util::PropertyValCopied; use crate::tags::{TableId, TagNode}; @@ -57,7 +58,7 @@ impl TableCtx { } } - pub fn insert(&mut self, cell: &TableCell, nodes: Vec) { + pub fn insert(&mut self, cell: &Packed, nodes: Vec) { let x = cell.x.val().unwrap_or_else(|| unreachable!()); let y = cell.y.val().unwrap_or_else(|| unreachable!()); let rowspan = cell.rowspan.val(); @@ -92,6 +93,7 @@ impl TableCtx { kind, headers: SmallVec::new(), nodes, + span: cell.span(), }); } @@ -175,12 +177,14 @@ impl TableCtx { .with_headers(Some(cell.headers)) .with_row_span(rowspan) .with_col_span(colspan) + .with_location(Some(cell.span.into_raw())) .into() } TableCellKind::Footer | TableCellKind::Data => Tag::TD .with_headers(Some(cell.headers)) .with_row_span(rowspan) .with_col_span(colspan) + .with_location(Some(cell.span.into_raw())) .into(), }; @@ -285,6 +289,7 @@ struct TableCtxCell { kind: Smart, headers: SmallVec<[TagId; 1]>, nodes: Vec, + span: Span, } impl TableCtxCell { @@ -332,7 +337,7 @@ mod tests { fn table(cells: [TableCell; SIZE]) -> TableCtx { let mut table = TableCtx::new(TableId(324), Some("summary".into())); for cell in cells { - table.insert(&cell, Vec::new()); + table.insert(&Packed::new(cell), Vec::new()); } table } @@ -404,14 +409,22 @@ mod tests { let id = table_cell_id(TableId(324), x, y); let ids = headers.map(|(x, y)| table_cell_id(TableId(324), x, y)).into_iter(); TagNode::group( - Tag::TH(scope).with_id(Some(id)).with_headers(Some(ids)), + Tag::TH(scope) + .with_id(Some(id)) + .with_headers(Some(ids)) + .with_location(Some(Span::detached().into_raw())), Vec::new(), ) } fn td(headers: [(u32, u32); SIZE]) -> TagNode { let ids = headers.map(|(x, y)| table_cell_id(TableId(324), x, y)).into_iter(); - TagNode::group(Tag::TD.with_headers(Some(ids)), Vec::new()) + TagNode::group( + Tag::TD + .with_headers(Some(ids)) + .with_location(Some(Span::detached().into_raw())), + Vec::new(), + ) } #[test] From b6038abc760322e0b52966b1ba72780f6e5f3c59 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Thu, 24 Jul 2025 18:03:10 +0200 Subject: [PATCH 412/558] Avoid doing extra work when `--disable-pdf-tags` is passed --- crates/typst-pdf/src/link.rs | 21 +++++++++++++------ crates/typst-pdf/src/tags/mod.rs | 35 +++++++++++++++++++++++++++----- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/crates/typst-pdf/src/link.rs b/crates/typst-pdf/src/link.rs index f7fbcdde3c..58d865dc66 100644 --- a/crates/typst-pdf/src/link.rs +++ b/crates/typst-pdf/src/link.rs @@ -50,11 +50,14 @@ pub(crate) fn handle_link( } }; - let Some((link_id, link, link_nodes)) = gc.tags.stack.find_parent_link() else { - unreachable!("expected a link parent") + let (link_id, tagging_ctx) = match gc.tags.stack.find_parent_link() { + Some((link_id, link, nodes)) => (link_id, Some((link, nodes))), + None if gc.options.disable_tags => { + let link_id = gc.tags.next_link_id(); + (link_id, None) + } + None => unreachable!("expected a link parent"), }; - let alt = link.alt.as_ref().map(EcoString::to_string); - let quad = to_quadrilateral(fc, size); // Unfortunately quadpoints still aren't well supported by most PDF readers. @@ -72,14 +75,20 @@ pub(crate) fn handle_link( Some(annotation) if join_annotations => annotation.quad_points.push(quad), _ => { let placeholder = gc.tags.placeholders.reserve(); - link_nodes.push(TagNode::Placeholder(placeholder)); + let (alt, span) = if let Some((link, nodes)) = tagging_ctx { + nodes.push(TagNode::Placeholder(placeholder)); + let alt = link.alt.as_ref().map(EcoString::to_string); + (alt, link.span()) + } else { + (None, Span::detached()) + }; fc.push_link_annotation(LinkAnnotation { id: link_id, placeholder, quad_points: vec![quad], alt, target, - span: link.span(), + span, }); } } diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index b031c753fe..97bc64808e 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -35,6 +35,10 @@ mod table; mod util; pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Content) { + if gc.options.disable_tags { + return; + } + if gc.tags.in_artifact.is_some() { // Don't nest artifacts return; @@ -188,6 +192,10 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten } pub fn handle_end(gc: &mut GlobalContext, surface: &mut Surface, loc: Location) { + if gc.options.disable_tags { + return; + } + if let Some((l, _)) = gc.tags.in_artifact { if l == loc { pop_artifact(gc, surface); @@ -311,6 +319,10 @@ fn pop_artifact(gc: &mut GlobalContext, surface: &mut Surface) { } pub fn page_start(gc: &mut GlobalContext, surface: &mut Surface) { + if gc.options.disable_tags { + return; + } + if let Some((_, kind)) = gc.tags.in_artifact { let ty = artifact_type(kind); let id = surface.start_tagged(ContentTag::Artifact(ty)); @@ -319,6 +331,10 @@ pub fn page_start(gc: &mut GlobalContext, surface: &mut Surface) { } pub fn page_end(gc: &mut GlobalContext, surface: &mut Surface) { + if gc.options.disable_tags { + return; + } + if gc.tags.in_artifact.is_some() { surface.end_tagged(); } @@ -339,8 +355,13 @@ pub fn add_link_annotations( a.alt, ) .with_location(Some(a.span.into_raw())); - let annot_id = page.add_tagged_annotation(annotation); - gc.tags.placeholders.init(a.placeholder, Node::Leaf(annot_id)); + + if gc.options.disable_tags { + page.add_annotation(annotation); + } else { + let annot_id = page.add_tagged_annotation(annotation); + gc.tags.placeholders.init(a.placeholder, Node::Leaf(annot_id)); + } } } @@ -356,10 +377,10 @@ pub struct Tags { pub footnotes: FxHashMap, pub in_artifact: Option<(Location, ArtifactKind)>, /// Used to group multiple link annotations using quad points. - pub link_id: LinkId, + link_id: LinkId, /// Used to generate IDs referenced in table `Headers` attributes. /// The IDs must be document wide unique. - pub table_id: TableId, + table_id: TableId, /// The output. pub tree: Vec, @@ -427,7 +448,7 @@ impl Tags { } } - fn next_link_id(&mut self) -> LinkId { + pub fn next_link_id(&mut self) -> LinkId { self.link_id.0 += 1; self.link_id } @@ -652,6 +673,10 @@ fn start_content<'a, 'b>( surface: &'b mut Surface<'a>, content: ContentTag, ) -> TagHandle<'a, 'b> { + if gc.options.disable_tags { + return TagHandle { surface, started: false }; + } + let content = if gc.tags.in_artifact.is_some() { return TagHandle { surface, started: false }; } else { From e6ba1957b5020fe7649236ec5cac2558061f88ba Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Mon, 21 Jul 2025 14:36:02 +0200 Subject: [PATCH 413/558] Compute BBox of Table, Formula, and Figure tags for PDF/UA-1 --- crates/typst-library/src/layout/mod.rs | 2 + crates/typst-library/src/layout/rect.rs | 27 ++ crates/typst-library/src/text/item.rs | 41 +++- crates/typst-library/src/visualize/curve.rs | 13 +- crates/typst-library/src/visualize/shape.rs | 20 +- crates/typst-pdf/src/convert.rs | 9 +- crates/typst-pdf/src/image.rs | 4 +- crates/typst-pdf/src/paint.rs | 2 +- crates/typst-pdf/src/shape.rs | 2 + crates/typst-pdf/src/tags/mod.rs | 259 +++++++++++++++++--- crates/typst-pdf/src/tags/table.rs | 16 +- crates/typst-pdf/src/text.rs | 2 + 12 files changed, 352 insertions(+), 45 deletions(-) create mode 100644 crates/typst-library/src/layout/rect.rs diff --git a/crates/typst-library/src/layout/mod.rs b/crates/typst-library/src/layout/mod.rs index ef1ecdb36d..dfff30a8fd 100644 --- a/crates/typst-library/src/layout/mod.rs +++ b/crates/typst-library/src/layout/mod.rs @@ -24,6 +24,7 @@ mod page; mod place; mod point; mod ratio; +mod rect; mod regions; mod rel; mod repeat; @@ -55,6 +56,7 @@ pub use self::page::*; pub use self::place::*; pub use self::point::*; pub use self::ratio::*; +pub use self::rect::*; pub use self::regions::*; pub use self::rel::*; pub use self::repeat::*; diff --git a/crates/typst-library/src/layout/rect.rs b/crates/typst-library/src/layout/rect.rs new file mode 100644 index 0000000000..9936193f23 --- /dev/null +++ b/crates/typst-library/src/layout/rect.rs @@ -0,0 +1,27 @@ +use crate::layout::{Point, Size}; + +/// A rectangle in 2D. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub struct Rect { + /// The top left corner (minimum coordinate). + pub min: Point, + /// The bottom right corner (maximum coordinate). + pub max: Point, +} + +impl Rect { + /// Create a new rectangle from the minimum/maximum coordinate. + pub fn new(min: Point, max: Point) -> Self { + Self { min, max } + } + + /// Create a new rectangle from the position and size. + pub fn from_pos_size(pos: Point, size: Size) -> Self { + Self { min: pos, max: pos + size.to_point() } + } + + /// Compute the size of the rectangle. + pub fn size(&self) -> Size { + Size::new(self.max.x - self.min.x, self.max.y - self.min.y) + } +} diff --git a/crates/typst-library/src/text/item.rs b/crates/typst-library/src/text/item.rs index 2668aa54ea..9e311c01c2 100644 --- a/crates/typst-library/src/text/item.rs +++ b/crates/typst-library/src/text/item.rs @@ -4,7 +4,7 @@ use std::ops::Range; use ecow::EcoString; use typst_syntax::Span; -use crate::layout::{Abs, Em}; +use crate::layout::{Abs, Em, Point, Rect}; use crate::text::{Font, Lang, Region, is_default_ignorable}; use crate::visualize::{FixedStroke, Paint}; @@ -40,6 +40,45 @@ impl TextItem { pub fn height(&self) -> Abs { self.glyphs.iter().map(|g| g.y_advance).sum::().at(self.size) } + + /// The bounding box of the text run. + #[comemo::memoize] + pub fn bbox(&self) -> Rect { + let mut min = Point::splat(Abs::inf()); + let mut max = Point::splat(-Abs::inf()); + let mut cursor = Point::zero(); + + for glyph in self.glyphs.iter() { + let advance = + Point::new(glyph.x_advance.at(self.size), glyph.y_advance.at(self.size)); + let offset = + Point::new(glyph.x_offset.at(self.size), glyph.y_offset.at(self.size)); + if let Some(rect) = + self.font.ttf().glyph_bounding_box(ttf_parser::GlyphId(glyph.id)) + { + let pos = cursor + offset; + let a = pos + + Point::new( + self.font.to_em(rect.x_min).at(self.size), + self.font.to_em(rect.y_min).at(self.size), + ); + let b = pos + + Point::new( + self.font.to_em(rect.x_max).at(self.size), + self.font.to_em(rect.y_max).at(self.size), + ); + min = min.min(a).min(b); + max = max.max(a).max(b); + } + cursor += advance; + } + + // Text runs use a y-up coordinate system, in contrast to the default + // frame orientation. + min.y *= -1.0; + max.y *= -1.0; + Rect::new(min, max) + } } impl Debug for TextItem { diff --git a/crates/typst-library/src/visualize/curve.rs b/crates/typst-library/src/visualize/curve.rs index 13634eb77d..1037dab504 100644 --- a/crates/typst-library/src/visualize/curve.rs +++ b/crates/typst-library/src/visualize/curve.rs @@ -4,7 +4,7 @@ use typst_utils::Numeric; use crate::diag::{HintedStrResult, HintedString, bail}; use crate::foundations::{Content, Packed, Smart, cast, elem}; -use crate::layout::{Abs, Axes, Length, Point, Rel, Size}; +use crate::layout::{Abs, Axes, Length, Point, Rect, Rel, Size}; use crate::visualize::{FillRule, Paint, Stroke}; use super::FixedStroke; @@ -474,8 +474,8 @@ impl Curve { } } - /// Computes the size of the bounding box of this curve. - pub fn bbox_size(&self) -> Size { + /// Computes the bounding box of this curve. + pub fn bbox(&self) -> Rect { let mut min = Point::splat(Abs::inf()); let mut max = Point::splat(-Abs::inf()); @@ -509,7 +509,12 @@ impl Curve { } } - Size::new(max.x - min.x, max.y - min.y) + Rect::new(min, max) + } + + /// Computes the size of the bounding box of this curve. + pub fn bbox_size(&self) -> Size { + self.bbox().size() } } diff --git a/crates/typst-library/src/visualize/shape.rs b/crates/typst-library/src/visualize/shape.rs index 456401d5ba..bf387f260f 100644 --- a/crates/typst-library/src/visualize/shape.rs +++ b/crates/typst-library/src/visualize/shape.rs @@ -1,5 +1,5 @@ use crate::foundations::{Cast, Content, Smart, elem}; -use crate::layout::{Abs, Corners, Length, Point, Rel, Sides, Size, Sizing}; +use crate::layout::{Abs, Corners, Length, Point, Rect, Rel, Sides, Size, Sizing}; use crate::visualize::{Curve, FixedStroke, Paint, Stroke}; /// A rectangle with optional content. @@ -383,6 +383,24 @@ impl Geometry { } } + /// The bounding box of the geometry. + pub fn bbox(&self) -> Rect { + match self { + Self::Line(end) => { + let min = end.min(Point::zero()); + let max = end.max(Point::zero()); + Rect::new(min, max) + } + Self::Rect(size) => { + let p = size.to_point(); + let min = p.min(Point::zero()); + let max = p.max(Point::zero()); + Rect::new(min, max) + } + Self::Curve(curve) => curve.bbox(), + } + } + /// The bounding box of the geometry. pub fn bbox_size(&self) -> Size { match self { diff --git a/crates/typst-pdf/src/convert.rs b/crates/typst-pdf/src/convert.rs index 5ac9e7e3c1..a4ee46e43b 100644 --- a/crates/typst-pdf/src/convert.rs +++ b/crates/typst-pdf/src/convert.rs @@ -106,7 +106,8 @@ fn convert_pages(gc: &mut GlobalContext, document: &mut Document) -> SourceResul let mut page = document.start_page_with(settings); let mut surface = page.surface(); - let mut fc = FrameContext::new(typst_page.frame.size()); + let page_idx = gc.page_index_converter.pdf_page_index(i); + let mut fc = FrameContext::new(page_idx, typst_page.frame.size()); tags::page_start(gc, &mut surface); @@ -175,13 +176,17 @@ impl State { /// Context needed for converting a single frame. pub(crate) struct FrameContext { + /// The logical page index. This might be `None` if the page isn't exported, + /// of if the FrameContext has been built to convert a pattern. + pub(crate) page_idx: Option, states: Vec, link_annotations: Vec, } impl FrameContext { - pub(crate) fn new(size: Size) -> Self { + pub(crate) fn new(page_idx: Option, size: Size) -> Self { Self { + page_idx, states: vec![State::new(size)], link_annotations: Vec::new(), } diff --git a/crates/typst-pdf/src/image.rs b/crates/typst-pdf/src/image.rs index 5a0ed2d37a..89b11b1183 100644 --- a/crates/typst-pdf/src/image.rs +++ b/crates/typst-pdf/src/image.rs @@ -9,7 +9,7 @@ use krilla::tagging::SpanTag; use krilla_svg::{SurfaceExt, SvgSettings}; use typst_library::diag::{SourceResult, bail}; use typst_library::foundations::Smart; -use typst_library::layout::{Abs, Angle, Ratio, Size, Transform}; +use typst_library::layout::{Abs, Angle, Point, Ratio, Rect, Size, Transform}; use typst_library::visualize::{ ExchangeFormat, Image, ImageKind, ImageScaling, PdfImage, RasterFormat, RasterImage, }; @@ -35,6 +35,8 @@ pub(crate) fn handle_image( gc.image_spans.insert(span); + tags::update_bbox(gc, fc, || Rect::from_pos_size(Point::zero(), size)); + let mut handle = tags::start_span(gc, surface, SpanTag::empty().with_alt_text(image.alt())); let surface = handle.surface(); diff --git a/crates/typst-pdf/src/paint.rs b/crates/typst-pdf/src/paint.rs index c994e0e210..5994b39d70 100644 --- a/crates/typst-pdf/src/paint.rs +++ b/crates/typst-pdf/src/paint.rs @@ -127,7 +127,7 @@ fn convert_pattern( let mut stream_builder = surface.stream_builder(); let mut surface = stream_builder.surface(); - let mut fc = FrameContext::new(pattern.frame().size()); + let mut fc = FrameContext::new(None, pattern.frame().size()); handle_frame(&mut fc, pattern.frame(), None, &mut surface, gc)?; surface.finish(); let stream = stream_builder.finish(); diff --git a/crates/typst-pdf/src/shape.rs b/crates/typst-pdf/src/shape.rs index def44c7bfb..8546678446 100644 --- a/crates/typst-pdf/src/shape.rs +++ b/crates/typst-pdf/src/shape.rs @@ -17,6 +17,8 @@ pub(crate) fn handle_shape( gc: &mut GlobalContext, span: Span, ) -> SourceResult<()> { + tags::update_bbox(gc, fc, || shape.geometry.bbox()); + let mut handle = tags::start_artifact(gc, surface, ArtifactKind::Other); let surface = handle.surface(); diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index 97bc64808e..5a207f306c 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -1,18 +1,20 @@ use std::cell::OnceCell; use std::num::NonZeroU16; -use std::ops::{Deref, DerefMut}; +use std::slice::SliceIndex; +use krilla::configure::Validator; +use krilla::geom as kg; use krilla::page::Page; use krilla::surface::Surface; use krilla::tagging as kt; use krilla::tagging::{ - ArtifactType, ContentTag, Identifier, ListNumbering, Node, SpanTag, Tag, TagGroup, - TagKind, TagTree, + ArtifactType, BBox, ContentTag, Identifier, ListNumbering, Node, SpanTag, Tag, + TagGroup, TagKind, TagTree, }; use rustc_hash::FxHashMap; use typst_library::foundations::{Content, Packed}; use typst_library::introspection::Location; -use typst_library::layout::RepeatElem; +use typst_library::layout::{Abs, Point, Rect, RepeatElem}; use typst_library::math::EquationElem; use typst_library::model::{ EnumElem, FigureCaption, FigureElem, FootnoteEntry, HeadingElem, LinkMarker, @@ -22,12 +24,13 @@ use typst_library::pdf::{ArtifactElem, ArtifactKind, PdfMarkerTag, PdfMarkerTagK use typst_library::text::{RawElem, RawLine}; use typst_library::visualize::ImageElem; -use crate::convert::GlobalContext; +use crate::convert::{FrameContext, GlobalContext}; use crate::link::LinkAnnotation; use crate::tags::list::ListCtx; use crate::tags::outline::OutlineCtx; use crate::tags::table::TableCtx; use crate::tags::util::{PropertyOptRef, PropertyValCopied}; +use crate::util::AbsExt; mod list; mod outline; @@ -63,7 +66,8 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten } PdfMarkerTagKind::FigureBody(alt) => { let alt = alt.as_ref().map(|s| s.to_string()); - Tag::Figure(alt).into() + push_stack(gc, loc, StackEntryKind::Figure(FigureCtx::new(alt))); + return; } PdfMarkerTagKind::FootnoteRef(decl_loc) => { push_stack(gc, loc, StackEntryKind::FootnoteRef(*decl_loc)); @@ -114,28 +118,25 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten } else if let Some(image) = elem.to_packed::() { let alt = image.alt.opt_ref().map(|s| s.to_string()); - if let Some(StackEntryKind::Standard(TagKind::Figure(tag))) = - gc.tags.stack.parent() - { + if let Some(figure_ctx) = gc.tags.stack.parent_figure() { // Set alt text of outer figure tag, if not present. - if tag.alt_text().is_none() { - tag.set_alt_text(alt); + if figure_ctx.alt.is_none() { + figure_ctx.alt = alt; } - return; } else { - Tag::Figure(alt).into() + push_stack(gc, loc, StackEntryKind::Figure(FigureCtx::new(alt))); } + return; } else if let Some(equation) = elem.to_packed::() { let alt = equation.alt.opt_ref().map(|s| s.to_string()); - if let Some(StackEntryKind::Standard(TagKind::Figure(tag))) = - gc.tags.stack.parent() - { + if let Some(figure_ctx) = gc.tags.stack.parent_figure() { // Set alt text of outer figure tag, if not present. - if tag.alt_text().is_none() { - tag.set_alt_text(alt.clone()); + if figure_ctx.alt.is_none() { + figure_ctx.alt = alt.clone(); } } - Tag::Formula(alt).into() + push_stack(gc, loc, StackEntryKind::Formula(FigureCtx::new(alt))); + return; } else if let Some(table) = elem.to_packed::() { let table_id = gc.tags.next_table_id(); let summary = table.summary.opt_ref().map(|s| s.to_string()); @@ -254,6 +255,14 @@ pub fn handle_end(gc: &mut GlobalContext, surface: &mut Surface, loc: Location) list_ctx.push_bib_entry(entry.nodes); return; } + StackEntryKind::Figure(ctx) => { + let tag = Tag::Figure(ctx.alt).with_bbox(ctx.bbox.get()); + TagNode::group(tag, entry.nodes) + } + StackEntryKind::Formula(ctx) => { + let tag = Tag::Formula(ctx.alt).with_bbox(ctx.bbox.get()); + TagNode::group(tag, entry.nodes) + } StackEntryKind::Link(_, _) => { let mut node = TagNode::group(Tag::Link, entry.nodes); // Wrap link in reference tag if inside an outline entry. @@ -365,6 +374,18 @@ pub fn add_link_annotations( } } +pub fn update_bbox( + gc: &mut GlobalContext, + fc: &FrameContext, + compute_bbox: impl FnOnce() -> Rect, +) { + if let Some(bbox) = gc.tags.stack.find_parent_bbox() + && gc.options.standards.config.validator() == Validator::UA1 + { + bbox.expand_frame(fc, compute_bbox()); + } +} + pub struct Tags { /// The intermediary stack of nested tag groups. pub stack: TagStack, @@ -389,7 +410,7 @@ pub struct Tags { impl Tags { pub fn new() -> Self { Self { - stack: TagStack(Vec::new()), + stack: TagStack::new(), placeholders: Placeholders(Vec::new()), footnotes: FxHashMap::default(), in_artifact: None, @@ -418,7 +439,7 @@ impl Tags { } pub fn build_tree(&mut self) -> TagTree { - assert!(self.stack.is_empty(), "tags weren't properly closed"); + assert!(self.stack.items.is_empty(), "tags weren't properly closed"); let children = std::mem::take(&mut self.tree) .into_iter() @@ -459,25 +480,79 @@ impl Tags { } } -pub struct TagStack(Vec); +#[derive(Debug)] +pub struct TagStack { + items: Vec, + /// The index of the topmost stack entry that has a bbox. + bbox_idx: Option, +} -impl Deref for TagStack { - type Target = Vec; +impl> std::ops::Index for TagStack { + type Output = I::Output; - fn deref(&self) -> &Self::Target { - &self.0 + #[inline] + fn index(&self, index: I) -> &Self::Output { + std::ops::Index::index(&self.items, index) } } -impl DerefMut for TagStack { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 +impl> std::ops::IndexMut for TagStack { + #[inline] + fn index_mut(&mut self, index: I) -> &mut Self::Output { + std::ops::IndexMut::index_mut(&mut self.items, index) } } impl TagStack { + pub fn new() -> Self { + Self { items: Vec::new(), bbox_idx: None } + } + + pub fn len(&self) -> usize { + self.items.len() + } + + pub fn last_mut(&mut self) -> Option<&mut StackEntry> { + self.items.last_mut() + } + + pub fn push(&mut self, entry: StackEntry) { + if entry.kind.bbox().is_some() { + self.bbox_idx = Some(self.len()); + } + self.items.push(entry); + } + + /// Remove the last stack entry if the predicate returns true. + /// This takes care of updating the parent bboxes. + pub fn pop_if( + &mut self, + mut predicate: impl FnMut(&mut StackEntry) -> bool, + ) -> Option { + let last = self.items.last_mut()?; + if predicate(last) { self.pop() } else { None } + } + + /// Remove the last stack entry. + /// This takes care of updating the parent bboxes. + pub fn pop(&mut self) -> Option { + let removed = self.items.pop()?; + + let Some(inner_bbox) = removed.kind.bbox() else { return Some(removed) }; + + self.bbox_idx = self.items.iter_mut().enumerate().rev().find_map(|(i, entry)| { + let outer_bbox = entry.kind.bbox_mut()?; + if let Some((page_idx, rect)) = inner_bbox.rect { + outer_bbox.expand_page(page_idx, rect); + } + Some(i) + }); + + Some(removed) + } + pub fn parent(&mut self) -> Option<&mut StackEntryKind> { - self.0.last_mut().map(|e| &mut e.kind) + self.items.last_mut().map(|e| &mut e.kind) } pub fn parent_table(&mut self) -> Option<&mut TableCtx> { @@ -488,8 +563,12 @@ impl TagStack { self.parent()?.as_list_mut() } + pub fn parent_figure(&mut self) -> Option<&mut FigureCtx> { + self.parent()?.as_figure_mut() + } + pub fn parent_outline(&mut self) -> Option<(&mut OutlineCtx, &mut Vec)> { - self.0.last_mut().and_then(|e| { + self.items.last_mut().and_then(|e| { let ctx = e.kind.as_outline_mut()?; Some((ctx, &mut e.nodes)) }) @@ -502,11 +581,16 @@ impl TagStack { pub fn find_parent_link( &mut self, ) -> Option<(LinkId, &Packed, &mut Vec)> { - self.0.iter_mut().rev().find_map(|e| { + self.items.iter_mut().rev().find_map(|e| { let (link_id, link) = e.kind.as_link()?; Some((link_id, link, &mut e.nodes)) }) } + + /// Finds the first parent that has a bounding box. + pub fn find_parent_bbox(&mut self) -> Option<&mut BBoxCtx> { + self.items[self.bbox_idx?].kind.bbox_mut() + } } pub struct Placeholders(Vec>); @@ -554,6 +638,8 @@ pub enum StackEntryKind { ListItemLabel, ListItemBody, BibEntry, + Figure(FigureCtx), + Formula(FigureCtx), Link(LinkId, Packed), /// The footnote reference in the text, contains the declaration location. FootnoteRef(Location), @@ -581,6 +667,10 @@ impl StackEntryKind { if let Self::List(v) = self { Some(v) } else { None } } + pub fn as_figure_mut(&mut self) -> Option<&mut FigureCtx> { + if let Self::Figure(v) = self { Some(v) } else { None } + } + pub fn as_link(&self) -> Option<(LinkId, &Packed)> { if let Self::Link(id, link) = self { Some((*id, link)) } else { None } } @@ -588,6 +678,24 @@ impl StackEntryKind { pub fn is_code_block(&self) -> bool { matches!(self, Self::CodeBlock) } + + pub fn bbox(&self) -> Option<&BBoxCtx> { + match self { + Self::Table(ctx) => Some(&ctx.bbox), + Self::Figure(ctx) => Some(&ctx.bbox), + Self::Formula(ctx) => Some(&ctx.bbox), + _ => None, + } + } + + pub fn bbox_mut(&mut self) -> Option<&mut BBoxCtx> { + match self { + Self::Table(ctx) => Some(&mut ctx.bbox), + Self::Figure(ctx) => Some(&mut ctx.bbox), + Self::Formula(ctx) => Some(&mut ctx.bbox), + _ => None, + } + } } #[derive(Debug, Clone, PartialEq)] @@ -606,6 +714,93 @@ impl FootnoteCtx { } } +/// Figure/Formula context +#[derive(Debug, Clone, PartialEq)] +pub struct FigureCtx { + alt: Option, + bbox: BBoxCtx, +} + +impl FigureCtx { + fn new(alt: Option) -> Self { + Self { alt, bbox: BBoxCtx::new() } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct BBoxCtx { + rect: Option<(usize, Rect)>, + multi_page: bool, +} + +impl BBoxCtx { + pub fn new() -> Self { + Self { rect: None, multi_page: false } + } + + /// Expand the bounding box with a `rect` relative to the current frame + /// context transform. + pub fn expand_frame(&mut self, fc: &FrameContext, rect: Rect) { + let Some(page_idx) = fc.page_idx else { return }; + if self.multi_page { + return; + } + let (idx, bbox) = self.rect.get_or_insert(( + page_idx, + Rect::new(Point::splat(Abs::inf()), Point::splat(-Abs::inf())), + )); + if *idx != page_idx { + self.multi_page = true; + self.rect = None; + return; + } + + let size = rect.size(); + for point in [ + rect.min, + rect.min + Point::with_x(size.x), + rect.min + Point::with_y(size.y), + rect.max, + ] { + let p = point.transform(fc.state().transform()); + bbox.min = bbox.min.min(p); + bbox.max = bbox.max.max(p); + } + } + + /// Expand the bounding box with a rectangle that's already transformed into + /// page coordinates. + pub fn expand_page(&mut self, page_idx: usize, rect: Rect) { + if self.multi_page { + return; + } + let (idx, bbox) = self.rect.get_or_insert(( + page_idx, + Rect::new(Point::splat(Abs::inf()), Point::splat(-Abs::inf())), + )); + if *idx != page_idx { + self.multi_page = true; + self.rect = None; + return; + } + + bbox.min = bbox.min.min(rect.min); + bbox.max = bbox.max.max(rect.max); + } + + pub fn get(&self) -> Option { + let (page_idx, rect) = self.rect?; + let rect = kg::Rect::from_ltrb( + rect.min.x.to_f32(), + rect.min.y.to_f32(), + rect.max.x.to_f32(), + rect.max.y.to_f32(), + ) + .unwrap(); + Some(BBox::new(page_idx, rect)) + } +} + #[derive(Debug, Clone, PartialEq)] pub enum TagNode { Group(TagKind, Vec), diff --git a/crates/typst-pdf/src/tags/table.rs b/crates/typst-pdf/src/tags/table.rs index 74169b4a92..e15c02bad2 100644 --- a/crates/typst-pdf/src/tags/table.rs +++ b/crates/typst-pdf/src/tags/table.rs @@ -10,19 +10,26 @@ use typst_library::pdf::{TableCellKind, TableHeaderScope}; use typst_syntax::Span; use crate::tags::util::PropertyValCopied; -use crate::tags::{TableId, TagNode}; +use crate::tags::{BBoxCtx, TableId, TagNode}; #[derive(Debug)] pub struct TableCtx { pub id: TableId, pub summary: Option, + pub bbox: BBoxCtx, rows: Vec>, min_width: usize, } impl TableCtx { pub fn new(id: TableId, summary: Option) -> Self { - Self { id, summary, rows: Vec::new(), min_width: 0 } + Self { + id, + summary, + bbox: BBoxCtx::new(), + rows: Vec::new(), + min_width: 0, + } } fn get(&self, x: usize, y: usize) -> Option<&TableCtxCell> { @@ -223,7 +230,10 @@ impl TableCtx { nodes.push(TagNode::Group(tag, row_chunk)); } - TagNode::group(Tag::Table.with_summary(self.summary), nodes) + let tag = Tag::Table + .with_summary(self.summary) + .with_bbox(self.bbox.get()); + TagNode::group(tag, nodes) } fn resolve_cell_headers( diff --git a/crates/typst-pdf/src/text.rs b/crates/typst-pdf/src/text.rs index 632b716a61..293a6ff833 100644 --- a/crates/typst-pdf/src/text.rs +++ b/crates/typst-pdf/src/text.rs @@ -24,6 +24,8 @@ pub(crate) fn handle_text( ) -> SourceResult<()> { *gc.languages.entry(t.lang).or_insert(0) += t.glyphs.len(); + tags::update_bbox(gc, fc, || t.bbox()); + let mut handle = tags::start_span(gc, surface, SpanTag::empty()); let surface = handle.surface(); From 35be04877d217898352ea5346d672f0795763c45 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Fri, 25 Jul 2025 15:42:12 +0200 Subject: [PATCH 414/558] Handle overlapping introspection tags --- crates/typst-pdf/src/convert.rs | 2 +- crates/typst-pdf/src/tags/list.rs | 4 +- crates/typst-pdf/src/tags/mod.rs | 276 ++++++++++++++++++++++----- crates/typst-pdf/src/tags/outline.rs | 4 +- crates/typst-pdf/src/tags/table.rs | 6 +- 5 files changed, 231 insertions(+), 61 deletions(-) diff --git a/crates/typst-pdf/src/convert.rs b/crates/typst-pdf/src/convert.rs index a4ee46e43b..6183f09575 100644 --- a/crates/typst-pdf/src/convert.rs +++ b/crates/typst-pdf/src/convert.rs @@ -301,7 +301,7 @@ pub(crate) fn handle_frame( } FrameItem::Link(dest, size) => handle_link(fc, gc, dest, *size), FrameItem::Tag(Tag::Start(elem)) => tags::handle_start(gc, surface, elem), - FrameItem::Tag(Tag::End(loc, _)) => tags::handle_end(gc, surface, *loc), + FrameItem::Tag(Tag::End(loc, _)) => tags::handle_end(gc, surface, *loc)?, } fc.pop(); diff --git a/crates/typst-pdf/src/tags/list.rs b/crates/typst-pdf/src/tags/list.rs index 77763b3be0..d9934f3202 100644 --- a/crates/typst-pdf/src/tags/list.rs +++ b/crates/typst-pdf/src/tags/list.rs @@ -2,13 +2,13 @@ use krilla::tagging::{ListNumbering, Tag, TagKind}; use crate::tags::TagNode; -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct ListCtx { numbering: ListNumbering, items: Vec, } -#[derive(Debug)] +#[derive(Clone, Debug)] struct ListItem { label: Vec, body: Option>, diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index 5a207f306c..36d26cb37e 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -12,17 +12,20 @@ use krilla::tagging::{ TagGroup, TagKind, TagTree, }; use rustc_hash::FxHashMap; +use typst_library::diag::{SourceResult, bail}; use typst_library::foundations::{Content, Packed}; use typst_library::introspection::Location; use typst_library::layout::{Abs, Point, Rect, RepeatElem}; use typst_library::math::EquationElem; use typst_library::model::{ EnumElem, FigureCaption, FigureElem, FootnoteEntry, HeadingElem, LinkMarker, - ListElem, Outlinable, OutlineEntry, QuoteElem, TableCell, TableElem, TermsElem, + ListElem, Outlinable, OutlineEntry, ParElem, QuoteElem, TableCell, TableElem, + TermsElem, }; use typst_library::pdf::{ArtifactElem, ArtifactKind, PdfMarkerTag, PdfMarkerTagKind}; use typst_library::text::{RawElem, RawLine}; use typst_library::visualize::ImageElem; +use typst_syntax::Span; use crate::convert::{FrameContext, GlobalContext}; use crate::link::LinkAnnotation; @@ -47,66 +50,64 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten return; } - let loc = elem.location().expect("elem to be locatable"); - if let Some(artifact) = elem.to_packed::() { let kind = artifact.kind.val(); - push_artifact(gc, surface, loc, kind); + push_artifact(gc, surface, elem, kind); return; } else if let Some(_) = elem.to_packed::() { - push_artifact(gc, surface, loc, ArtifactKind::Other); + push_artifact(gc, surface, elem, ArtifactKind::Other); return; } let tag: TagKind = if let Some(tag) = elem.to_packed::() { match &tag.kind { PdfMarkerTagKind::OutlineBody => { - push_stack(gc, loc, StackEntryKind::Outline(OutlineCtx::new())); + push_stack(gc, elem, StackEntryKind::Outline(OutlineCtx::new())); return; } PdfMarkerTagKind::FigureBody(alt) => { let alt = alt.as_ref().map(|s| s.to_string()); - push_stack(gc, loc, StackEntryKind::Figure(FigureCtx::new(alt))); + push_stack(gc, elem, StackEntryKind::Figure(FigureCtx::new(alt))); return; } PdfMarkerTagKind::FootnoteRef(decl_loc) => { - push_stack(gc, loc, StackEntryKind::FootnoteRef(*decl_loc)); + push_stack(gc, elem, StackEntryKind::FootnoteRef(*decl_loc)); return; } PdfMarkerTagKind::Bibliography(numbered) => { let numbering = if *numbered { ListNumbering::Decimal } else { ListNumbering::None }; - push_stack(gc, loc, StackEntryKind::List(ListCtx::new(numbering))); + push_stack(gc, elem, StackEntryKind::List(ListCtx::new(numbering))); return; } PdfMarkerTagKind::BibEntry => { - push_stack(gc, loc, StackEntryKind::BibEntry); + push_stack(gc, elem, StackEntryKind::BibEntry); return; } PdfMarkerTagKind::ListItemLabel => { - push_stack(gc, loc, StackEntryKind::ListItemLabel); + push_stack(gc, elem, StackEntryKind::ListItemLabel); return; } PdfMarkerTagKind::ListItemBody => { - push_stack(gc, loc, StackEntryKind::ListItemBody); + push_stack(gc, elem, StackEntryKind::ListItemBody); return; } PdfMarkerTagKind::Label => Tag::Lbl.into(), } } else if let Some(entry) = elem.to_packed::() { - push_stack(gc, loc, StackEntryKind::OutlineEntry(entry.clone())); + push_stack(gc, elem, StackEntryKind::OutlineEntry(entry.clone())); return; } else if let Some(_list) = elem.to_packed::() { let numbering = ListNumbering::Circle; // TODO: infer numbering from `list.marker` - push_stack(gc, loc, StackEntryKind::List(ListCtx::new(numbering))); + push_stack(gc, elem, StackEntryKind::List(ListCtx::new(numbering))); return; } else if let Some(_enumeration) = elem.to_packed::() { let numbering = ListNumbering::Decimal; // TODO: infer numbering from `enum.numbering` - push_stack(gc, loc, StackEntryKind::List(ListCtx::new(numbering))); + push_stack(gc, elem, StackEntryKind::List(ListCtx::new(numbering))); return; } else if let Some(_terms) = elem.to_packed::() { let numbering = ListNumbering::None; - push_stack(gc, loc, StackEntryKind::List(ListCtx::new(numbering))); + push_stack(gc, elem, StackEntryKind::List(ListCtx::new(numbering))); return; } else if let Some(_) = elem.to_packed::() { // Wrap the figure tag and the sibling caption in a container, if the @@ -124,7 +125,7 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten figure_ctx.alt = alt; } } else { - push_stack(gc, loc, StackEntryKind::Figure(FigureCtx::new(alt))); + push_stack(gc, elem, StackEntryKind::Figure(FigureCtx::new(alt))); } return; } else if let Some(equation) = elem.to_packed::() { @@ -135,13 +136,13 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten figure_ctx.alt = alt.clone(); } } - push_stack(gc, loc, StackEntryKind::Formula(FigureCtx::new(alt))); + push_stack(gc, elem, StackEntryKind::Formula(FigureCtx::new(alt))); return; } else if let Some(table) = elem.to_packed::() { let table_id = gc.tags.next_table_id(); let summary = table.summary.opt_ref().map(|s| s.to_string()); let ctx = TableCtx::new(table_id, summary); - push_stack(gc, loc, StackEntryKind::Table(ctx)); + push_stack(gc, elem, StackEntryKind::Table(ctx)); return; } else if let Some(cell) = elem.to_packed::() { let table_ctx = gc.tags.stack.parent_table(); @@ -151,29 +152,31 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten // semantic meaning in the tag tree, which doesn't use page breaks for // it's semantic structure. if cell.is_repeated.val() || table_ctx.is_some_and(|ctx| ctx.contains(cell)) { - push_artifact(gc, surface, loc, ArtifactKind::Other); + push_artifact(gc, surface, elem, ArtifactKind::Other); } else { - push_stack(gc, loc, StackEntryKind::TableCell(cell.clone())); + push_stack(gc, elem, StackEntryKind::TableCell(cell.clone())); } return; } else if let Some(heading) = elem.to_packed::() { let level = heading.level().try_into().unwrap_or(NonZeroU16::MAX); let name = heading.body.plain_text().to_string(); Tag::Hn(level, Some(name)).into() + } else if let Some(_) = elem.to_packed::() { + Tag::P.into() } else if let Some(link) = elem.to_packed::() { let link_id = gc.tags.next_link_id(); - push_stack(gc, loc, StackEntryKind::Link(link_id, link.clone())); + push_stack(gc, elem, StackEntryKind::Link(link_id, link.clone())); return; } else if let Some(entry) = elem.to_packed::() { let footnote_loc = entry.note.location().unwrap(); - push_stack(gc, loc, StackEntryKind::FootnoteEntry(footnote_loc)); + push_stack(gc, elem, StackEntryKind::FootnoteEntry(footnote_loc)); return; } else if let Some(quote) = elem.to_packed::() { // TODO: should the attribution be handled somehow? if quote.block.val() { Tag::BlockQuote.into() } else { Tag::InlineQuote.into() } } else if let Some(raw) = elem.to_packed::() { if raw.block.val() { - push_stack(gc, loc, StackEntryKind::CodeBlock); + push_stack(gc, elem, StackEntryKind::CodeBlock); return; } else { Tag::Code.into() @@ -181,7 +184,7 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten } else if let Some(_) = elem.to_packed::() { // If the raw element is inline, the content can be inserted directly. if gc.tags.stack.parent().is_some_and(|p| p.is_code_block()) { - push_stack(gc, loc, StackEntryKind::CodeBlockLine); + push_stack(gc, elem, StackEntryKind::CodeBlockLine); } return; } else { @@ -189,25 +192,130 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten }; let tag = tag.with_location(Some(elem.span().into_raw())); - push_stack(gc, loc, StackEntryKind::Standard(tag)); + push_stack(gc, elem, StackEntryKind::Standard(tag)); +} + +fn push_stack(gc: &mut GlobalContext, elem: &Content, kind: StackEntryKind) { + let loc = elem.location().expect("elem to be locatable"); + let span = elem.span(); + gc.tags.stack.push(StackEntry { loc, span, kind, nodes: Vec::new() }); +} + +fn push_artifact( + gc: &mut GlobalContext, + surface: &mut Surface, + elem: &Content, + kind: ArtifactKind, +) { + let loc = elem.location().expect("elem to be locatable"); + let ty = artifact_type(kind); + let id = surface.start_tagged(ContentTag::Artifact(ty)); + gc.tags.push(TagNode::Leaf(id)); + gc.tags.in_artifact = Some((loc, kind)); } -pub fn handle_end(gc: &mut GlobalContext, surface: &mut Surface, loc: Location) { +pub fn handle_end( + gc: &mut GlobalContext, + surface: &mut Surface, + loc: Location, +) -> SourceResult<()> { if gc.options.disable_tags { - return; + return Ok(()); } - if let Some((l, _)) = gc.tags.in_artifact { - if l == loc { - pop_artifact(gc, surface); - } - return; + if let Some((l, _)) = gc.tags.in_artifact + && l == loc + { + pop_artifact(gc, surface); + return Ok(()); } - let Some(entry) = gc.tags.stack.pop_if(|e| e.loc == loc) else { - return; + if let Some(entry) = gc.tags.stack.pop_if(|e| e.loc == loc) { + // The tag nesting was properly closed. + pop_stack(gc, entry); + return Ok(()); + } + + // Search for an improperly nested starting tag, that is being closed. + let Some(idx) = (gc.tags.stack.iter().enumerate()) + .rev() + .find_map(|(i, e)| (e.loc == loc).then_some(i)) + else { + // The start tag isn't in the tag stack, just ignore the end tag. + return Ok(()); }; + // There are overlapping tags in the tag tree. Figure whether breaking + // up the current tag stack is semantically ok. + let is_pdf_ua = gc.options.standards.config.validator() == Validator::UA1; + let mut is_breakable = true; + let mut non_breakable_span = Span::detached(); + for e in gc.tags.stack[idx + 1..].iter() { + if e.kind.is_breakable(is_pdf_ua) { + continue; + } + + is_breakable = false; + if !e.span.is_detached() { + non_breakable_span = e.span; + break; + } + } + if !is_breakable { + let validator = gc.options.standards.config.validator(); + if is_pdf_ua { + let ua1 = validator.as_str(); + bail!( + non_breakable_span, + "{ua1} error: invalid semantic structure, \ + this element's tag would be split up"; + hint: "maybe this is caused by a `parbreak`, `colbreak`, or `pagebreak`" + ); + } else { + bail!( + non_breakable_span, + "invalid semantic structure, \ + this element's tag would be split up"; + hint: "maybe this is caused by a `parbreak`, `colbreak`, or `pagebreak`"; + hint: "disable tagged pdf by passing `--no-pdf-tags`" + ); + } + } + + // Close all children tags and reopen them after the currently enclosing element. + let mut broken_entries = Vec::new(); + for _ in idx + 1..gc.tags.stack.len() { + let entry = gc.tags.stack.pop().unwrap(); + + let mut kind = entry.kind.clone(); + if let StackEntryKind::Link(id, _) = &mut kind { + // Assign a new link id, so a new link annotation will be created. + *id = gc.tags.next_link_id(); + } + if let Some(bbox) = kind.bbox_mut() { + bbox.reset(); + } + + broken_entries.push(StackEntry { + loc: entry.loc, + span: entry.span, + kind, + nodes: Vec::new(), + }); + pop_stack(gc, entry); + } + + // Pop the closed entry off the stack. + let closed = gc.tags.stack.pop().unwrap(); + pop_stack(gc, closed); + + // Push all broken and afterwards duplicated entries back on. + gc.tags.stack.extend(broken_entries); + + Ok(()) +} + +fn pop_stack(gc: &mut GlobalContext, entry: StackEntry) { let node = match entry.kind { StackEntryKind::Standard(tag) => TagNode::group(tag, entry.nodes), StackEntryKind::Outline(ctx) => ctx.build_outline(entry.nodes), @@ -306,22 +414,6 @@ pub fn handle_end(gc: &mut GlobalContext, surface: &mut Surface, loc: Location) gc.tags.push(node); } -fn push_stack(gc: &mut GlobalContext, loc: Location, kind: StackEntryKind) { - gc.tags.stack.push(StackEntry { loc, kind, nodes: Vec::new() }); -} - -fn push_artifact( - gc: &mut GlobalContext, - surface: &mut Surface, - loc: Location, - kind: ArtifactKind, -) { - let ty = artifact_type(kind); - let id = surface.start_tagged(ContentTag::Artifact(ty)); - gc.tags.push(TagNode::Leaf(id)); - gc.tags.in_artifact = Some((loc, kind)); -} - fn pop_artifact(gc: &mut GlobalContext, surface: &mut Surface) { surface.end_tagged(); gc.tags.in_artifact = None; @@ -516,6 +608,10 @@ impl TagStack { self.items.last_mut() } + pub fn iter(&self) -> std::slice::Iter<'_, StackEntry> { + self.items.iter() + } + pub fn push(&mut self, entry: StackEntry) { if entry.kind.bbox().is_some() { self.bbox_idx = Some(self.len()); @@ -523,6 +619,17 @@ impl TagStack { self.items.push(entry); } + pub fn extend(&mut self, iter: impl IntoIterator) { + let start = self.len(); + self.items.extend(iter); + let last_bbox_offset = self.items[start..] + .iter() + .rposition(|entry| entry.kind.bbox().is_some()); + if let Some(offset) = last_bbox_offset { + self.bbox_idx = Some(start + offset); + } + } + /// Remove the last stack entry if the predicate returns true. /// This takes care of updating the parent bboxes. pub fn pop_if( @@ -623,11 +730,12 @@ pub struct LinkId(u32); #[derive(Debug)] pub struct StackEntry { pub loc: Location, + pub span: Span, pub kind: StackEntryKind, pub nodes: Vec, } -#[derive(Debug)] +#[derive(Clone, Debug)] pub enum StackEntryKind { Standard(TagKind), Outline(OutlineCtx), @@ -696,6 +804,66 @@ impl StackEntryKind { _ => None, } } + + fn is_breakable(&self, is_pdf_ua: bool) -> bool { + match self { + StackEntryKind::Standard(tag) => match tag { + TagKind::Part(_) => !is_pdf_ua, + TagKind::Article(_) => !is_pdf_ua, + TagKind::Section(_) => !is_pdf_ua, + TagKind::Div(_) => !is_pdf_ua, + TagKind::BlockQuote(_) => !is_pdf_ua, + TagKind::Caption(_) => !is_pdf_ua, + TagKind::TOC(_) => false, + TagKind::TOCI(_) => false, + TagKind::Index(_) => false, + TagKind::P(_) => true, + TagKind::Hn(_) => !is_pdf_ua, + TagKind::L(_) => false, + TagKind::LI(_) => false, + TagKind::Lbl(_) => !is_pdf_ua, + TagKind::LBody(_) => !is_pdf_ua, + TagKind::Table(_) => false, + TagKind::TR(_) => false, + TagKind::TH(_) => false, + TagKind::TD(_) => false, + TagKind::THead(_) => false, + TagKind::TBody(_) => false, + TagKind::TFoot(_) => false, + TagKind::Span(_) => true, + TagKind::InlineQuote(_) => !is_pdf_ua, + TagKind::Note(_) => !is_pdf_ua, + TagKind::Reference(_) => !is_pdf_ua, + TagKind::BibEntry(_) => !is_pdf_ua, + TagKind::Code(_) => !is_pdf_ua, + TagKind::Link(_) => !is_pdf_ua, + TagKind::Annot(_) => !is_pdf_ua, + TagKind::Figure(_) => !is_pdf_ua, + TagKind::Formula(_) => !is_pdf_ua, + TagKind::NonStruct(_) => !is_pdf_ua, + TagKind::Datetime(_) => !is_pdf_ua, + TagKind::Terms(_) => !is_pdf_ua, + TagKind::Title(_) => !is_pdf_ua, + TagKind::Strong(_) => !is_pdf_ua, + TagKind::Em(_) => !is_pdf_ua, + }, + StackEntryKind::Outline(_) => false, + StackEntryKind::OutlineEntry(_) => false, + StackEntryKind::Table(_) => false, + StackEntryKind::TableCell(_) => false, + StackEntryKind::List(_) => false, + StackEntryKind::ListItemLabel => false, + StackEntryKind::ListItemBody => false, + StackEntryKind::BibEntry => false, + StackEntryKind::Figure(_) => false, + StackEntryKind::Formula(_) => false, + StackEntryKind::Link(..) => !is_pdf_ua, + StackEntryKind::FootnoteRef(_) => false, + StackEntryKind::FootnoteEntry(_) => false, + StackEntryKind::CodeBlock => false, + StackEntryKind::CodeBlockLine => false, + } + } } #[derive(Debug, Clone, PartialEq)] @@ -738,6 +906,10 @@ impl BBoxCtx { Self { rect: None, multi_page: false } } + pub fn reset(&mut self) { + *self = Self::new(); + } + /// Expand the bounding box with a `rect` relative to the current frame /// context transform. pub fn expand_frame(&mut self, fc: &FrameContext, rect: Rect) { diff --git a/crates/typst-pdf/src/tags/outline.rs b/crates/typst-pdf/src/tags/outline.rs index 36e68749c4..1f7205bf2c 100644 --- a/crates/typst-pdf/src/tags/outline.rs +++ b/crates/typst-pdf/src/tags/outline.rs @@ -4,7 +4,7 @@ use typst_library::model::OutlineEntry; use crate::tags::TagNode; -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct OutlineCtx { stack: Vec, } @@ -53,7 +53,7 @@ impl OutlineCtx { } } -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct OutlineSection { entries: Vec, } diff --git a/crates/typst-pdf/src/tags/table.rs b/crates/typst-pdf/src/tags/table.rs index e15c02bad2..6b80e55d94 100644 --- a/crates/typst-pdf/src/tags/table.rs +++ b/crates/typst-pdf/src/tags/table.rs @@ -12,7 +12,7 @@ use typst_syntax::Span; use crate::tags::util::PropertyValCopied; use crate::tags::{BBoxCtx, TableId, TagNode}; -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct TableCtx { pub id: TableId, pub summary: Option, @@ -230,9 +230,7 @@ impl TableCtx { nodes.push(TagNode::Group(tag, row_chunk)); } - let tag = Tag::Table - .with_summary(self.summary) - .with_bbox(self.bbox.get()); + let tag = Tag::Table.with_summary(self.summary).with_bbox(self.bbox.get()); TagNode::group(tag, nodes) } From 5009e97e660536ddda73dcebe12c3463dad4471d Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Thu, 31 Jul 2025 17:06:18 +0200 Subject: [PATCH 415/558] Change document language selection and write language attribute on tags The document language is now inferred from the first non-artifact text item. If no text is present it will default to english. --- crates/typst-pdf/src/convert.rs | 7 +- crates/typst-pdf/src/metadata.rs | 17 ++- crates/typst-pdf/src/tags/list.rs | 47 ++++---- crates/typst-pdf/src/tags/mod.rs | 156 +++++++++++++++++++-------- crates/typst-pdf/src/tags/outline.rs | 14 +-- crates/typst-pdf/src/tags/table.rs | 39 +++---- crates/typst-pdf/src/text.rs | 8 +- 7 files changed, 173 insertions(+), 115 deletions(-) diff --git a/crates/typst-pdf/src/convert.rs b/crates/typst-pdf/src/convert.rs index 6183f09575..0d2aeb71c7 100644 --- a/crates/typst-pdf/src/convert.rs +++ b/crates/typst-pdf/src/convert.rs @@ -1,5 +1,3 @@ -use std::collections::BTreeMap; - use ecow::{EcoVec, eco_format}; use krilla::configure::{Configuration, ValidationError, Validator}; use krilla::destination::NamedDestination; @@ -19,7 +17,7 @@ use typst_library::layout::{ Frame, FrameItem, GroupItem, PagedDocument, Size, Transform, }; use typst_library::model::HeadingElem; -use typst_library::text::{Font, Lang}; +use typst_library::text::Font; use typst_library::visualize::{Geometry, Paint}; use typst_syntax::Span; @@ -239,8 +237,6 @@ pub(crate) struct GlobalContext<'a> { pub(crate) options: &'a PdfOptions<'a>, /// Mapping between locations in the document and named destinations. pub(crate) loc_to_names: FxHashMap, - /// The languages used throughout the document. - pub(crate) languages: BTreeMap, pub(crate) page_index_converter: PageIndexConverter, /// Tagged PDF context. pub(crate) tags: Tags, @@ -261,7 +257,6 @@ impl<'a> GlobalContext<'a> { loc_to_names, image_to_spans: FxHashMap::default(), image_spans: FxHashSet::default(), - languages: BTreeMap::new(), page_index_converter, tags: Tags::new(), diff --git a/crates/typst-pdf/src/metadata.rs b/crates/typst-pdf/src/metadata.rs index 3bc9dc390a..25e32da5ef 100644 --- a/crates/typst-pdf/src/metadata.rs +++ b/crates/typst-pdf/src/metadata.rs @@ -1,17 +1,19 @@ use ecow::EcoString; use krilla::metadata::{Metadata, TextDirection}; -use typst_library::foundations::{Datetime, Smart}; +use typst_library::foundations::{Datetime, Smart, StyleChain}; use typst_library::layout::Dir; -use typst_library::text::Lang; +use typst_library::text::TextElem; use crate::convert::GlobalContext; pub(crate) fn build_metadata(gc: &GlobalContext) -> Metadata { let creator = format!("Typst {}", env!("CARGO_PKG_VERSION")); - let lang = gc.languages.iter().max_by_key(|&(_, &count)| count).map(|(&l, _)| l); + // Always write a language, PDF/UA-1 implicitly requires a document language + // so the metadata and outline entries have an applicable language. + let lang = gc.tags.doc_lang.unwrap_or(StyleChain::default().get(TextElem::lang)); - let dir = if lang.map(Lang::dir) == Some(Dir::RTL) { + let dir = if lang.dir() == Dir::RTL { TextDirection::RightToLeft } else { TextDirection::LeftToRight @@ -20,11 +22,8 @@ pub(crate) fn build_metadata(gc: &GlobalContext) -> Metadata { let mut metadata = Metadata::new() .creator(creator) .keywords(gc.document.info.keywords.iter().map(EcoString::to_string).collect()) - .authors(gc.document.info.author.iter().map(EcoString::to_string).collect()); - - if let Some(lang) = lang { - metadata = metadata.language(lang.as_str().to_string()); - } + .authors(gc.document.info.author.iter().map(EcoString::to_string).collect()) + .language(lang.as_str().to_string()); if let Some(title) = &gc.document.info.title { metadata = metadata.title(title.to_string()); diff --git a/crates/typst-pdf/src/tags/list.rs b/crates/typst-pdf/src/tags/list.rs index d9934f3202..5af3e39720 100644 --- a/crates/typst-pdf/src/tags/list.rs +++ b/crates/typst-pdf/src/tags/list.rs @@ -1,6 +1,6 @@ use krilla::tagging::{ListNumbering, Tag, TagKind}; -use crate::tags::TagNode; +use crate::tags::{GroupContents, TagNode}; #[derive(Clone, Debug)] pub struct ListCtx { @@ -10,8 +10,8 @@ pub struct ListCtx { #[derive(Clone, Debug)] struct ListItem { - label: Vec, - body: Option>, + label: TagNode, + body: Option, sub_list: Option, } @@ -20,11 +20,15 @@ impl ListCtx { Self { numbering, items: Vec::new() } } - pub fn push_label(&mut self, nodes: Vec) { - self.items.push(ListItem { label: nodes, body: None, sub_list: None }); + pub fn push_label(&mut self, contents: GroupContents) { + self.items.push(ListItem { + label: TagNode::group(Tag::Lbl, contents), + body: None, + sub_list: None, + }); } - pub fn push_body(&mut self, mut nodes: Vec) { + pub fn push_body(&mut self, mut contents: GroupContents) { let item = self.items.last_mut().expect("ListItemLabel"); // Nested lists are expected to have the following structure: @@ -60,40 +64,43 @@ impl ListCtx { // ``` // // So move the nested list out of the list item. - if let [.., TagNode::Group(TagKind::L(_), _)] = nodes.as_slice() { - item.sub_list = nodes.pop(); + if let [.., TagNode::Group(group)] = contents.nodes.as_slice() + && let TagKind::L(_) = group.tag + { + item.sub_list = contents.nodes.pop(); } - item.body = Some(nodes); + item.body = Some(TagNode::group(Tag::LBody, contents)); } - pub fn push_bib_entry(&mut self, nodes: Vec) { - let nodes = vec![TagNode::group(Tag::BibEntry, nodes)]; + pub fn push_bib_entry(&mut self, contents: GroupContents) { + let nodes = vec![TagNode::group(Tag::BibEntry, contents)]; // Bibliography lists cannot be nested, but may be missing labels. + let body = TagNode::virtual_group(Tag::LBody, nodes); if let Some(item) = self.items.last_mut().filter(|item| item.body.is_none()) { - item.body = Some(nodes); + item.body = Some(body); } else { self.items.push(ListItem { - label: Vec::new(), - body: Some(nodes), + label: TagNode::empty_group(Tag::Lbl), + body: Some(body), sub_list: None, }); } } - pub fn build_list(self, mut nodes: Vec) -> TagNode { + pub fn build_list(self, mut contents: GroupContents) -> TagNode { for item in self.items.into_iter() { - nodes.push(TagNode::group( + contents.nodes.push(TagNode::virtual_group( Tag::LI, vec![ - TagNode::group(Tag::Lbl, item.label), - TagNode::group(Tag::LBody, item.body.unwrap_or_default()), + item.label, + item.body.unwrap_or_else(|| TagNode::empty_group(Tag::LBody)), ], )); if let Some(sub_list) = item.sub_list { - nodes.push(sub_list); + contents.nodes.push(sub_list); } } - TagNode::group(Tag::L(self.numbering), nodes) + TagNode::group(Tag::L(self.numbering), contents) } } diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index 36d26cb37e..2a358997f3 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -9,7 +9,7 @@ use krilla::surface::Surface; use krilla::tagging as kt; use krilla::tagging::{ ArtifactType, BBox, ContentTag, Identifier, ListNumbering, Node, SpanTag, Tag, - TagGroup, TagKind, TagTree, + TagKind, TagTree, }; use rustc_hash::FxHashMap; use typst_library::diag::{SourceResult, bail}; @@ -23,7 +23,7 @@ use typst_library::model::{ TermsElem, }; use typst_library::pdf::{ArtifactElem, ArtifactKind, PdfMarkerTag, PdfMarkerTagKind}; -use typst_library::text::{RawElem, RawLine}; +use typst_library::text::{Lang, RawElem, RawLine}; use typst_library::visualize::ImageElem; use typst_syntax::Span; @@ -198,7 +198,9 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten fn push_stack(gc: &mut GlobalContext, elem: &Content, kind: StackEntryKind) { let loc = elem.location().expect("elem to be locatable"); let span = elem.span(); - gc.tags.stack.push(StackEntry { loc, span, kind, nodes: Vec::new() }); + gc.tags + .stack + .push(StackEntry { loc, span, lang: None, kind, nodes: Vec::new() }); } fn push_artifact( @@ -299,6 +301,7 @@ pub fn handle_end( broken_entries.push(StackEntry { loc: entry.loc, span: entry.span, + lang: None, kind, nodes: Vec::new(), }); @@ -316,72 +319,77 @@ pub fn handle_end( } fn pop_stack(gc: &mut GlobalContext, entry: StackEntry) { + // Try to propagate the tag language to the parent tag, or the document. + // If successfull omit the language attribute on this tag. + let lang = entry.lang.and_then(|lang| { + let parent_lang = (gc.tags.stack.last_mut()) + .map(|e| &mut e.lang) + .unwrap_or(&mut gc.tags.doc_lang); + if parent_lang.is_none_or(|l| l == lang) { + *parent_lang = Some(lang); + return None; + } + Some(lang) + }); + + let contents = GroupContents { span: entry.span, lang, nodes: entry.nodes }; let node = match entry.kind { - StackEntryKind::Standard(tag) => TagNode::group(tag, entry.nodes), - StackEntryKind::Outline(ctx) => ctx.build_outline(entry.nodes), + StackEntryKind::Standard(tag) => TagNode::group(tag, contents), + StackEntryKind::Outline(ctx) => ctx.build_outline(contents), StackEntryKind::OutlineEntry(outline_entry) => { - let Some((outline_ctx, outline_nodes)) = gc.tags.stack.parent_outline() - else { - // PDF/UA compliance of the structure hierarchy is checked - // elsewhere. While this doesn't make a lot of sense, just - // avoid crashing here. - let tag = Tag::TOCI.with_location(Some(outline_entry.span().into_raw())); - gc.tags.push(TagNode::group(tag, entry.nodes)); + if let Some((outline_ctx, outline_nodes)) = gc.tags.stack.parent_outline() { + outline_ctx.insert(outline_nodes, outline_entry, contents); return; - }; - - outline_ctx.insert(outline_nodes, outline_entry, entry.nodes); - return; + } else { + // Avoid panicking, the nesting will be validated later. + TagNode::group(Tag::TOCI, contents) + } } - StackEntryKind::Table(ctx) => ctx.build_table(entry.nodes), + StackEntryKind::Table(ctx) => ctx.build_table(contents), StackEntryKind::TableCell(cell) => { - let Some(table_ctx) = gc.tags.stack.parent_table() else { - // PDF/UA compliance of the structure hierarchy is checked - // elsewhere. While this doesn't make a lot of sense, just - // avoid crashing here. - let tag = Tag::TD.with_location(Some(cell.span().into_raw())); - gc.tags.push(TagNode::group(tag, entry.nodes)); + if let Some(table_ctx) = gc.tags.stack.parent_table() { + table_ctx.insert(&cell, contents); return; - }; - - table_ctx.insert(&cell, entry.nodes); - return; + } else { + // Avoid panicking, the nesting will be validated later. + TagNode::group(Tag::TD, contents) + } } - StackEntryKind::List(list) => list.build_list(entry.nodes), + StackEntryKind::List(list) => list.build_list(contents), StackEntryKind::ListItemLabel => { let list_ctx = gc.tags.stack.parent_list().expect("parent list"); - list_ctx.push_label(entry.nodes); + list_ctx.push_label(contents); return; } StackEntryKind::ListItemBody => { let list_ctx = gc.tags.stack.parent_list().expect("parent list"); - list_ctx.push_body(entry.nodes); + list_ctx.push_body(contents); return; } StackEntryKind::BibEntry => { let list_ctx = gc.tags.stack.parent_list().expect("parent list"); - list_ctx.push_bib_entry(entry.nodes); + list_ctx.push_bib_entry(contents); return; } StackEntryKind::Figure(ctx) => { let tag = Tag::Figure(ctx.alt).with_bbox(ctx.bbox.get()); - TagNode::group(tag, entry.nodes) + TagNode::group(tag, contents) } StackEntryKind::Formula(ctx) => { let tag = Tag::Formula(ctx.alt).with_bbox(ctx.bbox.get()); - TagNode::group(tag, entry.nodes) + TagNode::group(tag, contents) } StackEntryKind::Link(_, _) => { - let mut node = TagNode::group(Tag::Link, entry.nodes); + let mut node = TagNode::group(Tag::Link, contents); // Wrap link in reference tag if inside an outline entry. if gc.tags.stack.parent_outline_entry().is_some() { - node = TagNode::group(Tag::Reference, vec![node]); + node = TagNode::virtual_group(Tag::Reference, vec![node]); } node } StackEntryKind::FootnoteRef(decl_loc) => { // transparently insert all children. - gc.tags.extend(entry.nodes); + gc.tags.extend(contents.nodes); let ctx = gc.tags.footnotes.entry(decl_loc).or_insert(FootnoteCtx::new()); @@ -395,19 +403,18 @@ fn pop_stack(gc: &mut GlobalContext, entry: StackEntry) { StackEntryKind::FootnoteEntry(footnote_loc) => { // Store footnotes separately so they can be inserted directly after // the footnote reference in the reading order. - let tag = TagNode::group(Tag::Note, entry.nodes); + let tag = TagNode::group(Tag::Note, contents); let ctx = gc.tags.footnotes.entry(footnote_loc).or_insert(FootnoteCtx::new()); ctx.entry = Some(tag); return; } - StackEntryKind::CodeBlock => TagNode::group( - Tag::Code.with_placement(Some(kt::Placement::Block)), - entry.nodes, - ), + StackEntryKind::CodeBlock => { + TagNode::group(Tag::Code.with_placement(Some(kt::Placement::Block)), contents) + } StackEntryKind::CodeBlockLine => { // If the raw element is a block, wrap each line in a BLSE, so the // individual lines are properly wrapped and indented when reflowed. - TagNode::group(Tag::P, entry.nodes) + TagNode::group(Tag::P, contents) } }; @@ -479,6 +486,8 @@ pub fn update_bbox( } pub struct Tags { + /// The language of the first text item that has been encountered. + pub doc_lang: Option, /// The intermediary stack of nested tag groups. pub stack: TagStack, /// A list of placeholders corresponding to a [`TagNode::Placeholder`]. @@ -502,6 +511,7 @@ pub struct Tags { impl Tags { pub fn new() -> Self { Self { + doc_lang: None, stack: TagStack::new(), placeholders: Placeholders(Vec::new()), footnotes: FxHashMap::default(), @@ -540,15 +550,37 @@ impl Tags { TagTree::from(children) } + /// Try to set the language of a parent tag, or the entire document. + /// If the language couldn't be set and is different from the existing one, + /// this will return `Some`, and the language should be specified on the + /// marked content directly. + pub fn try_set_lang(&mut self, lang: Lang) -> Option { + // Discard languages within artifacts. + if self.in_artifact.is_some() { + return None; + } + if self.doc_lang.is_none_or(|l| l == lang) { + self.doc_lang = Some(lang); + return None; + } + if let Some(last) = self.stack.last_mut() + && last.lang.is_none_or(|l| l == lang) + { + last.lang = Some(lang); + return None; + } + Some(lang) + } + /// Resolves [`Placeholder`] nodes. fn resolve_node(&mut self, node: TagNode) -> Node { match node { - TagNode::Group(tag, nodes) => { + TagNode::Group(TagGroup { tag, nodes }) => { let children = nodes .into_iter() .map(|node| self.resolve_node(node)) .collect::>(); - Node::Group(TagGroup::with_children(tag, children)) + Node::Group(krilla::tagging::TagGroup::with_children(tag, children)) } TagNode::Leaf(identifier) => Node::Leaf(identifier), TagNode::Placeholder(placeholder) => self.placeholders.take(placeholder), @@ -731,6 +763,7 @@ pub struct LinkId(u32); pub struct StackEntry { pub loc: Location, pub span: Span, + pub lang: Option, pub kind: StackEntryKind, pub nodes: Vec, } @@ -975,7 +1008,7 @@ impl BBoxCtx { #[derive(Debug, Clone, PartialEq)] pub enum TagNode { - Group(TagKind, Vec), + Group(TagGroup), Leaf(Identifier), /// Allows inserting a placeholder into the tag tree. /// Currently used for [`krilla::page::Page::add_tagged_annotation`]. @@ -984,11 +1017,40 @@ pub enum TagNode { } impl TagNode { - pub fn group(tag: impl Into, children: Vec) -> Self { - TagNode::Group(tag.into(), children) + pub fn group(tag: impl Into, contents: GroupContents) -> Self { + let lang = contents.lang.map(|l| l.as_str().to_string()); + let tag = tag + .into() + .with_lang(lang) + .with_location(Some(contents.span.into_raw())); + TagNode::Group(TagGroup { tag, nodes: contents.nodes }) + } + + /// A tag group not directly related to a typst element, generated to + /// accomodate the tag structure. + pub fn virtual_group(tag: impl Into, nodes: Vec) -> Self { + let tag = tag.into(); + TagNode::Group(TagGroup { tag, nodes }) + } + + pub fn empty_group(tag: impl Into) -> Self { + Self::virtual_group(tag, Vec::new()) } } +#[derive(Debug, Clone, PartialEq)] +pub struct TagGroup { + tag: TagKind, + nodes: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct GroupContents { + span: Span, + lang: Option, + nodes: Vec, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Placeholder(usize); diff --git a/crates/typst-pdf/src/tags/outline.rs b/crates/typst-pdf/src/tags/outline.rs index 1f7205bf2c..d96d826fc9 100644 --- a/crates/typst-pdf/src/tags/outline.rs +++ b/crates/typst-pdf/src/tags/outline.rs @@ -2,7 +2,7 @@ use krilla::tagging::Tag; use typst_library::foundations::Packed; use typst_library::model::OutlineEntry; -use crate::tags::TagNode; +use crate::tags::{GroupContents, TagNode}; #[derive(Clone, Debug)] pub struct OutlineCtx { @@ -18,7 +18,7 @@ impl OutlineCtx { &mut self, outline_nodes: &mut Vec, entry: Packed, - nodes: Vec, + contents: GroupContents, ) { let expected_len = entry.level.get() - 1; if self.stack.len() < expected_len { @@ -29,7 +29,7 @@ impl OutlineCtx { } } - let section_entry = TagNode::group(Tag::TOCI, nodes); + let section_entry = TagNode::group(Tag::TOCI, contents); self.push(outline_nodes, section_entry); } @@ -45,11 +45,11 @@ impl OutlineCtx { } } - pub fn build_outline(mut self, mut outline_nodes: Vec) -> TagNode { + pub fn build_outline(mut self, mut contents: GroupContents) -> TagNode { while !self.stack.is_empty() { - self.finish_section(&mut outline_nodes); + self.finish_section(&mut contents.nodes); } - TagNode::group(Tag::TOC, outline_nodes) + TagNode::group(Tag::TOC, contents) } } @@ -68,6 +68,6 @@ impl OutlineSection { } fn into_tag(self) -> TagNode { - TagNode::group(Tag::TOC, self.entries) + TagNode::virtual_group(Tag::TOC, self.entries) } } diff --git a/crates/typst-pdf/src/tags/table.rs b/crates/typst-pdf/src/tags/table.rs index 6b80e55d94..59642fecfe 100644 --- a/crates/typst-pdf/src/tags/table.rs +++ b/crates/typst-pdf/src/tags/table.rs @@ -2,15 +2,14 @@ use std::io::Write as _; use std::num::NonZeroU32; use az::SaturatingAs; -use krilla::tagging::{Tag, TagId}; +use krilla::tagging::{Tag, TagId, TagKind}; use smallvec::SmallVec; use typst_library::foundations::{Packed, Smart}; use typst_library::model::TableCell; use typst_library::pdf::{TableCellKind, TableHeaderScope}; -use typst_syntax::Span; use crate::tags::util::PropertyValCopied; -use crate::tags::{BBoxCtx, TableId, TagNode}; +use crate::tags::{BBoxCtx, GroupContents, TableId, TagNode}; #[derive(Clone, Debug)] pub struct TableCtx { @@ -65,7 +64,7 @@ impl TableCtx { } } - pub fn insert(&mut self, cell: &Packed, nodes: Vec) { + pub fn insert(&mut self, cell: &Packed, contents: GroupContents) { let x = cell.x.val().unwrap_or_else(|| unreachable!()); let y = cell.y.val().unwrap_or_else(|| unreachable!()); let rowspan = cell.rowspan.val(); @@ -99,16 +98,15 @@ impl TableCtx { colspan: colspan.try_into().unwrap_or(NonZeroU32::MAX), kind, headers: SmallVec::new(), - nodes, - span: cell.span(), + contents, }); } - pub fn build_table(mut self, mut nodes: Vec) -> TagNode { + pub fn build_table(mut self, mut contents: GroupContents) -> TagNode { // Table layouting ensures that there are no overlapping cells, and that // any gaps left by the user are filled with empty cells. if self.rows.is_empty() { - return TagNode::group(Tag::Table.with_summary(self.summary), nodes); + return TagNode::group(Tag::Table.with_summary(self.summary), contents); } let height = self.rows.len(); let width = self.rows[0].len(); @@ -175,7 +173,7 @@ impl TableCtx { let cell = cell.into_cell()?; let rowspan = (cell.rowspan.get() != 1).then_some(cell.rowspan); let colspan = (cell.colspan.get() != 1).then_some(cell.colspan); - let tag = match cell.unwrap_kind() { + let tag: TagKind = match cell.unwrap_kind() { TableCellKind::Header(_, scope) => { let id = table_cell_id(self.id, cell.x, cell.y); let scope = table_header_scope(scope); @@ -184,37 +182,35 @@ impl TableCtx { .with_headers(Some(cell.headers)) .with_row_span(rowspan) .with_col_span(colspan) - .with_location(Some(cell.span.into_raw())) .into() } TableCellKind::Footer | TableCellKind::Data => Tag::TD .with_headers(Some(cell.headers)) .with_row_span(rowspan) .with_col_span(colspan) - .with_location(Some(cell.span.into_raw())) .into(), }; - - Some(TagNode::Group(tag, cell.nodes)) + Some(TagNode::group(tag, cell.contents)) }) .collect(); - let row = TagNode::group(Tag::TR, row_nodes); + let row = TagNode::virtual_group(Tag::TR, row_nodes); // Push the `TR` tags directly. if !gen_row_groups { - nodes.push(row); + contents.nodes.push(row); continue; } // Generate row groups. if !should_group_rows(chunk_kind, row_kind) { - let tag = match chunk_kind { + let tag: TagKind = match chunk_kind { TableCellKind::Header(..) => Tag::THead.into(), TableCellKind::Footer => Tag::TFoot.into(), TableCellKind::Data => Tag::TBody.into(), }; - nodes.push(TagNode::Group(tag, std::mem::take(&mut row_chunk))); + let chunk_nodes = std::mem::take(&mut row_chunk); + contents.nodes.push(TagNode::virtual_group(tag, chunk_nodes)); chunk_kind = row_kind; } @@ -222,16 +218,16 @@ impl TableCtx { } if !row_chunk.is_empty() { - let tag = match chunk_kind { + let tag: TagKind = match chunk_kind { TableCellKind::Header(..) => Tag::THead.into(), TableCellKind::Footer => Tag::TFoot.into(), TableCellKind::Data => Tag::TBody.into(), }; - nodes.push(TagNode::Group(tag, row_chunk)); + contents.nodes.push(TagNode::virtual_group(tag, row_chunk)); } let tag = Tag::Table.with_summary(self.summary).with_bbox(self.bbox.get()); - TagNode::group(tag, nodes) + TagNode::group(tag, contents) } fn resolve_cell_headers( @@ -296,8 +292,7 @@ struct TableCtxCell { colspan: NonZeroU32, kind: Smart, headers: SmallVec<[TagId; 1]>, - nodes: Vec, - span: Span, + contents: GroupContents, } impl TableCtxCell { diff --git a/crates/typst-pdf/src/text.rs b/crates/typst-pdf/src/text.rs index 293a6ff833..f5f8b27823 100644 --- a/crates/typst-pdf/src/text.rs +++ b/crates/typst-pdf/src/text.rs @@ -7,7 +7,7 @@ use krilla::tagging::SpanTag; use krilla::text::GlyphId; use typst_library::diag::{SourceResult, bail}; use typst_library::layout::Size; -use typst_library::text::{Font, Glyph, TextItem}; +use typst_library::text::{Font, Glyph, Lang, TextItem}; use typst_library::visualize::FillRule; use typst_syntax::Span; @@ -22,11 +22,11 @@ pub(crate) fn handle_text( surface: &mut Surface, gc: &mut GlobalContext, ) -> SourceResult<()> { - *gc.languages.entry(t.lang).or_insert(0) += t.glyphs.len(); - + let lang = gc.tags.try_set_lang(t.lang); + let lang = lang.as_ref().map(Lang::as_str); tags::update_bbox(gc, fc, || t.bbox()); - let mut handle = tags::start_span(gc, surface, SpanTag::empty()); + let mut handle = tags::start_span(gc, surface, SpanTag::empty().with_lang(lang)); let surface = handle.surface(); let font = convert_font(gc, t.font.clone())?; From 49d01dbe5b8d412451471ed4b1f41f71aab8d458 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Tue, 29 Jul 2025 11:20:46 +0200 Subject: [PATCH 416/558] Add support for snapshot testing of PDF tag trees --- crates/typst-pdf/src/convert.rs | 58 +++++++++++++---- crates/typst-pdf/src/lib.rs | 6 ++ tests/src/collect.rs | 2 + tests/src/run.rs | 107 ++++++++++++++++++++++++++++---- tests/src/tests.rs | 2 +- 5 files changed, 152 insertions(+), 23 deletions(-) diff --git a/crates/typst-pdf/src/convert.rs b/crates/typst-pdf/src/convert.rs index 0d2aeb71c7..79c1d15c78 100644 --- a/crates/typst-pdf/src/convert.rs +++ b/crates/typst-pdf/src/convert.rs @@ -7,6 +7,7 @@ use krilla::geom::PathBuilder; use krilla::page::{PageLabel, PageSettings}; use krilla::pdf::PdfError; use krilla::surface::Surface; +use krilla::tagging::fmt::Output; use krilla::{Document, SerializeSettings}; use krilla_svg::render_svg_glyph; use rustc_hash::{FxHashMap, FxHashSet}; @@ -17,7 +18,7 @@ use typst_library::layout::{ Frame, FrameItem, GroupItem, PagedDocument, Size, Transform, }; use typst_library::model::HeadingElem; -use typst_library::text::Font; +use typst_library::text::{Font, Lang}; use typst_library::visualize::{Geometry, Paint}; use typst_syntax::Span; @@ -38,6 +39,48 @@ pub fn convert( typst_document: &PagedDocument, options: &PdfOptions, ) -> SourceResult> { + let (mut document, mut gc) = setup(typst_document, options); + + convert_pages(&mut gc, &mut document)?; + attach_files(&gc, &mut document)?; + + document.set_outline(build_outline(&gc)); + document.set_metadata(build_metadata(&gc)); + document.set_tag_tree(gc.tags.build_tree()); + + finish(document, gc, options.standards.config) +} + +pub fn tag_tree( + typst_document: &PagedDocument, + options: &PdfOptions, +) -> SourceResult { + let (mut document, mut gc) = setup(typst_document, options); + convert_pages(&mut gc, &mut document)?; + attach_files(&gc, &mut document)?; + + let tree = gc.tags.build_tree(); + let mut output = String::new(); + if let Some(lang) = gc.tags.doc_lang + && lang != Lang::ENGLISH + { + output = format!("lang: \"{}\"\n---\n", lang.as_str()); + } + tree.output(&mut output).unwrap(); + + document.set_outline(build_outline(&gc)); + document.set_metadata(build_metadata(&gc)); + document.set_tag_tree(tree); + + finish(document, gc, options.standards.config)?; + + Ok(output) +} + +fn setup<'a>( + typst_document: &'a PagedDocument, + options: &'a PdfOptions, +) -> (Document, GlobalContext<'a>) { let settings = SerializeSettings { compress_content_streams: true, no_device_cs: true, @@ -49,26 +92,19 @@ pub fn convert( render_svg_glyph_fn: render_svg_glyph, }; - let mut document = Document::new_with(settings); + let document = Document::new_with(settings); let page_index_converter = PageIndexConverter::new(typst_document, options); let named_destinations = collect_named_destinations(typst_document, &page_index_converter); - let mut gc = GlobalContext::new( + let gc = GlobalContext::new( typst_document, options, named_destinations, page_index_converter, ); - convert_pages(&mut gc, &mut document)?; - attach_files(&gc, &mut document)?; - - document.set_outline(build_outline(&gc)); - document.set_metadata(build_metadata(&gc)); - document.set_tag_tree(gc.tags.build_tree()); - - finish(document, gc, options.standards.config) + (document, gc) } fn convert_pages(gc: &mut GlobalContext, document: &mut Document) -> SourceResult<()> { diff --git a/crates/typst-pdf/src/lib.rs b/crates/typst-pdf/src/lib.rs index c2829ed045..143c9ca275 100644 --- a/crates/typst-pdf/src/lib.rs +++ b/crates/typst-pdf/src/lib.rs @@ -31,6 +31,12 @@ pub fn pdf(document: &PagedDocument, options: &PdfOptions) -> SourceResult SourceResult { + convert::tag_tree(document, options) +} + /// Settings for PDF export. #[derive(Debug, Default)] pub struct PdfOptions<'a> { diff --git a/tests/src/collect.rs b/tests/src/collect.rs index e1274adb0a..b1795dba39 100644 --- a/tests/src/collect.rs +++ b/tests/src/collect.rs @@ -66,6 +66,7 @@ pub enum Attr { Html, Render, Large, + Pdftags, } /// The size of a file. @@ -304,6 +305,7 @@ impl<'a> Parser<'a> { "large" => Attr::Large, "html" => Attr::Html, "render" => Attr::Render, + "pdftags" => Attr::Pdftags, found => { self.error(format!( "expected attribute or closing ---, found `{found}`" diff --git a/tests/src/run.rs b/tests/src/run.rs index 913f9dd0b3..715a85b747 100644 --- a/tests/src/run.rs +++ b/tests/src/run.rs @@ -7,9 +7,9 @@ use tiny_skia as sk; use typst::diag::{SourceDiagnostic, SourceResult, Warned}; use typst::layout::{Abs, Frame, FrameItem, PagedDocument, Transform}; use typst::visualize::Color; -use typst::{Document, WorldExt}; +use typst::{World, WorldExt}; use typst_html::HtmlDocument; -use typst_pdf::PdfOptions; +use typst_pdf::{PdfOptions, PdfStandard, PdfStandards}; use typst_syntax::{FileId, Lines}; use crate::collect::{Attr, FileSize, NoteKind, Test}; @@ -65,13 +65,17 @@ impl<'a> Runner<'a> { } let html = self.test.attrs.contains(&Attr::Html); - let render = !html || self.test.attrs.contains(&Attr::Render); + let pdftags = self.test.attrs.contains(&Attr::Pdftags); + let render = !html && !pdftags || self.test.attrs.contains(&Attr::Render); if render { self.run_test::(); } if html { self.run_test::(); } + if pdftags { + self.run_test::(); + } self.handle_not_emitted(); self.handle_not_annotated(); @@ -81,7 +85,7 @@ impl<'a> Runner<'a> { /// Run test specific to document format. fn run_test(&mut self) { - let Warned { output, warnings } = typst::compile(&self.world); + let Warned { output, warnings } = D::compile(&self.world); let (doc, mut errors) = match output { Ok(doc) => (Some(doc), eco_vec![]), Err(errors) => (None, errors), @@ -89,7 +93,7 @@ impl<'a> Runner<'a> { D::check_custom(self, doc.as_ref()); - let output = doc.and_then(|doc: D| match doc.make_live() { + let output = doc.and_then(|doc| match doc.make_live() { Ok(live) => Some((doc, live)), Err(list) => { errors.extend(list); @@ -166,7 +170,18 @@ impl<'a> Runner<'a> { } // Render and save live version. - document.save_live(&self.test.name, &live); + if let Err(errors) = document.save_live(&self.test.name, &live) { + log!(self, "failed to save live version:"); + for e in errors { + if let Some(file) = e.span.id() { + let range = self.world.range(e.span); + let diag_range = self.format_range(file, &range); + log!(self, " Error: {diag_range} {}", e.message); + } else { + log!(self, " Error: {}", e.message); + } + } + } // Compare against reference output if available. // Test that is ok doesn't need to be updated. @@ -357,13 +372,16 @@ impl<'a> Runner<'a> { } /// An output type we can test. -trait OutputType: Document { +trait OutputType: Sized { /// The type that represents live output. type Live; /// The path at which the live output is stored. fn live_path(name: &str) -> PathBuf; + /// Compiles the Typst test into this output type. + fn compile(world: &dyn World) -> Warned>; + /// The path at which the reference output is stored. fn ref_path(name: &str) -> PathBuf; @@ -376,7 +394,7 @@ trait OutputType: Document { fn make_live(&self) -> SourceResult; /// Saves the live output. - fn save_live(&self, name: &str, live: &Self::Live); + fn save_live(&self, name: &str, live: &Self::Live) -> SourceResult<()>; /// Produces the reference output from the live output. fn make_ref(live: Self::Live) -> Vec; @@ -400,6 +418,10 @@ impl OutputType for PagedDocument { format!("{}/{}.png", crate::REF_PATH, name).into() } + fn compile(world: &dyn World) -> Warned> { + typst::compile(world) + } + fn is_skippable(&self) -> Result { /// Whether rendering of a frame can be skipped. fn skippable_frame(frame: &Frame) -> bool { @@ -424,7 +446,7 @@ impl OutputType for PagedDocument { Ok(render(self, 1.0)) } - fn save_live(&self, name: &str, live: &Self::Live) { + fn save_live(&self, name: &str, live: &Self::Live) -> SourceResult<()> { // Save live version, possibly rerendering if different scale is // requested. let mut pixmap_live = live; @@ -440,7 +462,7 @@ impl OutputType for PagedDocument { // Write PDF if requested. if crate::ARGS.pdf() { let pdf_path = format!("{}/pdf/{}.pdf", crate::STORE_PATH, name); - let pdf = typst_pdf::pdf(self, &PdfOptions::default()).unwrap(); + let pdf = typst_pdf::pdf(self, &PdfOptions::default())?; std::fs::write(pdf_path, pdf).unwrap(); } @@ -450,6 +472,8 @@ impl OutputType for PagedDocument { let svg = typst_svg::svg_merged(self, Abs::pt(5.0)); std::fs::write(svg_path, svg).unwrap(); } + + Ok(()) } fn make_ref(live: Self::Live) -> Vec { @@ -485,12 +509,17 @@ impl OutputType for HtmlDocument { format!("{}/html/{}.html", crate::REF_PATH, name).into() } + fn compile(world: &dyn World) -> Warned> { + typst::compile(world) + } + fn make_live(&self) -> SourceResult { typst_html::html(self) } - fn save_live(&self, name: &str, live: &Self::Live) { + fn save_live(&self, name: &str, live: &Self::Live) -> SourceResult<()> { std::fs::write(Self::live_path(name), live).unwrap(); + Ok(()) } fn make_ref(live: Self::Live) -> Vec { @@ -502,6 +531,62 @@ impl OutputType for HtmlDocument { } } +struct Pdftags(String); + +impl OutputType for Pdftags { + type Live = String; + + fn live_path(name: &str) -> PathBuf { + format!("{}/pdftags/{}.yml", crate::STORE_PATH, name).into() + } + + fn ref_path(name: &str) -> PathBuf { + format!("{}/pdftags/{}.yml", crate::REF_PATH, name).into() + } + + fn compile(world: &dyn World) -> Warned> { + let Warned { output, warnings } = typst::compile::(world); + let mut doc = match output { + Ok(doc) => doc, + Err(errors) => return Warned { output: Err(errors), warnings }, + }; + if doc.info.title.is_none() { + doc.info.title = Some("".into()); + } + + let options = PdfOptions { + standards: PdfStandards::new(&[PdfStandard::Ua_1]).unwrap(), + ..Default::default() + }; + let output = typst_pdf::pdf_tags(&doc, &options).map(Pdftags); + Warned { warnings, output } + } + + fn is_skippable(&self) -> Result { + Ok(self.0.is_empty()) + } + + fn make_live(&self) -> SourceResult { + Ok(self.0.clone()) + } + + fn save_live(&self, name: &str, live: &Self::Live) -> SourceResult<()> { + std::fs::write(Self::live_path(name), live).unwrap(); + Ok(()) + } + + fn make_ref(live: Self::Live) -> Vec { + live.into_bytes() + } + + fn matches(live: &Self::Live, ref_data: &[u8]) -> bool { + // Compare lines with newlines stripped, since on windows git might + // check out the ref files with CRLF line endings. + let ref_str = std::str::from_utf8(ref_data).unwrap(); + ref_str.lines().eq(live.lines()) + } +} + /// Draw all frames into one image with padding in between. fn render(document: &PagedDocument, pixel_per_pt: f32) -> sk::Pixmap { for page in &document.pages { diff --git a/tests/src/tests.rs b/tests/src/tests.rs index 0ed2fa4693..2ddf415a13 100644 --- a/tests/src/tests.rs +++ b/tests/src/tests.rs @@ -64,7 +64,7 @@ fn setup() { std::env::set_current_dir(workspace_dir).unwrap(); // Create the storage. - for ext in ["render", "html", "pdf", "svg"] { + for ext in ["render", "html", "pdf", "pdftags", "svg"] { std::fs::create_dir_all(Path::new(STORE_PATH).join(ext)).unwrap(); } From 2bb7f5744ba2f7847baf46d5a1ad01b5f9f7d4bb Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Wed, 30 Jul 2025 10:47:41 +0200 Subject: [PATCH 417/558] Add `pdftags` snapshot tests --- crates/typst-layout/src/rules.rs | 4 +- crates/typst-pdf/src/tags/table.rs | 263 ------------------ tests/ref/pdftags/bibliography-basic.yml | 61 ++++ .../pdftags/enum-number-override-nested.yml | 44 +++ .../figure-tags-equation-with-caption.yml | 33 +++ tests/ref/pdftags/figure-tags-image-basic.yml | 10 + .../figure-tags-image-figure-with-caption.yml | 15 + tests/ref/pdftags/grid-headers.yml | 254 +++++++++++++++++ tests/ref/pdftags/heading-basic.yml | 16 ++ tests/ref/pdftags/heading-offset.yml | 30 ++ tests/ref/pdftags/lang-tags-pars-basic.yml | 13 + tests/ref/pdftags/lang-tags-propagation.yml | 46 +++ tests/ref/pdftags/link-basic.yml | 36 +++ .../ref/pdftags/link-tags-contact-prefix.yml | 12 + .../link-tags-heading-with-numbering.yml | 11 + .../link-tags-heading-without-numbering.yml | 10 + .../link-tags-non-refable-location.yml | 9 + tests/ref/pdftags/link-tags-position.yml | 6 + tests/ref/pdftags/list-indent-specifics.yml | 40 +++ tests/ref/pdftags/list-tags-basic.yml | 74 +++++ ...ist-tags-complex-item-with-nested-list.yml | 67 +++++ .../list-tags-complex-item-with-sub-list.yml | 67 +++++ .../ref/pdftags/list-tags-mixed-with-enum.yml | 74 +++++ tests/ref/pdftags/outline-spacing.yml | 80 ++++++ tests/ref/pdftags/par-basic.yml | 23 ++ tests/ref/pdftags/quote-dir-align.yml | 12 + tests/ref/pdftags/quote-dir-author-pos.yml | 18 ++ tests/ref/pdftags/raw-tags-basic-block.yml | 43 +++ tests/ref/pdftags/raw-tags-basic-inline.yml | 13 + tests/ref/pdftags/table-tags-basic.yml | 68 +++++ .../table-tags-column-and-row-header.yml | 68 +++++ .../ref/pdftags/table-tags-missing-cells.yml | 115 ++++++++ tests/ref/pdftags/terms-constructor.yml | 21 ++ tests/ref/pdftags/text-lang.yml | 8 + tests/suite/layout/grid/headers.typ | 2 +- tests/suite/model/bibliography.typ | 2 +- tests/suite/model/enum.typ | 2 +- tests/suite/model/figure.typ | 4 +- tests/suite/model/heading.typ | 4 +- tests/suite/model/link.typ | 2 +- tests/suite/model/list.typ | 2 +- tests/suite/model/outline.typ | 2 +- tests/suite/model/par.typ | 2 +- tests/suite/model/quote.typ | 4 +- tests/suite/model/terms.typ | 2 +- tests/suite/pdftags/figure.typ | 23 ++ tests/suite/pdftags/lang.typ | 19 ++ tests/suite/pdftags/link.typ | 23 ++ tests/suite/pdftags/list.typ | 34 +++ tests/suite/pdftags/raw.typ | 11 + tests/suite/pdftags/table.typ | 37 +++ tests/suite/text/lang.typ | 2 +- 52 files changed, 1561 insertions(+), 280 deletions(-) create mode 100644 tests/ref/pdftags/bibliography-basic.yml create mode 100644 tests/ref/pdftags/enum-number-override-nested.yml create mode 100644 tests/ref/pdftags/figure-tags-equation-with-caption.yml create mode 100644 tests/ref/pdftags/figure-tags-image-basic.yml create mode 100644 tests/ref/pdftags/figure-tags-image-figure-with-caption.yml create mode 100644 tests/ref/pdftags/grid-headers.yml create mode 100644 tests/ref/pdftags/heading-basic.yml create mode 100644 tests/ref/pdftags/heading-offset.yml create mode 100644 tests/ref/pdftags/lang-tags-pars-basic.yml create mode 100644 tests/ref/pdftags/lang-tags-propagation.yml create mode 100644 tests/ref/pdftags/link-basic.yml create mode 100644 tests/ref/pdftags/link-tags-contact-prefix.yml create mode 100644 tests/ref/pdftags/link-tags-heading-with-numbering.yml create mode 100644 tests/ref/pdftags/link-tags-heading-without-numbering.yml create mode 100644 tests/ref/pdftags/link-tags-non-refable-location.yml create mode 100644 tests/ref/pdftags/link-tags-position.yml create mode 100644 tests/ref/pdftags/list-indent-specifics.yml create mode 100644 tests/ref/pdftags/list-tags-basic.yml create mode 100644 tests/ref/pdftags/list-tags-complex-item-with-nested-list.yml create mode 100644 tests/ref/pdftags/list-tags-complex-item-with-sub-list.yml create mode 100644 tests/ref/pdftags/list-tags-mixed-with-enum.yml create mode 100644 tests/ref/pdftags/outline-spacing.yml create mode 100644 tests/ref/pdftags/par-basic.yml create mode 100644 tests/ref/pdftags/quote-dir-align.yml create mode 100644 tests/ref/pdftags/quote-dir-author-pos.yml create mode 100644 tests/ref/pdftags/raw-tags-basic-block.yml create mode 100644 tests/ref/pdftags/raw-tags-basic-inline.yml create mode 100644 tests/ref/pdftags/table-tags-basic.yml create mode 100644 tests/ref/pdftags/table-tags-column-and-row-header.yml create mode 100644 tests/ref/pdftags/table-tags-missing-cells.yml create mode 100644 tests/ref/pdftags/terms-constructor.yml create mode 100644 tests/ref/pdftags/text-lang.yml create mode 100644 tests/suite/pdftags/figure.typ create mode 100644 tests/suite/pdftags/lang.typ create mode 100644 tests/suite/pdftags/link.typ create mode 100644 tests/suite/pdftags/list.typ create mode 100644 tests/suite/pdftags/raw.typ create mode 100644 tests/suite/pdftags/table.typ diff --git a/crates/typst-layout/src/rules.rs b/crates/typst-layout/src/rules.rs index b2d6031d53..cf2348c96d 100644 --- a/crates/typst-layout/src/rules.rs +++ b/crates/typst-layout/src/rules.rs @@ -398,8 +398,8 @@ const FOOTNOTE_RULE: ShowFn = |elem, engine, styles| { let span = elem.span(); let (dest, num) = elem.realize(engine, styles)?; let alt = FootnoteElem::alt_text(styles, &num.plain_text()); - let sup = PdfMarkerTag::Label(SuperElem::new(num).pack().spanned(span)); - let note = HElem::hole().clone() + sup.linked(dest, Some(alt)); + let sup = SuperElem::new(num).pack().spanned(span).linked(dest, Some(alt)); + let note = HElem::hole().clone() + PdfMarkerTag::Label(sup); let decl_loc = elem.declaration_location(engine).unwrap(); Ok(PdfMarkerTag::FootnoteRef(decl_loc, note)) }; diff --git a/crates/typst-pdf/src/tags/table.rs b/crates/typst-pdf/src/tags/table.rs index 59642fecfe..aff5b9f2ab 100644 --- a/crates/typst-pdf/src/tags/table.rs +++ b/crates/typst-pdf/src/tags/table.rs @@ -323,266 +323,3 @@ fn table_header_scope(scope: TableHeaderScope) -> krilla::tagging::TableHeaderSc TableHeaderScope::Row => krilla::tagging::TableHeaderScope::Row, } } - -#[cfg(test)] -mod tests { - use typst_library::foundations::Content; - - use super::*; - - #[track_caller] - fn test(table: TableCtx, exp_tag: TagNode) { - let tag = table.build_table(Vec::new()); - assert_eq!(exp_tag, tag); - } - - #[track_caller] - fn table(cells: [TableCell; SIZE]) -> TableCtx { - let mut table = TableCtx::new(TableId(324), Some("summary".into())); - for cell in cells { - table.insert(&Packed::new(cell), Vec::new()); - } - table - } - - #[track_caller] - fn header_cell( - (x, y): (usize, usize), - level: u32, - scope: TableHeaderScope, - ) -> TableCell { - TableCell::new(Content::default()) - .with_x(Smart::Custom(x)) - .with_y(Smart::Custom(y)) - .with_kind(Smart::Custom(TableCellKind::Header( - NonZeroU32::new(level).unwrap(), - scope, - ))) - } - - #[track_caller] - fn footer_cell(x: usize, y: usize) -> TableCell { - TableCell::new(Content::default()) - .with_x(Smart::Custom(x)) - .with_y(Smart::Custom(y)) - .with_kind(Smart::Custom(TableCellKind::Footer)) - } - - fn cell(x: usize, y: usize) -> TableCell { - TableCell::new(Content::default()) - .with_x(Smart::Custom(x)) - .with_y(Smart::Custom(y)) - .with_kind(Smart::Custom(TableCellKind::Data)) - } - - fn empty_cell(x: usize, y: usize) -> TableCell { - TableCell::new(Content::default()) - .with_x(Smart::Custom(x)) - .with_y(Smart::Custom(y)) - .with_kind(Smart::Auto) - } - - fn table_tag(nodes: [TagNode; SIZE]) -> TagNode { - let tag = Tag::Table.with_summary(Some("summary".into())); - TagNode::group(tag, nodes.into()) - } - - fn thead(nodes: [TagNode; SIZE]) -> TagNode { - TagNode::group(Tag::THead, nodes.into()) - } - - fn tbody(nodes: [TagNode; SIZE]) -> TagNode { - TagNode::group(Tag::TBody, nodes.into()) - } - - fn tfoot(nodes: [TagNode; SIZE]) -> TagNode { - TagNode::group(Tag::TFoot, nodes.into()) - } - - fn trow(nodes: [TagNode; SIZE]) -> TagNode { - TagNode::group(Tag::TR, nodes.into()) - } - - fn th( - (x, y): (u32, u32), - scope: TableHeaderScope, - headers: [(u32, u32); SIZE], - ) -> TagNode { - let scope = table_header_scope(scope); - let id = table_cell_id(TableId(324), x, y); - let ids = headers.map(|(x, y)| table_cell_id(TableId(324), x, y)).into_iter(); - TagNode::group( - Tag::TH(scope) - .with_id(Some(id)) - .with_headers(Some(ids)) - .with_location(Some(Span::detached().into_raw())), - Vec::new(), - ) - } - - fn td(headers: [(u32, u32); SIZE]) -> TagNode { - let ids = headers.map(|(x, y)| table_cell_id(TableId(324), x, y)).into_iter(); - TagNode::group( - Tag::TD - .with_headers(Some(ids)) - .with_location(Some(Span::detached().into_raw())), - Vec::new(), - ) - } - - #[test] - fn simple_table() { - #[rustfmt::skip] - let table = table([ - header_cell((0, 0), 1, TableHeaderScope::Column), - header_cell((1, 0), 1, TableHeaderScope::Column), - header_cell((2, 0), 1, TableHeaderScope::Column), - - cell(0, 1), - cell(1, 1), - cell(2, 1), - - cell(0, 2), - cell(1, 2), - cell(2, 2), - ]); - - #[rustfmt::skip] - let tag = table_tag([ - thead([trow([ - th((0, 0), TableHeaderScope::Column, []), - th((1, 0), TableHeaderScope::Column, []), - th((2, 0), TableHeaderScope::Column, []), - ])]), - tbody([ - trow([ - td([(0, 0)]), - td([(1, 0)]), - td([(2, 0)]), - ]), - trow([ - td([(0, 0)]), - td([(1, 0)]), - td([(2, 0)]), - ]), - ]), - ]); - - test(table, tag); - } - - #[test] - fn header_row_and_column() { - #[rustfmt::skip] - let table = table([ - header_cell((0, 0), 1, TableHeaderScope::Column), - header_cell((1, 0), 1, TableHeaderScope::Column), - header_cell((2, 0), 1, TableHeaderScope::Column), - - header_cell((0, 1), 1, TableHeaderScope::Row), - cell(1, 1), - cell(2, 1), - - header_cell((0, 2), 1, TableHeaderScope::Row), - cell(1, 2), - cell(2, 2), - ]); - - #[rustfmt::skip] - let tag = table_tag([ - trow([ - th((0, 0), TableHeaderScope::Column, []), - th((1, 0), TableHeaderScope::Column, []), - th((2, 0), TableHeaderScope::Column, []), - ]), - trow([ - th((0, 1), TableHeaderScope::Row, [(0, 0)]), - td([(1, 0), (0, 1)]), - td([(2, 0), (0, 1)]), - ]), - trow([ - th((0, 2), TableHeaderScope::Row, [(0, 0)]), - td([(1, 0), (0, 2)]), - td([(2, 0), (0, 2)]), - ]), - ]); - - test(table, tag); - } - - #[test] - fn complex_tables() { - #[rustfmt::skip] - let table = table([ - header_cell((0, 0), 1, TableHeaderScope::Column), - header_cell((1, 0), 1, TableHeaderScope::Column), - header_cell((2, 0), 1, TableHeaderScope::Column), - - header_cell((0, 1), 2, TableHeaderScope::Column), - header_cell((1, 1), 2, TableHeaderScope::Column), - header_cell((2, 1), 2, TableHeaderScope::Column), - - cell(0, 2), - empty_cell(1, 2), // the type of empty cells is inferred from the row - cell(2, 2), - - header_cell((0, 3), 2, TableHeaderScope::Column), - header_cell((1, 3), 2, TableHeaderScope::Column), - empty_cell(2, 3), // the type of empty cells is inferred from the row - - cell(0, 4), - cell(1, 4), - empty_cell(2, 4), - - empty_cell(0, 5), // the type of empty cells is inferred from the row - footer_cell(1, 5), - footer_cell(2, 5), - ]); - - #[rustfmt::skip] - let tag = table_tag([ - thead([ - trow([ - th((0, 0), TableHeaderScope::Column, []), - th((1, 0), TableHeaderScope::Column, []), - th((2, 0), TableHeaderScope::Column, []), - ]), - trow([ - th((0, 1), TableHeaderScope::Column, [(0, 0)]), - th((1, 1), TableHeaderScope::Column, [(1, 0)]), - th((2, 1), TableHeaderScope::Column, [(2, 0)]), - ]), - ]), - tbody([ - trow([ - td([(0, 1)]), - td([(1, 1)]), - td([(2, 1)]), - ]), - ]), - thead([ - trow([ - th((0, 3), TableHeaderScope::Column, [(0, 0)]), - th((1, 3), TableHeaderScope::Column, [(1, 0)]), - th((2, 3), TableHeaderScope::Column, [(2, 0)]), - ]), - ]), - tbody([ - trow([ - td([(0, 3)]), - td([(1, 3)]), - td([(2, 3)]), - ]), - ]), - tfoot([ - trow([ - td([(0, 3)]), - td([(1, 3)]), - td([(2, 3)]), - ]), - ]), - ]); - - test(table, tag); - } -} diff --git a/tests/ref/pdftags/bibliography-basic.yml b/tests/ref/pdftags/bibliography-basic.yml new file mode 100644 index 0000000000..bad6b4a5b8 --- /dev/null +++ b/tests/ref/pdftags/bibliography-basic.yml @@ -0,0 +1,61 @@ +- Tag: H1 + /T: "Details" + /K: + - Content: page=0 mcid=0 +- Tag: P + /K: + - Content: page=0 mcid=1 + - Tag: Link + /K: + - Content: page=0 mcid=2 + - Annotation: page=0 index=0 + - Content: page=0 mcid=3 + - Tag: Link + /K: + - Content: page=0 mcid=4 + - Annotation: page=0 index=1 + - Content: page=0 mcid=5 + - Tag: Link + /K: + - Content: page=0 mcid=6 + - Annotation: page=0 index=2 + - Content: page=0 mcid=7 + - Tag: Link + /K: + - Content: page=0 mcid=8 + - Annotation: page=0 index=3 + - Content: page=0 mcid=9 +- Tag: H1 + /T: "Bibliography" + /K: + - Content: page=0 mcid=10 +- Tag: L + /Numbering: Decimal + /K: + - Tag: LI + /K: + - Tag: Lbl + /K: + - Tag: Link + /K: + - Content: page=0 mcid=11 + - Annotation: page=0 index=4 + - Tag: LBody + /K: + - Tag: BibEntry + /K: + - Content: page=0 mcid=12 + - Tag: LI + /K: + - Tag: Lbl + /K: + - Tag: Link + /K: + - Content: page=0 mcid=13 + - Annotation: page=0 index=5 + - Tag: LBody + /K: + - Tag: BibEntry + /K: + - Content: page=0 mcid=14 + - Content: page=0 mcid=15 diff --git a/tests/ref/pdftags/enum-number-override-nested.yml b/tests/ref/pdftags/enum-number-override-nested.yml new file mode 100644 index 0000000000..2b6a31ff62 --- /dev/null +++ b/tests/ref/pdftags/enum-number-override-nested.yml @@ -0,0 +1,44 @@ +- Tag: L + /Numbering: Decimal + /K: + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=0 + - Tag: LBody + /K: + - Tag: P + /K: + - Content: page=0 mcid=1 + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=2 + - Tag: LBody + /K: + - Tag: P + /K: + - Content: page=0 mcid=3 + - Tag: L + /Numbering: Decimal + /K: + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=4 + - Tag: LBody + /K: + - Content: page=0 mcid=5 + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=6 + - Tag: LBody + /K: + - Tag: P + /K: + - Content: page=0 mcid=7 diff --git a/tests/ref/pdftags/figure-tags-equation-with-caption.yml b/tests/ref/pdftags/figure-tags-equation-with-caption.yml new file mode 100644 index 0000000000..a3d5c8dea4 --- /dev/null +++ b/tests/ref/pdftags/figure-tags-equation-with-caption.yml @@ -0,0 +1,33 @@ +- Tag: NonStruct + /K: + - Tag: P + /K: + - Tag: Figure + /Alt: "The Pythagorean theorem: a squared plus b squared is c squared" + /BBox: + page: 0 + left: 33.655 + top: 8.552 + right: 85.743 + bottom: 17.660 + /K: + - Tag: Formula + /Alt: "The Pythagorean theorem: a squared plus b squared is c squared" + /BBox: + page: 0 + left: 33.655 + top: 8.552 + right: 85.743 + bottom: 17.660 + /K: + - Content: page=0 mcid=0 + - Content: page=0 mcid=1 + - Content: page=0 mcid=2 + - Content: page=0 mcid=3 + - Content: page=0 mcid=4 + - Content: page=0 mcid=5 + - Content: page=0 mcid=6 + - Content: page=0 mcid=7 + - Tag: Caption + /K: + - Content: page=0 mcid=8 diff --git a/tests/ref/pdftags/figure-tags-image-basic.yml b/tests/ref/pdftags/figure-tags-image-basic.yml new file mode 100644 index 0000000000..3dbce80500 --- /dev/null +++ b/tests/ref/pdftags/figure-tags-image-basic.yml @@ -0,0 +1,10 @@ +- Tag: Figure + /Alt: "A tiger" + /BBox: + page: 0 + left: 10.000 + top: 10.000 + right: 110.000 + bottom: 75.430 + /K: + - Content: page=0 mcid=0 diff --git a/tests/ref/pdftags/figure-tags-image-figure-with-caption.yml b/tests/ref/pdftags/figure-tags-image-figure-with-caption.yml new file mode 100644 index 0000000000..f19786b00c --- /dev/null +++ b/tests/ref/pdftags/figure-tags-image-figure-with-caption.yml @@ -0,0 +1,15 @@ +- Tag: NonStruct + /K: + - Tag: Figure + /Alt: "A tiger" + /BBox: + page: 0 + left: 10.000 + top: 10.000 + right: 110.000 + bottom: 75.430 + /K: + - Content: page=0 mcid=0 + - Tag: Caption + /K: + - Content: page=0 mcid=1 diff --git a/tests/ref/pdftags/grid-headers.yml b/tests/ref/pdftags/grid-headers.yml new file mode 100644 index 0000000000..786ee46cd6 --- /dev/null +++ b/tests/ref/pdftags/grid-headers.yml @@ -0,0 +1,254 @@ +- Tag: Table + /K: + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Tag: THead + /K: + - Tag: TR + /K: + - Tag: TH + /Id: "U1x0y0" + /Scope: Column + /Headers: [] + /ColSpan: 5 + /K: + - Content: page=0 mcid=0 + - Tag: TR + /K: + - Tag: TH + /Id: "U1x0y1" + /Scope: Column + /Headers: [] + /K: + - Content: page=0 mcid=1 + - Tag: TH + /Id: "U1x1y1" + /Scope: Column + /Headers: [] + /K: + - Content: page=0 mcid=2 + - Tag: TH + /Id: "U1x2y1" + /Scope: Column + /Headers: [] + /K: + - Content: page=0 mcid=3 + - Tag: TH + /Id: "U1x3y1" + /Scope: Column + /Headers: [] + /K: + - Content: page=0 mcid=4 + - Tag: TH + /Id: "U1x4y1" + /Scope: Column + /Headers: [] + /K: + - Content: page=0 mcid=5 + - Tag: TBody + /K: + - Tag: TR + /K: + - Tag: TD + /Headers: ["U1x0y1"] + /K: + - Content: page=0 mcid=6 + - Tag: TD + /Headers: ["U1x1y1"] + /K: + - Content: page=0 mcid=7 + - Tag: TD + /Headers: ["U1x2y1"] + /K: + - Content: page=0 mcid=8 + - Tag: TD + /Headers: ["U1x3y1"] + /K: + - Content: page=0 mcid=9 + - Tag: TD + /Headers: ["U1x4y1"] + /K: + - Content: page=0 mcid=10 + - Tag: TR + /K: + - Tag: TD + /Headers: ["U1x0y1"] + /K: + - Content: page=0 mcid=11 + - Tag: TD + /Headers: ["U1x1y1"] + /K: + - Content: page=0 mcid=12 + - Tag: TD + /Headers: ["U1x2y1"] + /K: + - Content: page=0 mcid=13 + - Tag: TD + /Headers: ["U1x3y1"] + /K: + - Content: page=0 mcid=14 + - Tag: TD + /Headers: ["U1x4y1"] + /K: + - Content: page=0 mcid=15 + - Tag: TR + /K: + - Tag: TD + /Headers: ["U1x0y1"] + /K: + - Content: page=0 mcid=16 + - Tag: TD + /Headers: ["U1x1y1"] + /K: + - Content: page=0 mcid=17 + - Tag: TD + /Headers: ["U1x2y1"] + /K: + - Content: page=0 mcid=18 + - Tag: TD + /Headers: ["U1x3y1"] + /K: + - Content: page=0 mcid=19 + - Tag: TD + /Headers: ["U1x4y1"] + /K: + - Content: page=0 mcid=20 + - Tag: TR + /K: + - Tag: TD + /Headers: ["U1x0y1"] + /K: + - Content: page=0 mcid=21 + - Tag: TD + /Headers: ["U1x1y1"] + /K: + - Content: page=0 mcid=22 + - Tag: TD + /Headers: ["U1x2y1"] + /K: + - Content: page=0 mcid=23 + - Tag: TD + /Headers: ["U1x3y1"] + /K: + - Content: page=0 mcid=24 + - Tag: TD + /Headers: ["U1x4y1"] + /K: + - Content: page=0 mcid=25 + - Tag: TR + /K: + - Tag: TD + /Headers: ["U1x0y1"] + /K: + - Content: page=1 mcid=0 + - Tag: TD + /Headers: ["U1x1y1"] + /K: + - Content: page=1 mcid=1 + - Tag: TD + /Headers: ["U1x2y1"] + /K: + - Content: page=1 mcid=2 + - Tag: TD + /Headers: ["U1x3y1"] + /K: + - Content: page=1 mcid=3 + - Tag: TD + /Headers: ["U1x4y1"] + /K: + - Content: page=1 mcid=4 + - Tag: TR + /K: + - Tag: TD + /Headers: ["U1x0y1"] + /K: + - Content: page=1 mcid=5 + - Tag: TD + /Headers: ["U1x1y1"] + /K: + - Content: page=1 mcid=6 + - Tag: TD + /Headers: ["U1x2y1"] + /K: + - Content: page=1 mcid=7 + - Tag: TD + /Headers: ["U1x3y1"] + /K: + - Content: page=1 mcid=8 + - Tag: TD + /Headers: ["U1x4y1"] + /K: + - Content: page=1 mcid=9 diff --git a/tests/ref/pdftags/heading-basic.yml b/tests/ref/pdftags/heading-basic.yml new file mode 100644 index 0000000000..8f422a7261 --- /dev/null +++ b/tests/ref/pdftags/heading-basic.yml @@ -0,0 +1,16 @@ +- Tag: H1 + /T: "Level 1" + /K: + - Content: page=0 mcid=0 +- Tag: H2 + /T: "Level 2" + /K: + - Content: page=0 mcid=1 +- Tag: H3 + /T: "Level 3" + /K: + - Content: page=0 mcid=2 +- Tag: H11 + /T: "Level 11" + /K: + - Content: page=0 mcid=3 diff --git a/tests/ref/pdftags/heading-offset.yml b/tests/ref/pdftags/heading-offset.yml new file mode 100644 index 0000000000..a08d5d9425 --- /dev/null +++ b/tests/ref/pdftags/heading-offset.yml @@ -0,0 +1,30 @@ +- Tag: H1 + /T: "Level 1" + /K: + - Content: page=0 mcid=0 + - Content: page=0 mcid=1 +- Tag: H1 + /T: "We're twins" + /K: + - Content: page=0 mcid=2 + - Content: page=0 mcid=3 +- Tag: H1 + /T: "We're twins" + /K: + - Content: page=0 mcid=4 + - Content: page=0 mcid=5 +- Tag: H2 + /T: "Real level 2" + /K: + - Content: page=0 mcid=6 + - Content: page=0 mcid=7 +- Tag: H2 + /T: "Fake level 2" + /K: + - Content: page=0 mcid=8 + - Content: page=0 mcid=9 +- Tag: H3 + /T: "Fake level 3" + /K: + - Content: page=0 mcid=10 + - Content: page=0 mcid=11 diff --git a/tests/ref/pdftags/lang-tags-pars-basic.yml b/tests/ref/pdftags/lang-tags-pars-basic.yml new file mode 100644 index 0000000000..a11c265bf6 --- /dev/null +++ b/tests/ref/pdftags/lang-tags-pars-basic.yml @@ -0,0 +1,13 @@ +lang: "uk" +--- +- Tag: P + /K: + - Content: page=0 mcid=0 +- Tag: P + /Lang: "sr" + /K: + - Content: page=0 mcid=1 +- Tag: P + /Lang: "be" + /K: + - Content: page=0 mcid=2 diff --git a/tests/ref/pdftags/lang-tags-propagation.yml b/tests/ref/pdftags/lang-tags-propagation.yml new file mode 100644 index 0000000000..adf1b63a35 --- /dev/null +++ b/tests/ref/pdftags/lang-tags-propagation.yml @@ -0,0 +1,46 @@ +lang: "nl" +--- +- Tag: P + /K: + - Content: page=0 mcid=0 +- Tag: L + /Lang: "de" + /Numbering: Circle + /K: + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=1 + - Tag: LBody + /K: + - Tag: P + /K: + - Content: page=0 mcid=2 + - Tag: L + /Numbering: Circle + /K: + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=3 + - Tag: LBody + /K: + - Content: page=0 mcid=4 + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=5 + - Tag: LBody + /K: + - Content: page=0 mcid=6 + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=7 + - Tag: LBody + /K: + - Content: page=0 mcid=8 diff --git a/tests/ref/pdftags/link-basic.yml b/tests/ref/pdftags/link-basic.yml new file mode 100644 index 0000000000..c40e6738c6 --- /dev/null +++ b/tests/ref/pdftags/link-basic.yml @@ -0,0 +1,36 @@ +- Tag: P + /K: + - Tag: Link + /K: + - Content: page=0 mcid=0 + - Annotation: page=0 index=0 +- Tag: P + /K: + - Tag: Link + /K: + - Content: page=0 mcid=1 + - Annotation: page=0 index=1 +- Tag: P + /K: + - Content: page=0 mcid=2 + - Tag: Link + /K: + - Content: page=0 mcid=3 + - Annotation: page=0 index=2 + - Content: page=0 mcid=4 + - Content: page=0 mcid=5 +- Tag: P + /K: + - Content: page=0 mcid=6 + - Tag: Link + /K: + - Content: page=0 mcid=7 + - Annotation: page=0 index=3 + - Content: page=0 mcid=8 + - Content: page=0 mcid=9 + - Tag: Link + /K: + - Content: page=0 mcid=10 + - Annotation: page=0 index=4 + - Content: page=0 mcid=11 + - Content: page=0 mcid=12 diff --git a/tests/ref/pdftags/link-tags-contact-prefix.yml b/tests/ref/pdftags/link-tags-contact-prefix.yml new file mode 100644 index 0000000000..24def55916 --- /dev/null +++ b/tests/ref/pdftags/link-tags-contact-prefix.yml @@ -0,0 +1,12 @@ +- Tag: P + /K: + - Tag: Link + /K: + - Content: page=0 mcid=0 + - Annotation: page=0 index=0 +- Tag: P + /K: + - Tag: Link + /K: + - Content: page=0 mcid=1 + - Annotation: page=0 index=1 diff --git a/tests/ref/pdftags/link-tags-heading-with-numbering.yml b/tests/ref/pdftags/link-tags-heading-with-numbering.yml new file mode 100644 index 0000000000..db29c19409 --- /dev/null +++ b/tests/ref/pdftags/link-tags-heading-with-numbering.yml @@ -0,0 +1,11 @@ +- Tag: H1 + /T: "Heading" + /K: + - Content: page=0 mcid=0 + - Content: page=0 mcid=1 +- Tag: P + /K: + - Tag: Link + /K: + - Content: page=0 mcid=2 + - Annotation: page=0 index=0 diff --git a/tests/ref/pdftags/link-tags-heading-without-numbering.yml b/tests/ref/pdftags/link-tags-heading-without-numbering.yml new file mode 100644 index 0000000000..9dc85eb6e9 --- /dev/null +++ b/tests/ref/pdftags/link-tags-heading-without-numbering.yml @@ -0,0 +1,10 @@ +- Tag: H1 + /T: "Heading" + /K: + - Content: page=0 mcid=0 +- Tag: P + /K: + - Tag: Link + /K: + - Content: page=0 mcid=1 + - Annotation: page=0 index=0 diff --git a/tests/ref/pdftags/link-tags-non-refable-location.yml b/tests/ref/pdftags/link-tags-non-refable-location.yml new file mode 100644 index 0000000000..813ed4125d --- /dev/null +++ b/tests/ref/pdftags/link-tags-non-refable-location.yml @@ -0,0 +1,9 @@ +- Tag: P + /K: + - Content: page=0 mcid=0 +- Tag: P + /K: + - Tag: Link + /K: + - Content: page=0 mcid=1 + - Annotation: page=0 index=0 diff --git a/tests/ref/pdftags/link-tags-position.yml b/tests/ref/pdftags/link-tags-position.yml new file mode 100644 index 0000000000..00395dd136 --- /dev/null +++ b/tests/ref/pdftags/link-tags-position.yml @@ -0,0 +1,6 @@ +- Tag: P + /K: + - Tag: Link + /K: + - Content: page=0 mcid=0 + - Annotation: page=0 index=0 diff --git a/tests/ref/pdftags/list-indent-specifics.yml b/tests/ref/pdftags/list-indent-specifics.yml new file mode 100644 index 0000000000..74b18057e5 --- /dev/null +++ b/tests/ref/pdftags/list-indent-specifics.yml @@ -0,0 +1,40 @@ +- Tag: L + /Numbering: Circle + /K: + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=0 + - Tag: LBody + /K: + - Tag: P + /K: + - Content: page=0 mcid=1 + - Tag: L + /Numbering: Circle + /K: + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=2 + - Tag: LBody + /K: + - Content: page=0 mcid=3 + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=4 + - Tag: LBody + /K: + - Content: page=0 mcid=5 + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=6 + - Tag: LBody + /K: + - Content: page=0 mcid=7 diff --git a/tests/ref/pdftags/list-tags-basic.yml b/tests/ref/pdftags/list-tags-basic.yml new file mode 100644 index 0000000000..0580afd41b --- /dev/null +++ b/tests/ref/pdftags/list-tags-basic.yml @@ -0,0 +1,74 @@ +- Tag: L + /Numbering: Circle + /K: + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=0 + - Tag: LBody + /K: + - Tag: P + /K: + - Content: page=0 mcid=1 + - Tag: L + /Numbering: Circle + /K: + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=2 + - Tag: LBody + /K: + - Content: page=0 mcid=3 + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=4 + - Tag: LBody + /K: + - Tag: P + /K: + - Content: page=0 mcid=5 + - Tag: L + /Numbering: Circle + /K: + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=6 + - Tag: LBody + /K: + - Tag: P + /K: + - Content: page=0 mcid=7 + - Tag: L + /Numbering: Circle + /K: + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=8 + - Tag: LBody + /K: + - Content: page=0 mcid=9 + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=10 + - Tag: LBody + /K: + - Content: page=0 mcid=11 + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=12 + - Tag: LBody + /K: + - Content: page=0 mcid=13 diff --git a/tests/ref/pdftags/list-tags-complex-item-with-nested-list.yml b/tests/ref/pdftags/list-tags-complex-item-with-nested-list.yml new file mode 100644 index 0000000000..9cb5c362b2 --- /dev/null +++ b/tests/ref/pdftags/list-tags-complex-item-with-nested-list.yml @@ -0,0 +1,67 @@ +- Tag: L + /Numbering: Circle + /K: + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=0 + - Tag: LBody + /K: + - Tag: BlockQuote + /K: + - Content: page=0 mcid=1 + - Tag: P + /K: + - Tag: Lbl + /K: + - Tag: Link + /K: + - Content: page=0 mcid=2 + - Annotation: page=0 index=0 + - Tag: Note + /K: + - Tag: Lbl + /K: + - Tag: Link + /K: + - Content: page=0 mcid=12 + - Annotation: page=0 index=1 + - Content: page=0 mcid=13 + - Content: page=0 mcid=3 + - Tag: L + /Numbering: Circle + /K: + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=4 + - Tag: LBody + /K: + - Content: page=0 mcid=5 + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=6 + - Tag: LBody + /K: + - Content: page=0 mcid=7 + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=8 + - Tag: LBody + /K: + - Content: page=0 mcid=9 + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=10 + - Tag: LBody + /K: + - Content: page=0 mcid=11 +- Artifact diff --git a/tests/ref/pdftags/list-tags-complex-item-with-sub-list.yml b/tests/ref/pdftags/list-tags-complex-item-with-sub-list.yml new file mode 100644 index 0000000000..9cb5c362b2 --- /dev/null +++ b/tests/ref/pdftags/list-tags-complex-item-with-sub-list.yml @@ -0,0 +1,67 @@ +- Tag: L + /Numbering: Circle + /K: + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=0 + - Tag: LBody + /K: + - Tag: BlockQuote + /K: + - Content: page=0 mcid=1 + - Tag: P + /K: + - Tag: Lbl + /K: + - Tag: Link + /K: + - Content: page=0 mcid=2 + - Annotation: page=0 index=0 + - Tag: Note + /K: + - Tag: Lbl + /K: + - Tag: Link + /K: + - Content: page=0 mcid=12 + - Annotation: page=0 index=1 + - Content: page=0 mcid=13 + - Content: page=0 mcid=3 + - Tag: L + /Numbering: Circle + /K: + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=4 + - Tag: LBody + /K: + - Content: page=0 mcid=5 + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=6 + - Tag: LBody + /K: + - Content: page=0 mcid=7 + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=8 + - Tag: LBody + /K: + - Content: page=0 mcid=9 + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=10 + - Tag: LBody + /K: + - Content: page=0 mcid=11 +- Artifact diff --git a/tests/ref/pdftags/list-tags-mixed-with-enum.yml b/tests/ref/pdftags/list-tags-mixed-with-enum.yml new file mode 100644 index 0000000000..90d0fb5797 --- /dev/null +++ b/tests/ref/pdftags/list-tags-mixed-with-enum.yml @@ -0,0 +1,74 @@ +- Tag: L + /Numbering: Circle + /K: + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=0 + - Tag: LBody + /K: + - Tag: P + /K: + - Content: page=0 mcid=1 + - Tag: L + /Numbering: Decimal + /K: + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=2 + - Tag: LBody + /K: + - Content: page=0 mcid=3 + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=4 + - Tag: LBody + /K: + - Tag: P + /K: + - Content: page=0 mcid=5 + - Tag: L + /Numbering: Decimal + /K: + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=6 + - Tag: LBody + /K: + - Tag: P + /K: + - Content: page=0 mcid=7 + - Tag: L + /Numbering: Circle + /K: + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=8 + - Tag: LBody + /K: + - Content: page=0 mcid=9 + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=10 + - Tag: LBody + /K: + - Content: page=0 mcid=11 + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=12 + - Tag: LBody + /K: + - Content: page=0 mcid=13 diff --git a/tests/ref/pdftags/outline-spacing.yml b/tests/ref/pdftags/outline-spacing.yml new file mode 100644 index 0000000000..e3c50e4d1a --- /dev/null +++ b/tests/ref/pdftags/outline-spacing.yml @@ -0,0 +1,80 @@ +- Tag: H1 + /T: "Contents" + /K: + - Content: page=0 mcid=0 +- Tag: TOC + /K: + - Tag: TOCI + /K: + - Tag: Reference + /K: + - Tag: Link + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=1 + - Content: page=0 mcid=2 + - Content: page=0 mcid=3 + - Annotation: page=0 index=0 + - Tag: TOC + /K: + - Tag: TOCI + /K: + - Tag: Reference + /K: + - Tag: Link + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=4 + - Content: page=0 mcid=5 + - Content: page=0 mcid=6 + - Annotation: page=0 index=1 + - Tag: TOCI + /K: + - Tag: Reference + /K: + - Tag: Link + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=7 + - Content: page=0 mcid=8 + - Content: page=0 mcid=9 + - Annotation: page=0 index=2 + - Tag: TOCI + /K: + - Tag: Reference + /K: + - Tag: Link + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=10 + - Content: page=0 mcid=11 + - Content: page=0 mcid=12 + - Annotation: page=0 index=3 + - Tag: TOC + /K: + - Tag: TOCI + /K: + - Tag: Reference + /K: + - Tag: Link + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=13 + - Content: page=0 mcid=14 + - Content: page=0 mcid=15 + - Annotation: page=0 index=4 +- Tag: H1 + /T: "A" +- Tag: H2 + /T: "B" +- Tag: H2 + /T: "C" +- Tag: H1 + /T: "D" +- Tag: H2 + /T: "E" diff --git a/tests/ref/pdftags/par-basic.yml b/tests/ref/pdftags/par-basic.yml new file mode 100644 index 0000000000..5a7b2a106d --- /dev/null +++ b/tests/ref/pdftags/par-basic.yml @@ -0,0 +1,23 @@ +- Tag: P + /K: + - Content: page=0 mcid=0 + - Content: page=0 mcid=1 + - Content: page=0 mcid=2 + - Content: page=0 mcid=3 + - Content: page=0 mcid=4 + - Content: page=0 mcid=5 + - Content: page=0 mcid=6 + - Content: page=1 mcid=0 + - Content: page=1 mcid=1 +- Tag: P + /K: + - Content: page=1 mcid=2 + - Content: page=1 mcid=3 + - Content: page=1 mcid=4 + - Content: page=1 mcid=5 + - Content: page=1 mcid=6 + - Content: page=2 mcid=0 + - Content: page=2 mcid=1 + - Content: page=2 mcid=2 + - Content: page=2 mcid=3 + - Content: page=2 mcid=4 diff --git a/tests/ref/pdftags/quote-dir-align.yml b/tests/ref/pdftags/quote-dir-align.yml new file mode 100644 index 0000000000..8c56e21d81 --- /dev/null +++ b/tests/ref/pdftags/quote-dir-align.yml @@ -0,0 +1,12 @@ +- Tag: BlockQuote + /K: + - Content: page=0 mcid=0 + - Content: page=0 mcid=1 +- Tag: BlockQuote + /Lang: "ar" + /K: + - Content: page=0 mcid=2 + - Content: page=0 mcid=3 + - Content: page=0 mcid=4 + - Content: page=0 mcid=5 + - Content: page=0 mcid=6 diff --git a/tests/ref/pdftags/quote-dir-author-pos.yml b/tests/ref/pdftags/quote-dir-author-pos.yml new file mode 100644 index 0000000000..549e404b15 --- /dev/null +++ b/tests/ref/pdftags/quote-dir-author-pos.yml @@ -0,0 +1,18 @@ +- Tag: P + /K: + - Content: page=0 mcid=0 + - Tag: InlineQuote + /K: + - Content: page=0 mcid=1 + - Content: page=0 mcid=2 + - Content: page=0 mcid=3 +- Tag: P + /Lang: "ar" + /K: + - Tag: InlineQuote + /K: + - Content: page=0 mcid=4 + - Content: page=0 mcid=5 + - Content: page=0 mcid=6 + - Content: page=0 mcid=7 + - Content: page=0 mcid=8 diff --git a/tests/ref/pdftags/raw-tags-basic-block.yml b/tests/ref/pdftags/raw-tags-basic-block.yml new file mode 100644 index 0000000000..c4bf34661e --- /dev/null +++ b/tests/ref/pdftags/raw-tags-basic-block.yml @@ -0,0 +1,43 @@ +- Tag: Code + /Placement: Block + /K: + - Tag: P + /K: + - Content: page=0 mcid=0 + - Content: page=0 mcid=1 + - Content: page=0 mcid=2 + - Content: page=0 mcid=3 + - Content: page=0 mcid=4 + - Content: page=0 mcid=5 + - Content: page=0 mcid=6 + - Content: page=0 mcid=7 + - Content: page=0 mcid=8 + - Content: page=0 mcid=9 + - Content: page=0 mcid=10 + - Content: page=0 mcid=11 + - Content: page=0 mcid=12 + - Content: page=0 mcid=13 + - Tag: P + - Tag: P + /K: + - Content: page=0 mcid=14 + - Content: page=0 mcid=15 + - Content: page=0 mcid=16 + - Content: page=0 mcid=17 + - Content: page=0 mcid=18 + - Content: page=0 mcid=19 + - Content: page=0 mcid=20 + - Tag: P + /K: + - Content: page=0 mcid=21 + - Content: page=0 mcid=22 + - Content: page=0 mcid=23 + - Content: page=0 mcid=24 + - Content: page=0 mcid=25 + - Content: page=0 mcid=26 + - Content: page=0 mcid=27 + - Content: page=0 mcid=28 + - Content: page=0 mcid=29 + - Tag: P + /K: + - Content: page=0 mcid=30 diff --git a/tests/ref/pdftags/raw-tags-basic-inline.yml b/tests/ref/pdftags/raw-tags-basic-inline.yml new file mode 100644 index 0000000000..17817bc34c --- /dev/null +++ b/tests/ref/pdftags/raw-tags-basic-inline.yml @@ -0,0 +1,13 @@ +- Tag: P + /K: + - Content: page=0 mcid=0 + - Tag: Code + /K: + - Content: page=0 mcid=1 + - Content: page=0 mcid=2 + - Content: page=0 mcid=3 + - Tag: Code + /K: + - Content: page=0 mcid=4 + - Content: page=0 mcid=5 + - Content: page=0 mcid=6 diff --git a/tests/ref/pdftags/table-tags-basic.yml b/tests/ref/pdftags/table-tags-basic.yml new file mode 100644 index 0000000000..035982722d --- /dev/null +++ b/tests/ref/pdftags/table-tags-basic.yml @@ -0,0 +1,68 @@ +- Tag: Table + /BBox: + page: 0 + left: 9.500 + top: 9.500 + right: 76.350 + bottom: 60.240 + /K: + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Tag: THead + /K: + - Tag: TR + /K: + - Tag: TH + /Id: "U1x0y0" + /Scope: Column + /Headers: [] + /K: + - Content: page=0 mcid=0 + - Tag: TH + /Id: "U1x1y0" + /Scope: Column + /Headers: [] + /K: + - Content: page=0 mcid=1 + - Tag: TH + /Id: "U1x2y0" + /Scope: Column + /Headers: [] + /K: + - Content: page=0 mcid=2 + - Tag: TBody + /K: + - Tag: TR + /K: + - Tag: TD + /Headers: ["U1x0y0"] + /K: + - Content: page=0 mcid=3 + - Tag: TD + /Headers: ["U1x1y0"] + /K: + - Content: page=0 mcid=4 + - Tag: TD + /Headers: ["U1x2y0"] + /K: + - Content: page=0 mcid=5 + - Tag: TR + /K: + - Tag: TD + /Headers: ["U1x0y0"] + /K: + - Content: page=0 mcid=6 + - Tag: TD + /Headers: ["U1x1y0"] + /K: + - Content: page=0 mcid=7 + - Tag: TD + /Headers: ["U1x2y0"] + /K: + - Content: page=0 mcid=8 diff --git a/tests/ref/pdftags/table-tags-column-and-row-header.yml b/tests/ref/pdftags/table-tags-column-and-row-header.yml new file mode 100644 index 0000000000..eac8100f70 --- /dev/null +++ b/tests/ref/pdftags/table-tags-column-and-row-header.yml @@ -0,0 +1,68 @@ +- Tag: Table + /BBox: + page: 0 + left: 9.500 + top: 9.500 + right: 85.360 + bottom: 60.240 + /K: + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Tag: TR + /K: + - Tag: TH + /Id: "U1x0y0" + /Scope: Column + /Headers: [] + /K: + - Content: page=0 mcid=0 + - Tag: TH + /Id: "U1x1y0" + /Scope: Column + /Headers: [] + /K: + - Content: page=0 mcid=1 + - Tag: TH + /Id: "U1x2y0" + /Scope: Column + /Headers: [] + /K: + - Content: page=0 mcid=2 + - Tag: TR + /K: + - Tag: TH + /Id: "U1x0y1" + /Scope: Row + /Headers: ["U1x0y0"] + /K: + - Content: page=0 mcid=3 + - Tag: TD + /Headers: ["U1x1y0", "U1x0y1"] + /K: + - Content: page=0 mcid=4 + - Tag: TD + /Headers: ["U1x2y0", "U1x0y1"] + /K: + - Content: page=0 mcid=5 + - Tag: TR + /K: + - Tag: TH + /Id: "U1x0y2" + /Scope: Row + /Headers: ["U1x0y0"] + /K: + - Content: page=0 mcid=6 + - Tag: TD + /Headers: ["U1x1y0", "U1x0y2"] + /K: + - Content: page=0 mcid=7 + - Tag: TD + /Headers: ["U1x2y0", "U1x0y2"] + /K: + - Content: page=0 mcid=8 diff --git a/tests/ref/pdftags/table-tags-missing-cells.yml b/tests/ref/pdftags/table-tags-missing-cells.yml new file mode 100644 index 0000000000..8b031729d4 --- /dev/null +++ b/tests/ref/pdftags/table-tags-missing-cells.yml @@ -0,0 +1,115 @@ +- Tag: Table + /BBox: + page: 0 + left: 9.500 + top: 9.500 + right: 76.350 + bottom: 96.820 + /K: + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Artifact + - Tag: THead + /K: + - Tag: TR + /K: + - Tag: TH + /Id: "U1x0y0" + /Scope: Column + /Headers: [] + /K: + - Content: page=0 mcid=0 + - Tag: TH + /Id: "U1x1y0" + /Scope: Column + /Headers: [] + /K: + - Content: page=0 mcid=1 + - Tag: TH + /Id: "U1x2y0" + /Scope: Column + /Headers: [] + /K: + - Content: page=0 mcid=2 + - Tag: TR + /K: + - Tag: TH + /Id: "U1x0y1" + /Scope: Column + /Headers: ["U1x0y0"] + /K: + - Content: page=0 mcid=3 + - Tag: TH + /Id: "U1x1y1" + /Scope: Column + /Headers: ["U1x1y0"] + /K: + - Content: page=0 mcid=4 + - Tag: TH + /Id: "U1x2y1" + /Scope: Column + /Headers: ["U1x2y0"] + /K: + - Content: page=0 mcid=5 + - Tag: TBody + /K: + - Tag: TR + /K: + - Tag: TD + /Headers: ["U1x0y1"] + - Tag: TD + /Headers: ["U1x1y1"] + - Tag: TD + /Headers: ["U1x2y1"] + - Tag: THead + /K: + - Tag: TR + /K: + - Tag: TH + /Id: "U1x0y3" + /Scope: Column + /Headers: ["U1x0y0"] + /K: + - Content: page=0 mcid=6 + - Tag: TH + /Id: "U1x1y3" + /Scope: Column + /Headers: ["U1x1y0"] + /K: + - Content: page=0 mcid=7 + - Tag: TH + /Id: "U1x2y3" + /Scope: Column + /Headers: ["U1x2y0"] + - Tag: TBody + /K: + - Tag: TR + /K: + - Tag: TD + /Headers: ["U1x0y3"] + - Tag: TD + /Headers: ["U1x1y3"] + - Tag: TD + /Headers: ["U1x2y3"] + - Tag: TFoot + /K: + - Tag: TR + /K: + - Tag: TD + /Headers: ["U1x0y3"] + - Tag: TD + /Headers: ["U1x1y3"] + /K: + - Content: page=0 mcid=8 + - Tag: TD + /Headers: ["U1x2y3"] + /K: + - Content: page=0 mcid=9 diff --git a/tests/ref/pdftags/terms-constructor.yml b/tests/ref/pdftags/terms-constructor.yml new file mode 100644 index 0000000000..3a75ce91ff --- /dev/null +++ b/tests/ref/pdftags/terms-constructor.yml @@ -0,0 +1,21 @@ +- Tag: L + /Numbering: None + /K: + - Artifact + - Artifact + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=0 + - Tag: LBody + /K: + - Content: page=0 mcid=1 + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=2 + - Tag: LBody + /K: + - Content: page=0 mcid=3 diff --git a/tests/ref/pdftags/text-lang.yml b/tests/ref/pdftags/text-lang.yml new file mode 100644 index 0000000000..2b4793e205 --- /dev/null +++ b/tests/ref/pdftags/text-lang.yml @@ -0,0 +1,8 @@ +lang: "zh" +--- +- Tag: H1 + /T: "目录" + /K: + - Content: page=0 mcid=0 + - Content: page=0 mcid=1 +- Tag: TOC diff --git a/tests/suite/layout/grid/headers.typ b/tests/suite/layout/grid/headers.typ index 2742110c1d..94c80ab655 100644 --- a/tests/suite/layout/grid/headers.typ +++ b/tests/suite/layout/grid/headers.typ @@ -1,4 +1,4 @@ ---- grid-headers --- +--- grid-headers render pdftags --- #set page(width: auto, height: 12em) #table( columns: 5, diff --git a/tests/suite/model/bibliography.typ b/tests/suite/model/bibliography.typ index 97be4ab848..23450418f1 100644 --- a/tests/suite/model/bibliography.typ +++ b/tests/suite/model/bibliography.typ @@ -1,6 +1,6 @@ // Test citations and bibliographies. ---- bibliography-basic render html --- +--- bibliography-basic render html pdftags --- #show: it => context { set page(width: 200pt) if target() == "paged"; it } = Details diff --git a/tests/suite/model/enum.typ b/tests/suite/model/enum.typ index 8908a328a2..a658f5c347 100644 --- a/tests/suite/model/enum.typ +++ b/tests/suite/model/enum.typ @@ -3,7 +3,7 @@ --- enum-function-call --- #enum[Embrace][Extend][Extinguish] ---- enum-number-override-nested --- +--- enum-number-override-nested render pdftags --- 0. Before first! 1. First. 2. Indented diff --git a/tests/suite/model/figure.typ b/tests/suite/model/figure.typ index 37fb4ecdaa..f6f7d17335 100644 --- a/tests/suite/model/figure.typ +++ b/tests/suite/model/figure.typ @@ -1,6 +1,6 @@ // Test figures. ---- figure-basic --- +--- figure-basic render pdftags --- #set page(width: 150pt) #set figure(numbering: "I") @@ -13,7 +13,7 @@ We can clearly see that @fig-cylinder and ) #figure( - pad(y: -6pt, image("/assets/images/cylinder.svg", height: 2cm)), + pad(y: -6pt, image(alt: "Sketch of white standing cylinder", "/assets/images/cylinder.svg", height: 2cm)), caption: [The basic shapes.], numbering: "I", ) diff --git a/tests/suite/model/heading.typ b/tests/suite/model/heading.typ index e8d967274a..e0a9ac3463 100644 --- a/tests/suite/model/heading.typ +++ b/tests/suite/model/heading.typ @@ -1,6 +1,6 @@ // Test headings. ---- heading-basic --- +--- heading-basic render pdftags --- // Different number of equals signs. = Level 1 @@ -82,7 +82,7 @@ comment spans lines ===== Heading 🌍 #heading(level: 5)[Heading] ---- heading-offset --- +--- heading-offset render pdftags --- // Test setting the starting offset. #set heading(numbering: "1.1") #show heading.where(level: 2): set text(blue) diff --git a/tests/suite/model/link.typ b/tests/suite/model/link.typ index 2d558fe21d..21fda39e0b 100644 --- a/tests/suite/model/link.typ +++ b/tests/suite/model/link.typ @@ -1,6 +1,6 @@ // Test hyperlinking. ---- link-basic render html --- +--- link-basic render html pdftags --- // Link syntax. https://example.com/ diff --git a/tests/suite/model/list.typ b/tests/suite/model/list.typ index 796a7b0696..2ba5816922 100644 --- a/tests/suite/model/list.typ +++ b/tests/suite/model/list.typ @@ -28,7 +28,7 @@ _Shopping list_ - Top-level indent - is fine. ---- list-indent-specifics --- +--- list-indent-specifics render pdftags --- - A - B - C diff --git a/tests/suite/model/outline.typ b/tests/suite/model/outline.typ index ed9a3f35c3..8a75a98761 100644 --- a/tests/suite/model/outline.typ +++ b/tests/suite/model/outline.typ @@ -1,4 +1,4 @@ ---- outline-spacing --- +--- outline-spacing render pdftags --- #set heading(numbering: "1.a.") #set outline.entry(fill: none) #show outline.entry.where(level: 1): set block(above: 1.2em) diff --git a/tests/suite/model/par.typ b/tests/suite/model/par.typ index fae0e1f56e..1cbf98fb10 100644 --- a/tests/suite/model/par.typ +++ b/tests/suite/model/par.typ @@ -1,6 +1,6 @@ // Test configuring paragraph properties. ---- par-basic --- +--- par-basic render pdftags --- #set page(width: 250pt, height: 120pt) But, soft! what light through yonder window breaks? It is the east, and Juliet diff --git a/tests/suite/model/quote.typ b/tests/suite/model/quote.typ index c0f39ed160..962f74b804 100644 --- a/tests/suite/model/quote.typ +++ b/tests/suite/model/quote.typ @@ -1,6 +1,6 @@ // Test the quote element. ---- quote-dir-author-pos --- +--- quote-dir-author-pos render pdftags --- // Text direction affects author positioning #set text(font: ("Libertinus Serif", "Noto Sans Arabic")) And I quote: #quote(attribution: [René Descartes])[cogito, ergo sum]. @@ -8,7 +8,7 @@ And I quote: #quote(attribution: [René Descartes])[cogito, ergo sum]. #set text(lang: "ar") #quote(attribution: [عالم])[مرحبًا] ---- quote-dir-align --- +--- quote-dir-align render pdftags --- // Text direction affects block alignment #set text(font: ("Libertinus Serif", "Noto Sans Arabic")) #set quote(block: true) diff --git a/tests/suite/model/terms.typ b/tests/suite/model/terms.typ index 103a8033ee..eb6bf9a580 100644 --- a/tests/suite/model/terms.typ +++ b/tests/suite/model/terms.typ @@ -1,6 +1,6 @@ // Test term list. ---- terms-constructor --- +--- terms-constructor render pdftags --- // Test with constructor. #terms( ([One], [First]), diff --git a/tests/suite/pdftags/figure.typ b/tests/suite/pdftags/figure.typ new file mode 100644 index 0000000000..704c10f76a --- /dev/null +++ b/tests/suite/pdftags/figure.typ @@ -0,0 +1,23 @@ +--- figure-tags-image-basic pdftags --- +// The image is automatically wrapped in a figure tag. +#image(alt: "A tiger", "/assets/images/tiger.jpg") + +--- figure-tags-image-figure-with-caption pdftags --- +#figure( + // The image doesn't create a duplicate figure tag + // and the alt description is used in the outer figure. + image(alt: "A tiger", "/assets/images/tiger.jpg"), + // The caption is stored outside of the figure tag + // grouped in a nonstruct. + caption: [Some caption] +) + +--- figure-tags-equation-with-caption pdftags --- +#figure( + // The alt description is used in the outer figure. + math.equation( + alt: "The Pythagorean theorem: a squared plus b squared is c squared", + $a^2 + b^2 = c^2$, + ), + caption: [Some caption] +) diff --git a/tests/suite/pdftags/lang.typ b/tests/suite/pdftags/lang.typ new file mode 100644 index 0000000000..8a1313fe35 --- /dev/null +++ b/tests/suite/pdftags/lang.typ @@ -0,0 +1,19 @@ +--- lang-tags-pars-basic pdftags --- +#set text(lang: "uk") +Par 1. + +#set text(lang: "sr") +Par 2. + +#set text(lang: "be") +Par 3. + +--- lang-tags-propagation pdftags --- +#set text(lang: "nl") +A paragraph. + +// language attributes are propagated to the parent (L) tag +- #text(lang: "de", "a") + - #text(lang: "de", "b") + - #text(lang: "de", "c") +- #text(lang: "de", "d") diff --git a/tests/suite/pdftags/link.typ b/tests/suite/pdftags/link.typ new file mode 100644 index 0000000000..c7ac649f01 --- /dev/null +++ b/tests/suite/pdftags/link.typ @@ -0,0 +1,23 @@ +--- link-tags-heading-without-numbering pdftags --- += Heading + +#link()[link to heading] + +--- link-tags-heading-with-numbering pdftags --- +#set heading(numbering: "1.") += Heading + +#link()[link to heading] + +--- link-tags-non-refable-location pdftags --- +A random location + +#link()[link to somewhere] + +--- link-tags-contact-prefix pdftags --- +#link("mailto:hello@typst.app") + +#link("tel:123") + +--- link-tags-position pdftags --- +#context link(here().position())[somewhere] diff --git a/tests/suite/pdftags/list.typ b/tests/suite/pdftags/list.typ new file mode 100644 index 0000000000..8b443cc976 --- /dev/null +++ b/tests/suite/pdftags/list.typ @@ -0,0 +1,34 @@ +--- list-tags-basic pdftags --- +- a + - 1 +- b + - c + - d + - e +- f + +--- list-tags-mixed-with-enum pdftags --- +- a + + 1 +- b + + c + - d + + e +- f + +--- list-tags-complex-item-with-sub-list pdftags --- +- #[#quote(block: true)[hi] #footnote[1].] + - a + - b +- c +- d + +--- list-tags-complex-item-with-nested-list pdftags --- +- #[ + #quote(block: true)[hi] + #footnote[1]. + - a + - b + ] +- c +- d diff --git a/tests/suite/pdftags/raw.typ b/tests/suite/pdftags/raw.typ new file mode 100644 index 0000000000..1563234f4d --- /dev/null +++ b/tests/suite/pdftags/raw.typ @@ -0,0 +1,11 @@ +--- raw-tags-basic-block pdftags --- +```rs +const PDF_STANDARD: &str = "PDF/UA-1"; + +fn main() { + println!("hello {PDF_STANDARD}"); +} +``` + +--- raw-tags-basic-inline pdftags --- +Some `inline raw` text and the rust ```rs fn``` keyword. diff --git a/tests/suite/pdftags/table.typ b/tests/suite/pdftags/table.typ new file mode 100644 index 0000000000..c053ee991f --- /dev/null +++ b/tests/suite/pdftags/table.typ @@ -0,0 +1,37 @@ +--- table-tags-basic pdftags --- +#table( + columns: 3, + table.header([H1], [H2], [H3]), + [a1], [a2], [a3], + [b1], [b2], [b3], +) + +--- table-tags-column-and-row-header pdftags --- +#table( + columns: 3, + table.header([H1], [H2], [H3]), + pdf.header-cell(scope: "row")[10:00], [a2], [a3], + pdf.header-cell(scope: "row")[12:30], [b2], [b3], +) + +--- table-tags-missing-cells pdftags --- +#table( + columns: 3, + table.header(level: 1, [H1], [H1], [H1]), + table.header(level: 2, [H2], [H2], [H2]), + + // the middle cell is missing + table.cell(x: 0)[], + table.cell(x: 2)[], + + // the last cell is missing, its type should be inferred from the row + table.header(level: 2, [H2], [H2]), + + // last cell is missing + [], [], + + table.footer( + table.cell(x: 1)[F], + table.cell(x: 2)[F], + ), +) diff --git a/tests/suite/text/lang.typ b/tests/suite/text/lang.typ index b4f87310c5..96fc415550 100644 --- a/tests/suite/text/lang.typ +++ b/tests/suite/text/lang.typ @@ -1,6 +1,6 @@ // Test setting the document language. ---- text-lang --- +--- text-lang render pdftags --- // without any region #set text(font: "Noto Serif CJK TC", lang: "zh") #outline() From 0812d8777eda44534e0c28ab6b81d45f7db0fbbf Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Fri, 1 Aug 2025 11:24:53 +0200 Subject: [PATCH 418/558] Factor out PDF tagging code into a context module --- crates/typst-pdf/src/tags/context.rs | 547 +++++++++++++++++++++++ crates/typst-pdf/src/tags/mod.rs | 632 +++------------------------ crates/typst-pdf/src/tags/table.rs | 2 +- 3 files changed, 602 insertions(+), 579 deletions(-) create mode 100644 crates/typst-pdf/src/tags/context.rs diff --git a/crates/typst-pdf/src/tags/context.rs b/crates/typst-pdf/src/tags/context.rs new file mode 100644 index 0000000000..9f16e0f83c --- /dev/null +++ b/crates/typst-pdf/src/tags/context.rs @@ -0,0 +1,547 @@ +use std::cell::OnceCell; +use std::slice::SliceIndex; + +use krilla::geom as kg; +use krilla::tagging::{BBox, Node, TagKind, TagTree}; +use rustc_hash::FxHashMap; +use typst_library::foundations::Packed; +use typst_library::introspection::Location; +use typst_library::layout::{Abs, Point, Rect}; +use typst_library::model::{LinkMarker, OutlineEntry, TableCell}; +use typst_library::pdf::ArtifactKind; +use typst_library::text::Lang; +use typst_syntax::Span; + +use crate::convert::FrameContext; +use crate::tags::list::ListCtx; +use crate::tags::outline::OutlineCtx; +use crate::tags::table::TableCtx; +use crate::tags::{Placeholder, TagGroup, TagNode}; +use crate::util::AbsExt; + +pub struct Tags { + /// The language of the first text item that has been encountered. + pub doc_lang: Option, + /// The intermediary stack of nested tag groups. + pub stack: TagStack, + /// A list of placeholders corresponding to a [`TagNode::Placeholder`]. + pub placeholders: Placeholders, + /// Footnotes are inserted directly after the footenote reference in the + /// reading order. Because of some layouting bugs, the entry might appear + /// before the reference in the text, so we only resolve them once tags + /// for the whole document are generated. + pub footnotes: FxHashMap, + pub in_artifact: Option<(Location, ArtifactKind)>, + /// Used to group multiple link annotations using quad points. + link_id: LinkId, + /// Used to generate IDs referenced in table `Headers` attributes. + /// The IDs must be document wide unique. + table_id: TableId, + + /// The output. + pub tree: Vec, +} + +impl Tags { + pub fn new() -> Self { + Self { + doc_lang: None, + stack: TagStack::new(), + placeholders: Placeholders(Vec::new()), + footnotes: FxHashMap::default(), + in_artifact: None, + + link_id: LinkId(0), + table_id: TableId(0), + + tree: Vec::new(), + } + } + + pub fn push(&mut self, node: TagNode) { + if let Some(entry) = self.stack.last_mut() { + entry.nodes.push(node); + } else { + self.tree.push(node); + } + } + + pub fn extend(&mut self, nodes: impl IntoIterator) { + if let Some(entry) = self.stack.last_mut() { + entry.nodes.extend(nodes); + } else { + self.tree.extend(nodes); + } + } + + pub fn build_tree(&mut self) -> TagTree { + assert!(self.stack.items.is_empty(), "tags weren't properly closed"); + + let children = std::mem::take(&mut self.tree) + .into_iter() + .map(|node| self.resolve_node(node)) + .collect::>(); + TagTree::from(children) + } + + /// Try to set the language of a parent tag, or the entire document. + /// If the language couldn't be set and is different from the existing one, + /// this will return `Some`, and the language should be specified on the + /// marked content directly. + pub fn try_set_lang(&mut self, lang: Lang) -> Option { + // Discard languages within artifacts. + if self.in_artifact.is_some() { + return None; + } + if self.doc_lang.is_none_or(|l| l == lang) { + self.doc_lang = Some(lang); + return None; + } + if let Some(last) = self.stack.last_mut() + && last.lang.is_none_or(|l| l == lang) + { + last.lang = Some(lang); + return None; + } + Some(lang) + } + + /// Resolves [`Placeholder`] nodes. + fn resolve_node(&mut self, node: TagNode) -> Node { + match node { + TagNode::Group(TagGroup { tag, nodes }) => { + let children = nodes + .into_iter() + .map(|node| self.resolve_node(node)) + .collect::>(); + Node::Group(krilla::tagging::TagGroup::with_children(tag, children)) + } + TagNode::Leaf(identifier) => Node::Leaf(identifier), + TagNode::Placeholder(placeholder) => self.placeholders.take(placeholder), + TagNode::FootnoteEntry(loc) => { + let node = (self.footnotes.remove(&loc)) + .and_then(|ctx| ctx.entry) + .expect("footnote"); + self.resolve_node(node) + } + } + } + + pub fn next_link_id(&mut self) -> LinkId { + self.link_id.0 += 1; + self.link_id + } + + pub fn next_table_id(&mut self) -> TableId { + self.table_id.0 += 1; + self.table_id + } +} + +#[derive(Debug)] +pub struct TagStack { + items: Vec, + /// The index of the topmost stack entry that has a bbox. + bbox_idx: Option, +} + +impl> std::ops::Index for TagStack { + type Output = I::Output; + + #[inline] + fn index(&self, index: I) -> &Self::Output { + std::ops::Index::index(&self.items, index) + } +} + +impl> std::ops::IndexMut for TagStack { + #[inline] + fn index_mut(&mut self, index: I) -> &mut Self::Output { + std::ops::IndexMut::index_mut(&mut self.items, index) + } +} + +impl TagStack { + pub fn new() -> Self { + Self { items: Vec::new(), bbox_idx: None } + } + + pub fn len(&self) -> usize { + self.items.len() + } + + pub fn last_mut(&mut self) -> Option<&mut StackEntry> { + self.items.last_mut() + } + + pub fn iter(&self) -> std::slice::Iter<'_, StackEntry> { + self.items.iter() + } + + pub fn push(&mut self, entry: StackEntry) { + if entry.kind.bbox().is_some() { + self.bbox_idx = Some(self.len()); + } + self.items.push(entry); + } + + pub fn extend(&mut self, iter: impl IntoIterator) { + let start = self.len(); + self.items.extend(iter); + let last_bbox_offset = self.items[start..] + .iter() + .rposition(|entry| entry.kind.bbox().is_some()); + if let Some(offset) = last_bbox_offset { + self.bbox_idx = Some(start + offset); + } + } + + /// Remove the last stack entry if the predicate returns true. + /// This takes care of updating the parent bboxes. + pub fn pop_if( + &mut self, + mut predicate: impl FnMut(&mut StackEntry) -> bool, + ) -> Option { + let last = self.items.last_mut()?; + if predicate(last) { self.pop() } else { None } + } + + /// Remove the last stack entry. + /// This takes care of updating the parent bboxes. + pub fn pop(&mut self) -> Option { + let removed = self.items.pop()?; + + let Some(inner_bbox) = removed.kind.bbox() else { return Some(removed) }; + + self.bbox_idx = self.items.iter_mut().enumerate().rev().find_map(|(i, entry)| { + let outer_bbox = entry.kind.bbox_mut()?; + if let Some((page_idx, rect)) = inner_bbox.rect { + outer_bbox.expand_page(page_idx, rect); + } + Some(i) + }); + + Some(removed) + } + + pub fn parent(&mut self) -> Option<&mut StackEntryKind> { + self.items.last_mut().map(|e| &mut e.kind) + } + + pub fn parent_table(&mut self) -> Option<&mut TableCtx> { + self.parent()?.as_table_mut() + } + + pub fn parent_list(&mut self) -> Option<&mut ListCtx> { + self.parent()?.as_list_mut() + } + + pub fn parent_figure(&mut self) -> Option<&mut FigureCtx> { + self.parent()?.as_figure_mut() + } + + pub fn parent_outline(&mut self) -> Option<(&mut OutlineCtx, &mut Vec)> { + self.items.last_mut().and_then(|e| { + let ctx = e.kind.as_outline_mut()?; + Some((ctx, &mut e.nodes)) + }) + } + + pub fn parent_outline_entry(&mut self) -> Option<&mut OutlineEntry> { + self.parent()?.as_outline_entry_mut() + } + + pub fn find_parent_link( + &mut self, + ) -> Option<(LinkId, &Packed, &mut Vec)> { + self.items.iter_mut().rev().find_map(|e| { + let (link_id, link) = e.kind.as_link()?; + Some((link_id, link, &mut e.nodes)) + }) + } + + /// Finds the first parent that has a bounding box. + pub fn find_parent_bbox(&mut self) -> Option<&mut BBoxCtx> { + self.items[self.bbox_idx?].kind.bbox_mut() + } +} + +pub struct Placeholders(Vec>); + +impl Placeholders { + pub fn reserve(&mut self) -> Placeholder { + let idx = self.0.len(); + self.0.push(OnceCell::new()); + Placeholder(idx) + } + + pub fn init(&mut self, placeholder: Placeholder, node: Node) { + self.0[placeholder.0] + .set(node) + .map_err(|_| ()) + .expect("placeholder to be uninitialized"); + } + + pub fn take(&mut self, placeholder: Placeholder) -> Node { + self.0[placeholder.0].take().expect("initialized placeholder node") + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct TableId(u32); + +impl TableId { + pub fn get(self) -> u32 { + self.0 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LinkId(u32); + +#[derive(Debug)] +pub struct StackEntry { + pub loc: Location, + pub span: Span, + pub lang: Option, + pub kind: StackEntryKind, + pub nodes: Vec, +} + +#[derive(Clone, Debug)] +pub enum StackEntryKind { + Standard(TagKind), + Outline(OutlineCtx), + OutlineEntry(Packed), + Table(TableCtx), + TableCell(Packed), + List(ListCtx), + ListItemLabel, + ListItemBody, + BibEntry, + Figure(FigureCtx), + Formula(FigureCtx), + Link(LinkId, Packed), + /// The footnote reference in the text, contains the declaration location. + FootnoteRef(Location), + /// The footnote entry at the end of the page. Contains the [`Location`] of + /// the [`FootnoteElem`](typst_library::model::FootnoteElem). + FootnoteEntry(Location), + CodeBlock, + CodeBlockLine, +} + +impl StackEntryKind { + pub fn as_outline_mut(&mut self) -> Option<&mut OutlineCtx> { + if let Self::Outline(v) = self { Some(v) } else { None } + } + + pub fn as_outline_entry_mut(&mut self) -> Option<&mut OutlineEntry> { + if let Self::OutlineEntry(v) = self { Some(v) } else { None } + } + + pub fn as_table_mut(&mut self) -> Option<&mut TableCtx> { + if let Self::Table(v) = self { Some(v) } else { None } + } + + pub fn as_list_mut(&mut self) -> Option<&mut ListCtx> { + if let Self::List(v) = self { Some(v) } else { None } + } + + pub fn as_figure_mut(&mut self) -> Option<&mut FigureCtx> { + if let Self::Figure(v) = self { Some(v) } else { None } + } + + pub fn as_link(&self) -> Option<(LinkId, &Packed)> { + if let Self::Link(id, link) = self { Some((*id, link)) } else { None } + } + + pub fn is_code_block(&self) -> bool { + matches!(self, Self::CodeBlock) + } + + pub fn bbox(&self) -> Option<&BBoxCtx> { + match self { + Self::Table(ctx) => Some(&ctx.bbox), + Self::Figure(ctx) => Some(&ctx.bbox), + Self::Formula(ctx) => Some(&ctx.bbox), + _ => None, + } + } + + pub fn bbox_mut(&mut self) -> Option<&mut BBoxCtx> { + match self { + Self::Table(ctx) => Some(&mut ctx.bbox), + Self::Figure(ctx) => Some(&mut ctx.bbox), + Self::Formula(ctx) => Some(&mut ctx.bbox), + _ => None, + } + } + + pub fn is_breakable(&self, is_pdf_ua: bool) -> bool { + match self { + StackEntryKind::Standard(tag) => match tag { + TagKind::Part(_) => !is_pdf_ua, + TagKind::Article(_) => !is_pdf_ua, + TagKind::Section(_) => !is_pdf_ua, + TagKind::Div(_) => !is_pdf_ua, + TagKind::BlockQuote(_) => !is_pdf_ua, + TagKind::Caption(_) => !is_pdf_ua, + TagKind::TOC(_) => false, + TagKind::TOCI(_) => false, + TagKind::Index(_) => false, + TagKind::P(_) => true, + TagKind::Hn(_) => !is_pdf_ua, + TagKind::L(_) => false, + TagKind::LI(_) => false, + TagKind::Lbl(_) => !is_pdf_ua, + TagKind::LBody(_) => !is_pdf_ua, + TagKind::Table(_) => false, + TagKind::TR(_) => false, + TagKind::TH(_) => false, + TagKind::TD(_) => false, + TagKind::THead(_) => false, + TagKind::TBody(_) => false, + TagKind::TFoot(_) => false, + TagKind::Span(_) => true, + TagKind::InlineQuote(_) => !is_pdf_ua, + TagKind::Note(_) => !is_pdf_ua, + TagKind::Reference(_) => !is_pdf_ua, + TagKind::BibEntry(_) => !is_pdf_ua, + TagKind::Code(_) => !is_pdf_ua, + TagKind::Link(_) => !is_pdf_ua, + TagKind::Annot(_) => !is_pdf_ua, + TagKind::Figure(_) => !is_pdf_ua, + TagKind::Formula(_) => !is_pdf_ua, + TagKind::NonStruct(_) => !is_pdf_ua, + TagKind::Datetime(_) => !is_pdf_ua, + TagKind::Terms(_) => !is_pdf_ua, + TagKind::Title(_) => !is_pdf_ua, + TagKind::Strong(_) => !is_pdf_ua, + TagKind::Em(_) => !is_pdf_ua, + }, + StackEntryKind::Outline(_) => false, + StackEntryKind::OutlineEntry(_) => false, + StackEntryKind::Table(_) => false, + StackEntryKind::TableCell(_) => false, + StackEntryKind::List(_) => false, + StackEntryKind::ListItemLabel => false, + StackEntryKind::ListItemBody => false, + StackEntryKind::BibEntry => false, + StackEntryKind::Figure(_) => false, + StackEntryKind::Formula(_) => false, + StackEntryKind::Link(..) => !is_pdf_ua, + StackEntryKind::FootnoteRef(_) => false, + StackEntryKind::FootnoteEntry(_) => false, + StackEntryKind::CodeBlock => false, + StackEntryKind::CodeBlockLine => false, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct FootnoteCtx { + /// Whether this footenote has been referenced inside the document. The + /// entry will be inserted inside the reading order after the first + /// reference. All other references will still have links to the footnote. + pub is_referenced: bool, + /// The nodes that make up the footnote entry. + pub entry: Option, +} + +impl FootnoteCtx { + pub const fn new() -> Self { + Self { is_referenced: false, entry: None } + } +} + +/// Figure/Formula context +#[derive(Debug, Clone, PartialEq)] +pub struct FigureCtx { + pub alt: Option, + pub bbox: BBoxCtx, +} + +impl FigureCtx { + pub fn new(alt: Option) -> Self { + Self { alt, bbox: BBoxCtx::new() } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct BBoxCtx { + pub rect: Option<(usize, Rect)>, + pub multi_page: bool, +} + +impl BBoxCtx { + pub fn new() -> Self { + Self { rect: None, multi_page: false } + } + + pub fn reset(&mut self) { + *self = Self::new(); + } + + /// Expand the bounding box with a `rect` relative to the current frame + /// context transform. + pub fn expand_frame(&mut self, fc: &FrameContext, rect: Rect) { + let Some(page_idx) = fc.page_idx else { return }; + if self.multi_page { + return; + } + let (idx, bbox) = self.rect.get_or_insert(( + page_idx, + Rect::new(Point::splat(Abs::inf()), Point::splat(-Abs::inf())), + )); + if *idx != page_idx { + self.multi_page = true; + self.rect = None; + return; + } + + let size = rect.size(); + for point in [ + rect.min, + rect.min + Point::with_x(size.x), + rect.min + Point::with_y(size.y), + rect.max, + ] { + let p = point.transform(fc.state().transform()); + bbox.min = bbox.min.min(p); + bbox.max = bbox.max.max(p); + } + } + + /// Expand the bounding box with a rectangle that's already transformed into + /// page coordinates. + pub fn expand_page(&mut self, page_idx: usize, rect: Rect) { + if self.multi_page { + return; + } + let (idx, bbox) = self.rect.get_or_insert(( + page_idx, + Rect::new(Point::splat(Abs::inf()), Point::splat(-Abs::inf())), + )); + if *idx != page_idx { + self.multi_page = true; + self.rect = None; + return; + } + + bbox.min = bbox.min.min(rect.min); + bbox.max = bbox.max.max(rect.max); + } + + pub fn get(&self) -> Option { + let (page_idx, rect) = self.rect?; + let rect = kg::Rect::from_ltrb( + rect.min.x.to_f32(), + rect.min.y.to_f32(), + rect.max.x.to_f32(), + rect.max.y.to_f32(), + ) + .unwrap(); + Some(BBox::new(page_idx, rect)) + } +} diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index 2a358997f3..de934524a6 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -1,21 +1,16 @@ -use std::cell::OnceCell; use std::num::NonZeroU16; -use std::slice::SliceIndex; use krilla::configure::Validator; -use krilla::geom as kg; use krilla::page::Page; use krilla::surface::Surface; use krilla::tagging as kt; use krilla::tagging::{ - ArtifactType, BBox, ContentTag, Identifier, ListNumbering, Node, SpanTag, Tag, - TagKind, TagTree, + ArtifactType, ContentTag, Identifier, ListNumbering, Node, SpanTag, Tag, TagKind, }; -use rustc_hash::FxHashMap; use typst_library::diag::{SourceResult, bail}; -use typst_library::foundations::{Content, Packed}; +use typst_library::foundations::Content; use typst_library::introspection::Location; -use typst_library::layout::{Abs, Point, Rect, RepeatElem}; +use typst_library::layout::{Rect, RepeatElem}; use typst_library::math::EquationElem; use typst_library::model::{ EnumElem, FigureCaption, FigureElem, FootnoteEntry, HeadingElem, LinkMarker, @@ -33,13 +28,63 @@ use crate::tags::list::ListCtx; use crate::tags::outline::OutlineCtx; use crate::tags::table::TableCtx; use crate::tags::util::{PropertyOptRef, PropertyValCopied}; -use crate::util::AbsExt; +pub use context::*; + +mod context; mod list; mod outline; mod table; mod util; +#[derive(Debug, Clone, PartialEq)] +pub enum TagNode { + Group(TagGroup), + Leaf(Identifier), + /// Allows inserting a placeholder into the tag tree. + /// Currently used for [`krilla::page::Page::add_tagged_annotation`]. + Placeholder(Placeholder), + FootnoteEntry(Location), +} + +impl TagNode { + pub fn group(tag: impl Into, contents: GroupContents) -> Self { + let lang = contents.lang.map(|l| l.as_str().to_string()); + let tag = tag + .into() + .with_lang(lang) + .with_location(Some(contents.span.into_raw())); + TagNode::Group(TagGroup { tag, nodes: contents.nodes }) + } + + /// A tag group not directly related to a typst element, generated to + /// accomodate the tag structure. + pub fn virtual_group(tag: impl Into, nodes: Vec) -> Self { + let tag = tag.into(); + TagNode::Group(TagGroup { tag, nodes }) + } + + pub fn empty_group(tag: impl Into) -> Self { + Self::virtual_group(tag, Vec::new()) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TagGroup { + tag: TagKind, + nodes: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct GroupContents { + span: Span, + lang: Option, + nodes: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Placeholder(usize); + pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Content) { if gc.options.disable_tags { return; @@ -485,575 +530,6 @@ pub fn update_bbox( } } -pub struct Tags { - /// The language of the first text item that has been encountered. - pub doc_lang: Option, - /// The intermediary stack of nested tag groups. - pub stack: TagStack, - /// A list of placeholders corresponding to a [`TagNode::Placeholder`]. - pub placeholders: Placeholders, - /// Footnotes are inserted directly after the footenote reference in the - /// reading order. Because of some layouting bugs, the entry might appear - /// before the reference in the text, so we only resolve them once tags - /// for the whole document are generated. - pub footnotes: FxHashMap, - pub in_artifact: Option<(Location, ArtifactKind)>, - /// Used to group multiple link annotations using quad points. - link_id: LinkId, - /// Used to generate IDs referenced in table `Headers` attributes. - /// The IDs must be document wide unique. - table_id: TableId, - - /// The output. - pub tree: Vec, -} - -impl Tags { - pub fn new() -> Self { - Self { - doc_lang: None, - stack: TagStack::new(), - placeholders: Placeholders(Vec::new()), - footnotes: FxHashMap::default(), - in_artifact: None, - - link_id: LinkId(0), - table_id: TableId(0), - - tree: Vec::new(), - } - } - - pub fn push(&mut self, node: TagNode) { - if let Some(entry) = self.stack.last_mut() { - entry.nodes.push(node); - } else { - self.tree.push(node); - } - } - - pub fn extend(&mut self, nodes: impl IntoIterator) { - if let Some(entry) = self.stack.last_mut() { - entry.nodes.extend(nodes); - } else { - self.tree.extend(nodes); - } - } - - pub fn build_tree(&mut self) -> TagTree { - assert!(self.stack.items.is_empty(), "tags weren't properly closed"); - - let children = std::mem::take(&mut self.tree) - .into_iter() - .map(|node| self.resolve_node(node)) - .collect::>(); - TagTree::from(children) - } - - /// Try to set the language of a parent tag, or the entire document. - /// If the language couldn't be set and is different from the existing one, - /// this will return `Some`, and the language should be specified on the - /// marked content directly. - pub fn try_set_lang(&mut self, lang: Lang) -> Option { - // Discard languages within artifacts. - if self.in_artifact.is_some() { - return None; - } - if self.doc_lang.is_none_or(|l| l == lang) { - self.doc_lang = Some(lang); - return None; - } - if let Some(last) = self.stack.last_mut() - && last.lang.is_none_or(|l| l == lang) - { - last.lang = Some(lang); - return None; - } - Some(lang) - } - - /// Resolves [`Placeholder`] nodes. - fn resolve_node(&mut self, node: TagNode) -> Node { - match node { - TagNode::Group(TagGroup { tag, nodes }) => { - let children = nodes - .into_iter() - .map(|node| self.resolve_node(node)) - .collect::>(); - Node::Group(krilla::tagging::TagGroup::with_children(tag, children)) - } - TagNode::Leaf(identifier) => Node::Leaf(identifier), - TagNode::Placeholder(placeholder) => self.placeholders.take(placeholder), - TagNode::FootnoteEntry(loc) => { - let node = (self.footnotes.remove(&loc)) - .and_then(|ctx| ctx.entry) - .expect("footnote"); - self.resolve_node(node) - } - } - } - - pub fn next_link_id(&mut self) -> LinkId { - self.link_id.0 += 1; - self.link_id - } - - fn next_table_id(&mut self) -> TableId { - self.table_id.0 += 1; - self.table_id - } -} - -#[derive(Debug)] -pub struct TagStack { - items: Vec, - /// The index of the topmost stack entry that has a bbox. - bbox_idx: Option, -} - -impl> std::ops::Index for TagStack { - type Output = I::Output; - - #[inline] - fn index(&self, index: I) -> &Self::Output { - std::ops::Index::index(&self.items, index) - } -} - -impl> std::ops::IndexMut for TagStack { - #[inline] - fn index_mut(&mut self, index: I) -> &mut Self::Output { - std::ops::IndexMut::index_mut(&mut self.items, index) - } -} - -impl TagStack { - pub fn new() -> Self { - Self { items: Vec::new(), bbox_idx: None } - } - - pub fn len(&self) -> usize { - self.items.len() - } - - pub fn last_mut(&mut self) -> Option<&mut StackEntry> { - self.items.last_mut() - } - - pub fn iter(&self) -> std::slice::Iter<'_, StackEntry> { - self.items.iter() - } - - pub fn push(&mut self, entry: StackEntry) { - if entry.kind.bbox().is_some() { - self.bbox_idx = Some(self.len()); - } - self.items.push(entry); - } - - pub fn extend(&mut self, iter: impl IntoIterator) { - let start = self.len(); - self.items.extend(iter); - let last_bbox_offset = self.items[start..] - .iter() - .rposition(|entry| entry.kind.bbox().is_some()); - if let Some(offset) = last_bbox_offset { - self.bbox_idx = Some(start + offset); - } - } - - /// Remove the last stack entry if the predicate returns true. - /// This takes care of updating the parent bboxes. - pub fn pop_if( - &mut self, - mut predicate: impl FnMut(&mut StackEntry) -> bool, - ) -> Option { - let last = self.items.last_mut()?; - if predicate(last) { self.pop() } else { None } - } - - /// Remove the last stack entry. - /// This takes care of updating the parent bboxes. - pub fn pop(&mut self) -> Option { - let removed = self.items.pop()?; - - let Some(inner_bbox) = removed.kind.bbox() else { return Some(removed) }; - - self.bbox_idx = self.items.iter_mut().enumerate().rev().find_map(|(i, entry)| { - let outer_bbox = entry.kind.bbox_mut()?; - if let Some((page_idx, rect)) = inner_bbox.rect { - outer_bbox.expand_page(page_idx, rect); - } - Some(i) - }); - - Some(removed) - } - - pub fn parent(&mut self) -> Option<&mut StackEntryKind> { - self.items.last_mut().map(|e| &mut e.kind) - } - - pub fn parent_table(&mut self) -> Option<&mut TableCtx> { - self.parent()?.as_table_mut() - } - - pub fn parent_list(&mut self) -> Option<&mut ListCtx> { - self.parent()?.as_list_mut() - } - - pub fn parent_figure(&mut self) -> Option<&mut FigureCtx> { - self.parent()?.as_figure_mut() - } - - pub fn parent_outline(&mut self) -> Option<(&mut OutlineCtx, &mut Vec)> { - self.items.last_mut().and_then(|e| { - let ctx = e.kind.as_outline_mut()?; - Some((ctx, &mut e.nodes)) - }) - } - - pub fn parent_outline_entry(&mut self) -> Option<&mut OutlineEntry> { - self.parent()?.as_outline_entry_mut() - } - - pub fn find_parent_link( - &mut self, - ) -> Option<(LinkId, &Packed, &mut Vec)> { - self.items.iter_mut().rev().find_map(|e| { - let (link_id, link) = e.kind.as_link()?; - Some((link_id, link, &mut e.nodes)) - }) - } - - /// Finds the first parent that has a bounding box. - pub fn find_parent_bbox(&mut self) -> Option<&mut BBoxCtx> { - self.items[self.bbox_idx?].kind.bbox_mut() - } -} - -pub struct Placeholders(Vec>); - -impl Placeholders { - pub fn reserve(&mut self) -> Placeholder { - let idx = self.0.len(); - self.0.push(OnceCell::new()); - Placeholder(idx) - } - - pub fn init(&mut self, placeholder: Placeholder, node: Node) { - self.0[placeholder.0] - .set(node) - .map_err(|_| ()) - .expect("placeholder to be uninitialized"); - } - - pub fn take(&mut self, placeholder: Placeholder) -> Node { - self.0[placeholder.0].take().expect("initialized placeholder node") - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct TableId(u32); - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct LinkId(u32); - -#[derive(Debug)] -pub struct StackEntry { - pub loc: Location, - pub span: Span, - pub lang: Option, - pub kind: StackEntryKind, - pub nodes: Vec, -} - -#[derive(Clone, Debug)] -pub enum StackEntryKind { - Standard(TagKind), - Outline(OutlineCtx), - OutlineEntry(Packed), - Table(TableCtx), - TableCell(Packed), - List(ListCtx), - ListItemLabel, - ListItemBody, - BibEntry, - Figure(FigureCtx), - Formula(FigureCtx), - Link(LinkId, Packed), - /// The footnote reference in the text, contains the declaration location. - FootnoteRef(Location), - /// The footnote entry at the end of the page. Contains the [`Location`] of - /// the [`FootnoteElem`](typst_library::model::FootnoteElem). - FootnoteEntry(Location), - CodeBlock, - CodeBlockLine, -} - -impl StackEntryKind { - pub fn as_outline_mut(&mut self) -> Option<&mut OutlineCtx> { - if let Self::Outline(v) = self { Some(v) } else { None } - } - - pub fn as_outline_entry_mut(&mut self) -> Option<&mut OutlineEntry> { - if let Self::OutlineEntry(v) = self { Some(v) } else { None } - } - - pub fn as_table_mut(&mut self) -> Option<&mut TableCtx> { - if let Self::Table(v) = self { Some(v) } else { None } - } - - pub fn as_list_mut(&mut self) -> Option<&mut ListCtx> { - if let Self::List(v) = self { Some(v) } else { None } - } - - pub fn as_figure_mut(&mut self) -> Option<&mut FigureCtx> { - if let Self::Figure(v) = self { Some(v) } else { None } - } - - pub fn as_link(&self) -> Option<(LinkId, &Packed)> { - if let Self::Link(id, link) = self { Some((*id, link)) } else { None } - } - - pub fn is_code_block(&self) -> bool { - matches!(self, Self::CodeBlock) - } - - pub fn bbox(&self) -> Option<&BBoxCtx> { - match self { - Self::Table(ctx) => Some(&ctx.bbox), - Self::Figure(ctx) => Some(&ctx.bbox), - Self::Formula(ctx) => Some(&ctx.bbox), - _ => None, - } - } - - pub fn bbox_mut(&mut self) -> Option<&mut BBoxCtx> { - match self { - Self::Table(ctx) => Some(&mut ctx.bbox), - Self::Figure(ctx) => Some(&mut ctx.bbox), - Self::Formula(ctx) => Some(&mut ctx.bbox), - _ => None, - } - } - - fn is_breakable(&self, is_pdf_ua: bool) -> bool { - match self { - StackEntryKind::Standard(tag) => match tag { - TagKind::Part(_) => !is_pdf_ua, - TagKind::Article(_) => !is_pdf_ua, - TagKind::Section(_) => !is_pdf_ua, - TagKind::Div(_) => !is_pdf_ua, - TagKind::BlockQuote(_) => !is_pdf_ua, - TagKind::Caption(_) => !is_pdf_ua, - TagKind::TOC(_) => false, - TagKind::TOCI(_) => false, - TagKind::Index(_) => false, - TagKind::P(_) => true, - TagKind::Hn(_) => !is_pdf_ua, - TagKind::L(_) => false, - TagKind::LI(_) => false, - TagKind::Lbl(_) => !is_pdf_ua, - TagKind::LBody(_) => !is_pdf_ua, - TagKind::Table(_) => false, - TagKind::TR(_) => false, - TagKind::TH(_) => false, - TagKind::TD(_) => false, - TagKind::THead(_) => false, - TagKind::TBody(_) => false, - TagKind::TFoot(_) => false, - TagKind::Span(_) => true, - TagKind::InlineQuote(_) => !is_pdf_ua, - TagKind::Note(_) => !is_pdf_ua, - TagKind::Reference(_) => !is_pdf_ua, - TagKind::BibEntry(_) => !is_pdf_ua, - TagKind::Code(_) => !is_pdf_ua, - TagKind::Link(_) => !is_pdf_ua, - TagKind::Annot(_) => !is_pdf_ua, - TagKind::Figure(_) => !is_pdf_ua, - TagKind::Formula(_) => !is_pdf_ua, - TagKind::NonStruct(_) => !is_pdf_ua, - TagKind::Datetime(_) => !is_pdf_ua, - TagKind::Terms(_) => !is_pdf_ua, - TagKind::Title(_) => !is_pdf_ua, - TagKind::Strong(_) => !is_pdf_ua, - TagKind::Em(_) => !is_pdf_ua, - }, - StackEntryKind::Outline(_) => false, - StackEntryKind::OutlineEntry(_) => false, - StackEntryKind::Table(_) => false, - StackEntryKind::TableCell(_) => false, - StackEntryKind::List(_) => false, - StackEntryKind::ListItemLabel => false, - StackEntryKind::ListItemBody => false, - StackEntryKind::BibEntry => false, - StackEntryKind::Figure(_) => false, - StackEntryKind::Formula(_) => false, - StackEntryKind::Link(..) => !is_pdf_ua, - StackEntryKind::FootnoteRef(_) => false, - StackEntryKind::FootnoteEntry(_) => false, - StackEntryKind::CodeBlock => false, - StackEntryKind::CodeBlockLine => false, - } - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct FootnoteCtx { - /// Whether this footenote has been referenced inside the document. The - /// entry will be inserted inside the reading order after the first - /// reference. All other references will still have links to the footnote. - is_referenced: bool, - /// The nodes that make up the footnote entry. - entry: Option, -} - -impl FootnoteCtx { - pub const fn new() -> Self { - Self { is_referenced: false, entry: None } - } -} - -/// Figure/Formula context -#[derive(Debug, Clone, PartialEq)] -pub struct FigureCtx { - alt: Option, - bbox: BBoxCtx, -} - -impl FigureCtx { - fn new(alt: Option) -> Self { - Self { alt, bbox: BBoxCtx::new() } - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct BBoxCtx { - rect: Option<(usize, Rect)>, - multi_page: bool, -} - -impl BBoxCtx { - pub fn new() -> Self { - Self { rect: None, multi_page: false } - } - - pub fn reset(&mut self) { - *self = Self::new(); - } - - /// Expand the bounding box with a `rect` relative to the current frame - /// context transform. - pub fn expand_frame(&mut self, fc: &FrameContext, rect: Rect) { - let Some(page_idx) = fc.page_idx else { return }; - if self.multi_page { - return; - } - let (idx, bbox) = self.rect.get_or_insert(( - page_idx, - Rect::new(Point::splat(Abs::inf()), Point::splat(-Abs::inf())), - )); - if *idx != page_idx { - self.multi_page = true; - self.rect = None; - return; - } - - let size = rect.size(); - for point in [ - rect.min, - rect.min + Point::with_x(size.x), - rect.min + Point::with_y(size.y), - rect.max, - ] { - let p = point.transform(fc.state().transform()); - bbox.min = bbox.min.min(p); - bbox.max = bbox.max.max(p); - } - } - - /// Expand the bounding box with a rectangle that's already transformed into - /// page coordinates. - pub fn expand_page(&mut self, page_idx: usize, rect: Rect) { - if self.multi_page { - return; - } - let (idx, bbox) = self.rect.get_or_insert(( - page_idx, - Rect::new(Point::splat(Abs::inf()), Point::splat(-Abs::inf())), - )); - if *idx != page_idx { - self.multi_page = true; - self.rect = None; - return; - } - - bbox.min = bbox.min.min(rect.min); - bbox.max = bbox.max.max(rect.max); - } - - pub fn get(&self) -> Option { - let (page_idx, rect) = self.rect?; - let rect = kg::Rect::from_ltrb( - rect.min.x.to_f32(), - rect.min.y.to_f32(), - rect.max.x.to_f32(), - rect.max.y.to_f32(), - ) - .unwrap(); - Some(BBox::new(page_idx, rect)) - } -} - -#[derive(Debug, Clone, PartialEq)] -pub enum TagNode { - Group(TagGroup), - Leaf(Identifier), - /// Allows inserting a placeholder into the tag tree. - /// Currently used for [`krilla::page::Page::add_tagged_annotation`]. - Placeholder(Placeholder), - FootnoteEntry(Location), -} - -impl TagNode { - pub fn group(tag: impl Into, contents: GroupContents) -> Self { - let lang = contents.lang.map(|l| l.as_str().to_string()); - let tag = tag - .into() - .with_lang(lang) - .with_location(Some(contents.span.into_raw())); - TagNode::Group(TagGroup { tag, nodes: contents.nodes }) - } - - /// A tag group not directly related to a typst element, generated to - /// accomodate the tag structure. - pub fn virtual_group(tag: impl Into, nodes: Vec) -> Self { - let tag = tag.into(); - TagNode::Group(TagGroup { tag, nodes }) - } - - pub fn empty_group(tag: impl Into) -> Self { - Self::virtual_group(tag, Vec::new()) - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct TagGroup { - tag: TagKind, - nodes: Vec, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct GroupContents { - span: Span, - lang: Option, - nodes: Vec, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct Placeholder(usize); - /// Automatically calls [`Surface::end_tagged`] when dropped. pub struct TagHandle<'a, 'b> { surface: &'b mut Surface<'a>, diff --git a/crates/typst-pdf/src/tags/table.rs b/crates/typst-pdf/src/tags/table.rs index aff5b9f2ab..4ceb039d7e 100644 --- a/crates/typst-pdf/src/tags/table.rs +++ b/crates/typst-pdf/src/tags/table.rs @@ -312,7 +312,7 @@ fn should_group_rows(a: TableCellKind, b: TableCellKind) -> bool { fn table_cell_id(table_id: TableId, x: u32, y: u32) -> TagId { let mut buf = SmallVec::<[u8; 32]>::new(); - _ = write!(&mut buf, "{}x{x}y{y}", table_id.0); + _ = write!(&mut buf, "{}x{x}y{y}", table_id.get()); TagId::from(buf) } From 80768afc34121c7e319c91327348fa269d0f7456 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Fri, 1 Aug 2025 16:02:58 +0200 Subject: [PATCH 419/558] Generate Span tags with text attributes - underline, overline, and strike text decorations - highlight text decoration - super- and sub-script Artifact content ids are no longer added to the tag tree. This greatly simplifies the grouping of text items into span tags with text decoration attributes. Artifact IDs are dummy ids anyway and more of an implementation detail of how krilla writes marked content/artifacts. --- crates/typst-pdf/src/convert.rs | 2 +- crates/typst-pdf/src/image.rs | 9 +- crates/typst-pdf/src/lib.rs | 9 + crates/typst-pdf/src/shape.rs | 5 +- crates/typst-pdf/src/tags/context.rs | 54 +++- crates/typst-pdf/src/tags/mod.rs | 226 +++++++++----- crates/typst-pdf/src/tags/text.rs | 283 ++++++++++++++++++ crates/typst-pdf/src/text.rs | 9 +- .../ref/pdftags/deco-tags-different-color.yml | 13 + .../deco-tags-different-stroke-thickness.yml | 13 + .../ref/pdftags/deco-tags-different-type.yml | 14 + .../ref/pdftags/deco-tags-highlight-basic.yml | 9 + .../deco-tags-script-custom-baseline.yml | 13 + .../ref/pdftags/deco-tags-subscript-basic.yml | 14 + .../pdftags/deco-tags-superscript-basic.yml | 10 + tests/ref/pdftags/deco-tags-underline.yml | 15 + tests/ref/pdftags/grid-headers.yml | 75 ----- ...ist-tags-complex-item-with-nested-list.yml | 13 +- .../list-tags-complex-item-with-sub-list.yml | 13 +- tests/ref/pdftags/table-tags-basic.yml | 8 - .../table-tags-column-and-row-header.yml | 8 - .../ref/pdftags/table-tags-missing-cells.yml | 11 - tests/ref/pdftags/terms-constructor.yml | 2 - tests/suite/pdftags/deco.typ | 49 +++ 24 files changed, 664 insertions(+), 213 deletions(-) create mode 100644 crates/typst-pdf/src/tags/text.rs create mode 100644 tests/ref/pdftags/deco-tags-different-color.yml create mode 100644 tests/ref/pdftags/deco-tags-different-stroke-thickness.yml create mode 100644 tests/ref/pdftags/deco-tags-different-type.yml create mode 100644 tests/ref/pdftags/deco-tags-highlight-basic.yml create mode 100644 tests/ref/pdftags/deco-tags-script-custom-baseline.yml create mode 100644 tests/ref/pdftags/deco-tags-subscript-basic.yml create mode 100644 tests/ref/pdftags/deco-tags-superscript-basic.yml create mode 100644 tests/ref/pdftags/deco-tags-underline.yml create mode 100644 tests/suite/pdftags/deco.typ diff --git a/crates/typst-pdf/src/convert.rs b/crates/typst-pdf/src/convert.rs index 79c1d15c78..369e273ca4 100644 --- a/crates/typst-pdf/src/convert.rs +++ b/crates/typst-pdf/src/convert.rs @@ -331,7 +331,7 @@ pub(crate) fn handle_frame( handle_image(gc, fc, image, *size, surface, *span)? } FrameItem::Link(dest, size) => handle_link(fc, gc, dest, *size), - FrameItem::Tag(Tag::Start(elem)) => tags::handle_start(gc, surface, elem), + FrameItem::Tag(Tag::Start(elem)) => tags::handle_start(gc, surface, elem)?, FrameItem::Tag(Tag::End(loc, _)) => tags::handle_end(gc, surface, *loc)?, } diff --git a/crates/typst-pdf/src/image.rs b/crates/typst-pdf/src/image.rs index 89b11b1183..50e016272a 100644 --- a/crates/typst-pdf/src/image.rs +++ b/crates/typst-pdf/src/image.rs @@ -5,11 +5,10 @@ use image::{DynamicImage, EncodableLayout, GenericImageView, Rgba}; use krilla::image::{BitsPerComponent, CustomImage, ImageColorspace}; use krilla::pdf::PdfDocument; use krilla::surface::Surface; -use krilla::tagging::SpanTag; use krilla_svg::{SurfaceExt, SvgSettings}; use typst_library::diag::{SourceResult, bail}; use typst_library::foundations::Smart; -use typst_library::layout::{Abs, Angle, Point, Ratio, Rect, Size, Transform}; +use typst_library::layout::{Abs, Angle, Ratio, Size, Transform}; use typst_library::visualize::{ ExchangeFormat, Image, ImageKind, ImageScaling, PdfImage, RasterFormat, RasterImage, }; @@ -35,11 +34,9 @@ pub(crate) fn handle_image( gc.image_spans.insert(span); - tags::update_bbox(gc, fc, || Rect::from_pos_size(Point::zero(), size)); - - let mut handle = - tags::start_span(gc, surface, SpanTag::empty().with_alt_text(image.alt())); + let mut handle = tags::image(gc, fc, surface, image, size); let surface = handle.surface(); + match image.kind() { ImageKind::Raster(raster) => { let (exif_transform, new_size) = exif_transform(raster, size); diff --git a/crates/typst-pdf/src/lib.rs b/crates/typst-pdf/src/lib.rs index 143c9ca275..d1a30aa228 100644 --- a/crates/typst-pdf/src/lib.rs +++ b/crates/typst-pdf/src/lib.rs @@ -18,6 +18,7 @@ pub use self::metadata::{Timestamp, Timezone}; use std::fmt::{self, Debug, Formatter}; use ecow::eco_format; +use krilla::configure::Validator; use serde::{Deserialize, Serialize}; use typst_library::diag::{SourceResult, StrResult, bail}; use typst_library::foundations::Smart; @@ -67,6 +68,14 @@ pub struct PdfOptions<'a> { pub disable_tags: bool, } +impl PdfOptions<'_> { + /// Whether the current export mode is PDF/UA-1, and in the future maybe + /// PDF/UA-2. + pub(crate) fn is_pdf_ua(&self) -> bool { + self.standards.config.validator() == Validator::UA1 + } +} + /// Encapsulates a list of compatible PDF standards. #[derive(Clone)] pub struct PdfStandards { diff --git a/crates/typst-pdf/src/shape.rs b/crates/typst-pdf/src/shape.rs index 8546678446..2fa5dacebb 100644 --- a/crates/typst-pdf/src/shape.rs +++ b/crates/typst-pdf/src/shape.rs @@ -1,7 +1,6 @@ use krilla::geom::{Path, PathBuilder, Rect}; use krilla::surface::Surface; use typst_library::diag::SourceResult; -use typst_library::pdf::ArtifactKind; use typst_library::visualize::{Geometry, Shape}; use typst_syntax::Span; @@ -17,9 +16,7 @@ pub(crate) fn handle_shape( gc: &mut GlobalContext, span: Span, ) -> SourceResult<()> { - tags::update_bbox(gc, fc, || shape.geometry.bbox()); - - let mut handle = tags::start_artifact(gc, surface, ArtifactKind::Other); + let mut handle = tags::shape(gc, fc, surface, shape); let surface = handle.surface(); surface.set_location(span.into_raw()); diff --git a/crates/typst-pdf/src/tags/context.rs b/crates/typst-pdf/src/tags/context.rs index 9f16e0f83c..990f11f740 100644 --- a/crates/typst-pdf/src/tags/context.rs +++ b/crates/typst-pdf/src/tags/context.rs @@ -2,7 +2,7 @@ use std::cell::OnceCell; use std::slice::SliceIndex; use krilla::geom as kg; -use krilla::tagging::{BBox, Node, TagKind, TagTree}; +use krilla::tagging::{BBox, Identifier, Node, TagKind, TagTree}; use rustc_hash::FxHashMap; use typst_library::foundations::Packed; use typst_library::introspection::Location; @@ -16,12 +16,15 @@ use crate::convert::FrameContext; use crate::tags::list::ListCtx; use crate::tags::outline::OutlineCtx; use crate::tags::table::TableCtx; -use crate::tags::{Placeholder, TagGroup, TagNode}; +use crate::tags::text::{ResolvedTextAttrs, TextAttrs}; +use crate::tags::{Placeholder, TagNode}; use crate::util::AbsExt; pub struct Tags { /// The language of the first text item that has been encountered. pub doc_lang: Option, + /// The set of text attributes. + pub text_attrs: TextAttrs, /// The intermediary stack of nested tag groups. pub stack: TagStack, /// A list of placeholders corresponding to a [`TagNode::Placeholder`]. @@ -39,13 +42,14 @@ pub struct Tags { table_id: TableId, /// The output. - pub tree: Vec, + tree: Vec, } impl Tags { pub fn new() -> Self { Self { doc_lang: None, + text_attrs: TextAttrs::new(), stack: TagStack::new(), placeholders: Placeholders(Vec::new()), footnotes: FxHashMap::default(), @@ -66,6 +70,28 @@ impl Tags { } } + pub fn push_text(&mut self, new_attrs: ResolvedTextAttrs, id: Identifier) { + if new_attrs.is_empty() { + self.push(TagNode::Leaf(id)); + return; + } + + // FIXME: Artifacts will force a split in the spans, and decoartions + // generate artifacts + let last_node = if let Some(entry) = self.stack.last_mut() { + entry.nodes.last_mut() + } else { + self.tree.last_mut() + }; + if let Some(TagNode::Text(prev_attrs, nodes)) = last_node + && *prev_attrs == new_attrs + { + nodes.push(id); + } else { + self.push(TagNode::Text(new_attrs, vec![id])); + } + } + pub fn extend(&mut self, nodes: impl IntoIterator) { if let Some(entry) = self.stack.last_mut() { entry.nodes.extend(nodes); @@ -89,10 +115,6 @@ impl Tags { /// this will return `Some`, and the language should be specified on the /// marked content directly. pub fn try_set_lang(&mut self, lang: Lang) -> Option { - // Discard languages within artifacts. - if self.in_artifact.is_some() { - return None; - } if self.doc_lang.is_none_or(|l| l == lang) { self.doc_lang = Some(lang); return None; @@ -106,15 +128,15 @@ impl Tags { Some(lang) } - /// Resolves [`Placeholder`] nodes. + /// Resolves nodes into an accumulator. fn resolve_node(&mut self, node: TagNode) -> Node { match node { - TagNode::Group(TagGroup { tag, nodes }) => { - let children = nodes - .into_iter() + TagNode::Group(group) => { + let nodes = (group.nodes.into_iter()) .map(|node| self.resolve_node(node)) - .collect::>(); - Node::Group(krilla::tagging::TagGroup::with_children(tag, children)) + .collect(); + let group = krilla::tagging::TagGroup::with_children(group.tag, nodes); + Node::Group(group) } TagNode::Leaf(identifier) => Node::Leaf(identifier), TagNode::Placeholder(placeholder) => self.placeholders.take(placeholder), @@ -124,6 +146,12 @@ impl Tags { .expect("footnote"); self.resolve_node(node) } + TagNode::Text(attrs, ids) => { + let tag = attrs.to_tag(); + let children = ids.into_iter().map(Node::Leaf).collect(); + let group = krilla::tagging::TagGroup::with_children(tag, children); + Node::Group(group) + } } } diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index de934524a6..52d5c88236 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -10,7 +10,7 @@ use krilla::tagging::{ use typst_library::diag::{SourceResult, bail}; use typst_library::foundations::Content; use typst_library::introspection::Location; -use typst_library::layout::{Rect, RepeatElem}; +use typst_library::layout::{Point, Rect, RepeatElem, Size}; use typst_library::math::EquationElem; use typst_library::model::{ EnumElem, FigureCaption, FigureElem, FootnoteEntry, HeadingElem, LinkMarker, @@ -18,8 +18,11 @@ use typst_library::model::{ TermsElem, }; use typst_library::pdf::{ArtifactElem, ArtifactKind, PdfMarkerTag, PdfMarkerTagKind}; -use typst_library::text::{Lang, RawElem, RawLine}; -use typst_library::visualize::ImageElem; +use typst_library::text::{ + HighlightElem, Lang, OverlineElem, RawElem, RawLine, ScriptKind, StrikeElem, SubElem, + SuperElem, TextItem, UnderlineElem, +}; +use typst_library::visualize::{Image, ImageElem, Shape}; use typst_syntax::Span; use crate::convert::{FrameContext, GlobalContext}; @@ -27,7 +30,8 @@ use crate::link::LinkAnnotation; use crate::tags::list::ListCtx; use crate::tags::outline::OutlineCtx; use crate::tags::table::TableCtx; -use crate::tags::util::{PropertyOptRef, PropertyValCopied}; +use crate::tags::text::{ResolvedTextAttrs, TextDecoKind}; +use crate::tags::util::{PropertyOptRef, PropertyValCloned, PropertyValCopied}; pub use context::*; @@ -35,6 +39,7 @@ mod context; mod list; mod outline; mod table; +mod text; mod util; #[derive(Debug, Clone, PartialEq)] @@ -45,6 +50,9 @@ pub enum TagNode { /// Currently used for [`krilla::page::Page::add_tagged_annotation`]. Placeholder(Placeholder), FootnoteEntry(Location), + /// If the attributes are non-empty this will resolve to a [`Tag::Span`], + /// otherwise the items are inserted directly. + Text(ResolvedTextAttrs, Vec), } impl TagNode { @@ -85,75 +93,79 @@ pub struct GroupContents { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Placeholder(usize); -pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Content) { +pub fn handle_start( + gc: &mut GlobalContext, + surface: &mut Surface, + elem: &Content, +) -> SourceResult<()> { if gc.options.disable_tags { - return; + return Ok(()); } if gc.tags.in_artifact.is_some() { // Don't nest artifacts - return; + return Ok(()); } if let Some(artifact) = elem.to_packed::() { let kind = artifact.kind.val(); push_artifact(gc, surface, elem, kind); - return; + return Ok(()); } else if let Some(_) = elem.to_packed::() { push_artifact(gc, surface, elem, ArtifactKind::Other); - return; + return Ok(()); } let tag: TagKind = if let Some(tag) = elem.to_packed::() { match &tag.kind { PdfMarkerTagKind::OutlineBody => { push_stack(gc, elem, StackEntryKind::Outline(OutlineCtx::new())); - return; + return Ok(()); } PdfMarkerTagKind::FigureBody(alt) => { let alt = alt.as_ref().map(|s| s.to_string()); push_stack(gc, elem, StackEntryKind::Figure(FigureCtx::new(alt))); - return; + return Ok(()); } PdfMarkerTagKind::FootnoteRef(decl_loc) => { push_stack(gc, elem, StackEntryKind::FootnoteRef(*decl_loc)); - return; + return Ok(()); } PdfMarkerTagKind::Bibliography(numbered) => { let numbering = if *numbered { ListNumbering::Decimal } else { ListNumbering::None }; push_stack(gc, elem, StackEntryKind::List(ListCtx::new(numbering))); - return; + return Ok(()); } PdfMarkerTagKind::BibEntry => { push_stack(gc, elem, StackEntryKind::BibEntry); - return; + return Ok(()); } PdfMarkerTagKind::ListItemLabel => { push_stack(gc, elem, StackEntryKind::ListItemLabel); - return; + return Ok(()); } PdfMarkerTagKind::ListItemBody => { push_stack(gc, elem, StackEntryKind::ListItemBody); - return; + return Ok(()); } PdfMarkerTagKind::Label => Tag::Lbl.into(), } } else if let Some(entry) = elem.to_packed::() { push_stack(gc, elem, StackEntryKind::OutlineEntry(entry.clone())); - return; + return Ok(()); } else if let Some(_list) = elem.to_packed::() { let numbering = ListNumbering::Circle; // TODO: infer numbering from `list.marker` push_stack(gc, elem, StackEntryKind::List(ListCtx::new(numbering))); - return; + return Ok(()); } else if let Some(_enumeration) = elem.to_packed::() { let numbering = ListNumbering::Decimal; // TODO: infer numbering from `enum.numbering` push_stack(gc, elem, StackEntryKind::List(ListCtx::new(numbering))); - return; + return Ok(()); } else if let Some(_terms) = elem.to_packed::() { let numbering = ListNumbering::None; push_stack(gc, elem, StackEntryKind::List(ListCtx::new(numbering))); - return; + return Ok(()); } else if let Some(_) = elem.to_packed::() { // Wrap the figure tag and the sibling caption in a container, if the // caption is contained within the figure like recommended for tables @@ -172,7 +184,7 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten } else { push_stack(gc, elem, StackEntryKind::Figure(FigureCtx::new(alt))); } - return; + return Ok(()); } else if let Some(equation) = elem.to_packed::() { let alt = equation.alt.opt_ref().map(|s| s.to_string()); if let Some(figure_ctx) = gc.tags.stack.parent_figure() { @@ -182,13 +194,13 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten } } push_stack(gc, elem, StackEntryKind::Formula(FigureCtx::new(alt))); - return; + return Ok(()); } else if let Some(table) = elem.to_packed::() { let table_id = gc.tags.next_table_id(); let summary = table.summary.opt_ref().map(|s| s.to_string()); let ctx = TableCtx::new(table_id, summary); push_stack(gc, elem, StackEntryKind::Table(ctx)); - return; + return Ok(()); } else if let Some(cell) = elem.to_packed::() { let table_ctx = gc.tags.stack.parent_table(); @@ -201,7 +213,7 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten } else { push_stack(gc, elem, StackEntryKind::TableCell(cell.clone())); } - return; + return Ok(()); } else if let Some(heading) = elem.to_packed::() { let level = heading.level().try_into().unwrap_or(NonZeroU16::MAX); let name = heading.body.plain_text().to_string(); @@ -211,18 +223,18 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten } else if let Some(link) = elem.to_packed::() { let link_id = gc.tags.next_link_id(); push_stack(gc, elem, StackEntryKind::Link(link_id, link.clone())); - return; + return Ok(()); } else if let Some(entry) = elem.to_packed::() { let footnote_loc = entry.note.location().unwrap(); push_stack(gc, elem, StackEntryKind::FootnoteEntry(footnote_loc)); - return; + return Ok(()); } else if let Some(quote) = elem.to_packed::() { // TODO: should the attribution be handled somehow? if quote.block.val() { Tag::BlockQuote.into() } else { Tag::InlineQuote.into() } } else if let Some(raw) = elem.to_packed::() { if raw.block.val() { push_stack(gc, elem, StackEntryKind::CodeBlock); - return; + return Ok(()); } else { Tag::Code.into() } @@ -231,13 +243,46 @@ pub fn handle_start(gc: &mut GlobalContext, surface: &mut Surface, elem: &Conten if gc.tags.stack.parent().is_some_and(|p| p.is_code_block()) { push_stack(gc, elem, StackEntryKind::CodeBlockLine); } - return; + return Ok(()); + } else if let Some(sub) = elem.to_packed::() { + let baseline_shift = sub.baseline.val(); + let lineheight = sub.size.val(); + let kind = ScriptKind::Sub; + gc.tags.text_attrs.push_script(elem, kind, baseline_shift, lineheight); + return Ok(()); + } else if let Some(sub) = elem.to_packed::() { + let baseline_shift = sub.baseline.val(); + let lineheight = sub.size.val(); + let kind = ScriptKind::Super; + gc.tags.text_attrs.push_script(elem, kind, baseline_shift, lineheight); + return Ok(()); + } else if let Some(highlight) = elem.to_packed::() { + let paint = highlight.fill.opt_ref(); + gc.tags.text_attrs.push_highlight(elem, paint); + return Ok(()); + } else if let Some(underline) = elem.to_packed::() { + let kind = TextDecoKind::Underline; + let stroke = underline.stroke.val_cloned(); + gc.tags.text_attrs.push_deco(gc.options, elem, kind, stroke)?; + return Ok(()); + } else if let Some(overline) = elem.to_packed::() { + let kind = TextDecoKind::Overline; + let stroke = overline.stroke.val_cloned(); + gc.tags.text_attrs.push_deco(gc.options, elem, kind, stroke)?; + return Ok(()); + } else if let Some(strike) = elem.to_packed::() { + let kind = TextDecoKind::Strike; + let stroke = strike.stroke.val_cloned(); + gc.tags.text_attrs.push_deco(gc.options, elem, kind, stroke)?; + return Ok(()); } else { - return; + return Ok(()); }; let tag = tag.with_location(Some(elem.span().into_raw())); push_stack(gc, elem, StackEntryKind::Standard(tag)); + + Ok(()) } fn push_stack(gc: &mut GlobalContext, elem: &Content, kind: StackEntryKind) { @@ -256,8 +301,7 @@ fn push_artifact( ) { let loc = elem.location().expect("elem to be locatable"); let ty = artifact_type(kind); - let id = surface.start_tagged(ContentTag::Artifact(ty)); - gc.tags.push(TagNode::Leaf(id)); + surface.start_tagged(ContentTag::Artifact(ty)); gc.tags.in_artifact = Some((loc, kind)); } @@ -283,6 +327,10 @@ pub fn handle_end( return Ok(()); } + if gc.tags.text_attrs.pop(loc) { + return Ok(()); + } + // Search for an improperly nested starting tag, that is being closed. let Some(idx) = (gc.tags.stack.iter().enumerate()) .rev() @@ -294,11 +342,10 @@ pub fn handle_end( // There are overlapping tags in the tag tree. Figure whether breaking // up the current tag stack is semantically ok. - let is_pdf_ua = gc.options.standards.config.validator() == Validator::UA1; let mut is_breakable = true; let mut non_breakable_span = Span::detached(); for e in gc.tags.stack[idx + 1..].iter() { - if e.kind.is_breakable(is_pdf_ua) { + if e.kind.is_breakable(gc.options.is_pdf_ua()) { continue; } @@ -309,12 +356,12 @@ pub fn handle_end( } } if !is_breakable { - let validator = gc.options.standards.config.validator(); - if is_pdf_ua { - let ua1 = validator.as_str(); + if gc.options.is_pdf_ua() { + let validator = gc.options.standards.config.validator(); + let validator = validator.as_str(); bail!( non_breakable_span, - "{ua1} error: invalid semantic structure, \ + "{validator} error: invalid semantic structure, \ this element's tag would be split up"; hint: "maybe this is caused by a `parbreak`, `colbreak`, or `pagebreak`" ); @@ -478,8 +525,7 @@ pub fn page_start(gc: &mut GlobalContext, surface: &mut Surface) { if let Some((_, kind)) = gc.tags.in_artifact { let ty = artifact_type(kind); - let id = surface.start_tagged(ContentTag::Artifact(ty)); - gc.tags.push(TagNode::Leaf(id)); + surface.start_tagged(ContentTag::Artifact(ty)); } } @@ -518,7 +564,66 @@ pub fn add_link_annotations( } } -pub fn update_bbox( +pub fn text<'a, 'b>( + gc: &mut GlobalContext, + fc: &FrameContext, + surface: &'b mut Surface<'a>, + text: &TextItem, +) -> TagHandle<'a, 'b> { + if gc.options.disable_tags { + return TagHandle { surface, started: false }; + } + + update_bbox(gc, fc, || text.bbox()); + + if gc.tags.in_artifact.is_some() { + return TagHandle { surface, started: false }; + } + + let attrs = gc.tags.text_attrs.resolve(text); + + // Marked content + let lang = gc.tags.try_set_lang(text.lang); + let lang = lang.as_ref().map(Lang::as_str); + let content = ContentTag::Span(SpanTag::empty().with_lang(lang)); + let id = surface.start_tagged(content); + + gc.tags.push_text(attrs, id); + + TagHandle { surface, started: true } +} + +pub fn image<'a, 'b>( + gc: &mut GlobalContext, + fc: &FrameContext, + surface: &'b mut Surface<'a>, + image: &Image, + size: Size, +) -> TagHandle<'a, 'b> { + if gc.options.disable_tags { + return TagHandle { surface, started: false }; + } + + update_bbox(gc, fc, || Rect::from_pos_size(Point::zero(), size)); + let content = ContentTag::Span(SpanTag::empty().with_alt_text(image.alt())); + start_content(gc, surface, content) +} + +pub fn shape<'a, 'b>( + gc: &mut GlobalContext, + fc: &FrameContext, + surface: &'b mut Surface<'a>, + shape: &Shape, +) -> TagHandle<'a, 'b> { + if gc.options.disable_tags { + return TagHandle { surface, started: false }; + } + + update_bbox(gc, fc, || shape.geometry.bbox()); + start_content(gc, surface, ContentTag::Artifact(ArtifactType::Other)) +} + +fn update_bbox( gc: &mut GlobalContext, fc: &FrameContext, compute_bbox: impl FnOnce() -> Rect, @@ -552,44 +657,21 @@ impl<'a> TagHandle<'a, '_> { } } -/// Returns a [`TagHandle`] that automatically calls [`Surface::end_tagged`] -/// when dropped. -pub fn start_span<'a, 'b>( - gc: &mut GlobalContext, - surface: &'b mut Surface<'a>, - span: SpanTag, -) -> TagHandle<'a, 'b> { - start_content(gc, surface, ContentTag::Span(span)) -} - -/// Returns a [`TagHandle`] that automatically calls [`Surface::end_tagged`] -/// when dropped. -pub fn start_artifact<'a, 'b>( - gc: &mut GlobalContext, - surface: &'b mut Surface<'a>, - kind: ArtifactKind, -) -> TagHandle<'a, 'b> { - let ty = artifact_type(kind); - start_content(gc, surface, ContentTag::Artifact(ty)) -} - fn start_content<'a, 'b>( gc: &mut GlobalContext, surface: &'b mut Surface<'a>, content: ContentTag, ) -> TagHandle<'a, 'b> { - if gc.options.disable_tags { - return TagHandle { surface, started: false }; - } - - let content = if gc.tags.in_artifact.is_some() { - return TagHandle { surface, started: false }; + if gc.tags.in_artifact.is_some() { + TagHandle { surface, started: false } } else { - content - }; - let id = surface.start_tagged(content); - gc.tags.push(TagNode::Leaf(id)); - TagHandle { surface, started: true } + let artifact = matches!(content, ContentTag::Artifact(_)); + let id = surface.start_tagged(content); + if !artifact { + gc.tags.push(TagNode::Leaf(id)); + } + TagHandle { surface, started: true } + } } fn artifact_type(kind: ArtifactKind) -> ArtifactType { diff --git a/crates/typst-pdf/src/tags/text.rs b/crates/typst-pdf/src/tags/text.rs new file mode 100644 index 0000000000..7428c32056 --- /dev/null +++ b/crates/typst-pdf/src/tags/text.rs @@ -0,0 +1,283 @@ +use krilla::tagging::{LineHeight, NaiveRgbColor, Tag, TextDecorationType, kind}; +use typst_library::diag::{SourceResult, bail}; +use typst_library::foundations::{Content, Smart}; +use typst_library::introspection::Location; +use typst_library::layout::{Abs, Length}; +use typst_library::text::{Font, ScriptKind, TextItem, TextSize}; +use typst_library::visualize::{Paint, Stroke}; + +use crate::PdfOptions; +use crate::util::AbsExt; + +#[derive(Clone, Debug)] +pub struct TextAttrs { + /// Store the last resolved set of text attribute. The resolution isn't that + /// expensive, but for large bodies of text it is resolved quite often. + last_resolved: Option<(TextParams, ResolvedTextAttrs)>, + items: Vec<(Location, TextAttr)>, +} + +impl TextAttrs { + pub const fn new() -> Self { + Self { last_resolved: None, items: Vec::new() } + } + + pub fn push_script( + &mut self, + elem: &Content, + kind: ScriptKind, + baseline_shift: Smart, + lineheight: Smart, + ) { + let val = Script { kind, baseline_shift, lineheight }; + let loc = elem.location().unwrap(); + self.push(loc, TextAttr::Script(val)); + } + + pub fn push_highlight(&mut self, elem: &Content, paint: Option<&Paint>) { + let color = paint.and_then(color_from_paint); + let loc = elem.location().unwrap(); + self.push(loc, TextAttr::Highlight(color)); + } + + pub fn push_deco( + &mut self, + options: &PdfOptions, + elem: &Content, + kind: TextDecoKind, + stroke: Smart, + ) -> SourceResult<()> { + let stroke = TextDecoStroke::from(stroke); + let deco = TextDeco { kind, stroke }; + + // TODO: can overlapping tags break this? + // PDF can only represent one text decoration style at a time. + // If PDF/UA-1 is enforced throw an error. + if options.is_pdf_ua() + && self + .items + .iter() + .filter_map(|(_, a)| a.as_deco()) + .any(|d| d.kind != deco.kind) + { + let validator = options.standards.config.validator().as_str(); + bail!( + elem.span(), + "{validator} error: cannot combine underline, overline, or strike" + ); + } + + let loc = elem.location().unwrap(); + self.push(loc, TextAttr::Deco(deco)); + Ok(()) + } + + fn push(&mut self, loc: Location, attr: TextAttr) { + self.last_resolved = None; + self.items.push((loc, attr)); + } + + /// Returns true if a decoration was removed. + pub fn pop(&mut self, loc: Location) -> bool { + self.last_resolved = None; + + // TODO: Ideally we would just check the top of the stack, can + // overlapping tags even happen for decorations? + if let Some(i) = self.items.iter().rposition(|(l, _)| *l == loc) { + self.items.remove(i); + return true; + } + false + } + + pub fn resolve(&mut self, text: &TextItem) -> ResolvedTextAttrs { + let params = TextParams::new(text); + if let Some((prev_params, attrs)) = &self.last_resolved + && prev_params == ¶ms + { + return *attrs; + } + + let attrs = resolve_attrs(&self.items, &text.font, text.size); + self.last_resolved = Some((params, attrs)); + attrs + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum TextAttr { + Script(Script), + Highlight(Option), + Deco(TextDeco), +} + +impl TextAttr { + fn as_deco(&self) -> Option<&TextDeco> { + if let Self::Deco(v) = self { Some(v) } else { None } + } +} + +/// Sub- or super-script. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Script { + kind: ScriptKind, + baseline_shift: Smart, + lineheight: Smart, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct TextDeco { + kind: TextDecoKind, + stroke: TextDecoStroke, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TextDecoKind { + Underline, + Overline, + Strike, +} + +impl TextDecoKind { + fn to_krilla(self) -> TextDecorationType { + match self { + TextDecoKind::Underline => TextDecorationType::Underline, + TextDecoKind::Overline => TextDecorationType::Overline, + TextDecoKind::Strike => TextDecorationType::LineThrough, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct TextDecoStroke { + color: Option, + thickness: Option, +} + +impl TextDecoStroke { + fn from(stroke: Smart) -> Self { + let Smart::Custom(stroke) = stroke else { + return TextDecoStroke::default(); + }; + let color = match stroke.paint.custom() { + Some(paint) => color_from_paint(&paint), + None => None, + }; + let thickness = stroke.thickness.custom(); + TextDecoStroke { color, thickness } + } +} + +fn color_from_paint(paint: &Paint) -> Option { + match paint { + Paint::Solid(color) => { + let c = color.to_rgb(); + Some(NaiveRgbColor::new_f32(c.red, c.green, c.blue)) + } + Paint::Gradient(_) => None, + Paint::Tiling(_) => None, + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ResolvedTextAttrs { + script: Option, + background: Option>, + deco: Option, +} + +impl ResolvedTextAttrs { + pub const EMPTY: Self = Self { script: None, background: None, deco: None }; + + pub fn is_empty(&self) -> bool { + self == &Self::EMPTY + } + + pub fn all_resolved(&self) -> bool { + self.script.is_some() && self.background.is_some() && self.deco.is_some() + } + + pub fn to_tag(self) -> Tag { + Tag::Span + .with_line_height(self.script.map(|s| s.lineheight)) + .with_baseline_shift(self.script.map(|s| s.baseline_shift)) + .with_background_color(self.background.flatten()) + .with_text_decoration_type(self.deco.map(|d| d.kind.to_krilla())) + .with_text_decoration_color(self.deco.and_then(|d| d.color)) + .with_text_decoration_thickness(self.deco.and_then(|d| d.thickness)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ResolvedScript { + baseline_shift: f32, + lineheight: LineHeight, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ResolvedTextDeco { + kind: TextDecoKind, + color: Option, + thickness: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct TextParams { + font_index: u32, + size: Abs, +} + +impl TextParams { + fn new(text: &TextItem) -> TextParams { + TextParams { + // Comparing font indices is enough. + font_index: text.font.index(), + size: text.size, + } + } +} + +fn resolve_attrs( + items: &[(Location, TextAttr)], + font: &Font, + size: Abs, +) -> ResolvedTextAttrs { + let mut attrs = ResolvedTextAttrs::EMPTY; + for (_, attr) in items.iter().rev() { + match *attr { + TextAttr::Script(script) => { + // TODO: The `typographic` setting is ignored for now. + // Is it better to be accurate regarding the layouting, and + // thus don't write any baseline shift and lineheight when + // a typographic sub/super script glyph is used? Or should + // we always write the shift so the sub/super script can be + // picked up by AT? + let script_metrics = script.kind.read_metrics(font.metrics()); + // NOTE: The user provided baseline_shift needs to be inverted. + let baseline_shift = (script.baseline_shift.map(|s| -s.at(size))) + .unwrap_or_else(|| script_metrics.vertical_offset.at(size)); + let lineheight = (script.lineheight.map(|s| s.0.at(size))) + .unwrap_or_else(|| script_metrics.height.at(size)); + + attrs.script.get_or_insert_with(|| ResolvedScript { + baseline_shift: baseline_shift.to_f32(), + lineheight: LineHeight::Custom(lineheight.to_f32()), + }); + } + TextAttr::Highlight(color) => { + attrs.background.get_or_insert(color); + } + TextAttr::Deco(TextDeco { kind, stroke }) => { + attrs.deco.get_or_insert_with(|| { + let thickness = stroke.thickness.map(|t| t.at(size).to_f32()); + ResolvedTextDeco { kind, color: stroke.color, thickness } + }); + } + } + + if attrs.all_resolved() { + break; + } + } + attrs +} diff --git a/crates/typst-pdf/src/text.rs b/crates/typst-pdf/src/text.rs index f5f8b27823..7be2eb5774 100644 --- a/crates/typst-pdf/src/text.rs +++ b/crates/typst-pdf/src/text.rs @@ -3,11 +3,10 @@ use std::sync::Arc; use bytemuck::TransparentWrapper; use krilla::surface::{Location, Surface}; -use krilla::tagging::SpanTag; use krilla::text::GlyphId; use typst_library::diag::{SourceResult, bail}; use typst_library::layout::Size; -use typst_library::text::{Font, Glyph, Lang, TextItem}; +use typst_library::text::{Font, Glyph, TextItem}; use typst_library::visualize::FillRule; use typst_syntax::Span; @@ -22,11 +21,7 @@ pub(crate) fn handle_text( surface: &mut Surface, gc: &mut GlobalContext, ) -> SourceResult<()> { - let lang = gc.tags.try_set_lang(t.lang); - let lang = lang.as_ref().map(Lang::as_str); - tags::update_bbox(gc, fc, || t.bbox()); - - let mut handle = tags::start_span(gc, surface, SpanTag::empty().with_lang(lang)); + let mut handle = tags::text(gc, fc, surface, t); let surface = handle.surface(); let font = convert_font(gc, t.font.clone())?; diff --git a/tests/ref/pdftags/deco-tags-different-color.yml b/tests/ref/pdftags/deco-tags-different-color.yml new file mode 100644 index 0000000000..9e52b7d413 --- /dev/null +++ b/tests/ref/pdftags/deco-tags-different-color.yml @@ -0,0 +1,13 @@ +- Tag: P + /K: + - Tag: Span + /TextDecorationColor: #ff4136 + /TextDecorationType: Underline + /K: + - Content: page=0 mcid=0 + - Tag: Span + /TextDecorationColor: #0074d9 + /TextDecorationType: Underline + /K: + - Content: page=0 mcid=1 + - Content: page=0 mcid=2 diff --git a/tests/ref/pdftags/deco-tags-different-stroke-thickness.yml b/tests/ref/pdftags/deco-tags-different-stroke-thickness.yml new file mode 100644 index 0000000000..07b658be96 --- /dev/null +++ b/tests/ref/pdftags/deco-tags-different-stroke-thickness.yml @@ -0,0 +1,13 @@ +- Tag: P + /K: + - Tag: Span + /TextDecorationThickness: 2.000 + /TextDecorationType: Underline + /K: + - Content: page=0 mcid=0 + - Tag: Span + /TextDecorationThickness: 1.000 + /TextDecorationType: Underline + /K: + - Content: page=0 mcid=1 + - Content: page=0 mcid=2 diff --git a/tests/ref/pdftags/deco-tags-different-type.yml b/tests/ref/pdftags/deco-tags-different-type.yml new file mode 100644 index 0000000000..2d26ea4343 --- /dev/null +++ b/tests/ref/pdftags/deco-tags-different-type.yml @@ -0,0 +1,14 @@ +- Tag: P + /K: + - Tag: Span + /TextDecorationType: Underline + /K: + - Content: page=0 mcid=0 + - Tag: Span + /TextDecorationType: Overline + /K: + - Content: page=0 mcid=1 + - Tag: Span + /TextDecorationType: LineThrough + /K: + - Content: page=0 mcid=2 diff --git a/tests/ref/pdftags/deco-tags-highlight-basic.yml b/tests/ref/pdftags/deco-tags-highlight-basic.yml new file mode 100644 index 0000000000..20d323228d --- /dev/null +++ b/tests/ref/pdftags/deco-tags-highlight-basic.yml @@ -0,0 +1,9 @@ +- Tag: P + /K: + - Content: page=0 mcid=0 + - Tag: Span + /BackgroundColor: #fffd11 + /K: + - Content: page=0 mcid=1 + - Content: page=0 mcid=2 + - Content: page=0 mcid=3 diff --git a/tests/ref/pdftags/deco-tags-script-custom-baseline.yml b/tests/ref/pdftags/deco-tags-script-custom-baseline.yml new file mode 100644 index 0000000000..5cfc27a12f --- /dev/null +++ b/tests/ref/pdftags/deco-tags-script-custom-baseline.yml @@ -0,0 +1,13 @@ +- Tag: P + /K: + - Tag: Span + /BaselineShift: -2.500 + /LineHeight: 6.000 + /K: + - Content: page=0 mcid=0 + - Content: page=0 mcid=1 + - Tag: Span + /BaselineShift: 9.500 + /LineHeight: 6.000 + /K: + - Content: page=0 mcid=2 diff --git a/tests/ref/pdftags/deco-tags-subscript-basic.yml b/tests/ref/pdftags/deco-tags-subscript-basic.yml new file mode 100644 index 0000000000..73ec673565 --- /dev/null +++ b/tests/ref/pdftags/deco-tags-subscript-basic.yml @@ -0,0 +1,14 @@ +- Tag: P + /K: + - Content: page=0 mcid=0 + - Tag: Span + /BaselineShift: -0.750 + /LineHeight: 6.000 + /K: + - Content: page=0 mcid=1 + - Content: page=0 mcid=2 + - Tag: Span + /BaselineShift: -0.750 + /LineHeight: 6.000 + /K: + - Content: page=0 mcid=3 diff --git a/tests/ref/pdftags/deco-tags-superscript-basic.yml b/tests/ref/pdftags/deco-tags-superscript-basic.yml new file mode 100644 index 0000000000..371e859971 --- /dev/null +++ b/tests/ref/pdftags/deco-tags-superscript-basic.yml @@ -0,0 +1,10 @@ +- Tag: P + /K: + - Content: page=0 mcid=0 + - Tag: Span + /BaselineShift: 3.500 + /LineHeight: 6.000 + /K: + - Content: page=0 mcid=1 + - Content: page=0 mcid=2 + - Content: page=0 mcid=3 diff --git a/tests/ref/pdftags/deco-tags-underline.yml b/tests/ref/pdftags/deco-tags-underline.yml new file mode 100644 index 0000000000..774825d4bf --- /dev/null +++ b/tests/ref/pdftags/deco-tags-underline.yml @@ -0,0 +1,15 @@ +- Tag: P + /K: + - Tag: Span + /TextDecorationColor: #ff4136 + /TextDecorationType: Underline + /K: + - Content: page=0 mcid=0 + - Content: page=0 mcid=1 +- Tag: P + /K: + - Tag: Span + /TextDecorationColor: #ff4136 + /TextDecorationType: Underline + /K: + - Content: page=0 mcid=2 diff --git a/tests/ref/pdftags/grid-headers.yml b/tests/ref/pdftags/grid-headers.yml index 786ee46cd6..83883cae4d 100644 --- a/tests/ref/pdftags/grid-headers.yml +++ b/tests/ref/pdftags/grid-headers.yml @@ -1,80 +1,5 @@ - Tag: Table /K: - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - Tag: THead /K: - Tag: TR diff --git a/tests/ref/pdftags/list-tags-complex-item-with-nested-list.yml b/tests/ref/pdftags/list-tags-complex-item-with-nested-list.yml index 9cb5c362b2..11a04e383f 100644 --- a/tests/ref/pdftags/list-tags-complex-item-with-nested-list.yml +++ b/tests/ref/pdftags/list-tags-complex-item-with-nested-list.yml @@ -17,7 +17,11 @@ /K: - Tag: Link /K: - - Content: page=0 mcid=2 + - Tag: Span + /BaselineShift: 3.500 + /LineHeight: 6.000 + /K: + - Content: page=0 mcid=2 - Annotation: page=0 index=0 - Tag: Note /K: @@ -25,7 +29,11 @@ /K: - Tag: Link /K: - - Content: page=0 mcid=12 + - Tag: Span + /BaselineShift: 2.975 + /LineHeight: 5.100 + /K: + - Content: page=0 mcid=12 - Annotation: page=0 index=1 - Content: page=0 mcid=13 - Content: page=0 mcid=3 @@ -64,4 +72,3 @@ - Tag: LBody /K: - Content: page=0 mcid=11 -- Artifact diff --git a/tests/ref/pdftags/list-tags-complex-item-with-sub-list.yml b/tests/ref/pdftags/list-tags-complex-item-with-sub-list.yml index 9cb5c362b2..11a04e383f 100644 --- a/tests/ref/pdftags/list-tags-complex-item-with-sub-list.yml +++ b/tests/ref/pdftags/list-tags-complex-item-with-sub-list.yml @@ -17,7 +17,11 @@ /K: - Tag: Link /K: - - Content: page=0 mcid=2 + - Tag: Span + /BaselineShift: 3.500 + /LineHeight: 6.000 + /K: + - Content: page=0 mcid=2 - Annotation: page=0 index=0 - Tag: Note /K: @@ -25,7 +29,11 @@ /K: - Tag: Link /K: - - Content: page=0 mcid=12 + - Tag: Span + /BaselineShift: 2.975 + /LineHeight: 5.100 + /K: + - Content: page=0 mcid=12 - Annotation: page=0 index=1 - Content: page=0 mcid=13 - Content: page=0 mcid=3 @@ -64,4 +72,3 @@ - Tag: LBody /K: - Content: page=0 mcid=11 -- Artifact diff --git a/tests/ref/pdftags/table-tags-basic.yml b/tests/ref/pdftags/table-tags-basic.yml index 035982722d..d8616c888a 100644 --- a/tests/ref/pdftags/table-tags-basic.yml +++ b/tests/ref/pdftags/table-tags-basic.yml @@ -6,14 +6,6 @@ right: 76.350 bottom: 60.240 /K: - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - Tag: THead /K: - Tag: TR diff --git a/tests/ref/pdftags/table-tags-column-and-row-header.yml b/tests/ref/pdftags/table-tags-column-and-row-header.yml index eac8100f70..c5741e5f2f 100644 --- a/tests/ref/pdftags/table-tags-column-and-row-header.yml +++ b/tests/ref/pdftags/table-tags-column-and-row-header.yml @@ -6,14 +6,6 @@ right: 85.360 bottom: 60.240 /K: - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - Tag: TR /K: - Tag: TH diff --git a/tests/ref/pdftags/table-tags-missing-cells.yml b/tests/ref/pdftags/table-tags-missing-cells.yml index 8b031729d4..66330ee42b 100644 --- a/tests/ref/pdftags/table-tags-missing-cells.yml +++ b/tests/ref/pdftags/table-tags-missing-cells.yml @@ -6,17 +6,6 @@ right: 76.350 bottom: 96.820 /K: - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - Tag: THead /K: - Tag: TR diff --git a/tests/ref/pdftags/terms-constructor.yml b/tests/ref/pdftags/terms-constructor.yml index 3a75ce91ff..ca41d74b04 100644 --- a/tests/ref/pdftags/terms-constructor.yml +++ b/tests/ref/pdftags/terms-constructor.yml @@ -1,8 +1,6 @@ - Tag: L /Numbering: None /K: - - Artifact - - Artifact - Tag: LI /K: - Tag: Lbl diff --git a/tests/suite/pdftags/deco.typ b/tests/suite/pdftags/deco.typ new file mode 100644 index 0000000000..08b2dadb7e --- /dev/null +++ b/tests/suite/pdftags/deco.typ @@ -0,0 +1,49 @@ +--- deco-tags-underline pdftags --- +#show: underline.with(stroke: red) + +// The content in this paragraph is grouped into one span tag with the +// corresponding text attributes. +red underlined text +red underlined text + +red underlined text + +--- deco-tags-different-color pdftags --- +#show: underline.with(stroke: red) +red underlined text +#show: underline.with(stroke: blue) +blue underlined text + +--- deco-tags-different-stroke-thickness pdftags --- +#show: underline.with(stroke: 2pt) +thick underlined +#show: underline.with(stroke: 1pt) +thin underlined + +--- deco-tags-different-type pdftags --- +#underline[underlined]\ +#overline[overlined]\ +#strike[striked]\ + +--- deco-tags-multiple-styles pdftags --- +#show: underline +// Error: 2-16 PDF/UA-1 error: cannot combine underline, overline, or strike +#show: overline +text with a bunch of lines + +--- deco-tags-highlight-basic pdftags --- +A #highlight[highlighted] alksjdflk asdjlkfj alskdj word. + +--- deco-tags-subscript-basic pdftags --- +CO#sub[2] emissions. +A2#sub[hex] + +--- deco-tags-superscript-basic pdftags --- +CI#super[-] has a negative charge. + +--- deco-tags-script-custom-baseline pdftags --- +// NOTE: the baseline shift values attribute is inverted. +#set sub(baseline: 2.5pt) +#set super(baseline: -9.5pt) +#sub[sub] +#super[super] From d9ccd2bff151fc9f2b809e7a350613252d14b5f0 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Mon, 4 Aug 2025 18:51:02 +0200 Subject: [PATCH 420/558] Disable tags inside tilings and hide elements --- crates/typst-pdf/src/paint.rs | 9 +- crates/typst-pdf/src/tags/context.rs | 11 ++- crates/typst-pdf/src/tags/mod.rs | 96 +++++++++++++------ tests/ref/pdftags/disable-tags-artifact.yml | 8 ++ tests/ref/pdftags/disable-tags-hide.yml | 4 + .../disable-tags-partially-hidden-list.yml | 34 +++++++ tests/ref/pdftags/disable-tags-tiling.yml | 4 + tests/suite/pdftags/disable.typ | 49 ++++++++++ 8 files changed, 184 insertions(+), 31 deletions(-) create mode 100644 tests/ref/pdftags/disable-tags-artifact.yml create mode 100644 tests/ref/pdftags/disable-tags-hide.yml create mode 100644 tests/ref/pdftags/disable-tags-partially-hidden-list.yml create mode 100644 tests/ref/pdftags/disable-tags-tiling.yml create mode 100644 tests/suite/pdftags/disable.typ diff --git a/crates/typst-pdf/src/paint.rs b/crates/typst-pdf/src/paint.rs index 5994b39d70..8d9fc92469 100644 --- a/crates/typst-pdf/src/paint.rs +++ b/crates/typst-pdf/src/paint.rs @@ -16,6 +16,7 @@ use typst_library::visualize::{ use typst_utils::Numeric; use crate::convert::{FrameContext, GlobalContext, State, handle_frame}; +use crate::tags::{self, Disable}; use crate::util::{AbsExt, FillRuleExt, LineCapExt, LineJoinExt, TransformExt}; pub(crate) fn convert_fill( @@ -127,8 +128,12 @@ fn convert_pattern( let mut stream_builder = surface.stream_builder(); let mut surface = stream_builder.surface(); - let mut fc = FrameContext::new(None, pattern.frame().size()); - handle_frame(&mut fc, pattern.frame(), None, &mut surface, gc)?; + { + let mut handle = tags::disable(gc, &mut surface, Disable::Tiling); + let (gc, surface) = handle.reborrow(); + let mut fc = FrameContext::new(None, pattern.frame().size()); + handle_frame(&mut fc, pattern.frame(), None, surface, gc)?; + } surface.finish(); let stream = stream_builder.finish(); let pattern = Pattern { diff --git a/crates/typst-pdf/src/tags/context.rs b/crates/typst-pdf/src/tags/context.rs index 990f11f740..dccaf2b1cd 100644 --- a/crates/typst-pdf/src/tags/context.rs +++ b/crates/typst-pdf/src/tags/context.rs @@ -34,7 +34,7 @@ pub struct Tags { /// before the reference in the text, so we only resolve them once tags /// for the whole document are generated. pub footnotes: FxHashMap, - pub in_artifact: Option<(Location, ArtifactKind)>, + pub disable: Option, /// Used to group multiple link annotations using quad points. link_id: LinkId, /// Used to generate IDs referenced in table `Headers` attributes. @@ -53,7 +53,7 @@ impl Tags { stack: TagStack::new(), placeholders: Placeholders(Vec::new()), footnotes: FxHashMap::default(), - in_artifact: None, + disable: None, link_id: LinkId(0), table_id: TableId(0), @@ -166,6 +166,13 @@ impl Tags { } } +#[derive(Clone, Copy, Debug)] +pub enum Disable { + /// Either an artifact or a hide element. + Elem(Location, ArtifactKind), + Tiling, +} + #[derive(Debug)] pub struct TagStack { items: Vec, diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index 52d5c88236..436d6432be 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -10,7 +10,7 @@ use krilla::tagging::{ use typst_library::diag::{SourceResult, bail}; use typst_library::foundations::Content; use typst_library::introspection::Location; -use typst_library::layout::{Point, Rect, RepeatElem, Size}; +use typst_library::layout::{HideElem, Point, Rect, RepeatElem, Size}; use typst_library::math::EquationElem; use typst_library::model::{ EnumElem, FigureCaption, FigureElem, FootnoteEntry, HeadingElem, LinkMarker, @@ -102,20 +102,25 @@ pub fn handle_start( return Ok(()); } - if gc.tags.in_artifact.is_some() { + if gc.tags.disable.is_some() { // Don't nest artifacts return Ok(()); } - if let Some(artifact) = elem.to_packed::() { + #[allow(clippy::redundant_pattern_matching)] + if let Some(_) = elem.to_packed::() { + push_disable(gc, surface, elem, ArtifactKind::Other); + return Ok(()); + } else if let Some(artifact) = elem.to_packed::() { let kind = artifact.kind.val(); - push_artifact(gc, surface, elem, kind); + push_disable(gc, surface, elem, kind); return Ok(()); } else if let Some(_) = elem.to_packed::() { - push_artifact(gc, surface, elem, ArtifactKind::Other); + push_disable(gc, surface, elem, ArtifactKind::Other); return Ok(()); } + #[allow(clippy::redundant_pattern_matching)] let tag: TagKind = if let Some(tag) = elem.to_packed::() { match &tag.kind { PdfMarkerTagKind::OutlineBody => { @@ -209,7 +214,7 @@ pub fn handle_start( // semantic meaning in the tag tree, which doesn't use page breaks for // it's semantic structure. if cell.is_repeated.val() || table_ctx.is_some_and(|ctx| ctx.contains(cell)) { - push_artifact(gc, surface, elem, ArtifactKind::Other); + push_disable(gc, surface, elem, ArtifactKind::Other); } else { push_stack(gc, elem, StackEntryKind::TableCell(cell.clone())); } @@ -293,7 +298,7 @@ fn push_stack(gc: &mut GlobalContext, elem: &Content, kind: StackEntryKind) { .push(StackEntry { loc, span, lang: None, kind, nodes: Vec::new() }); } -fn push_artifact( +fn push_disable( gc: &mut GlobalContext, surface: &mut Surface, elem: &Content, @@ -302,7 +307,7 @@ fn push_artifact( let loc = elem.location().expect("elem to be locatable"); let ty = artifact_type(kind); surface.start_tagged(ContentTag::Artifact(ty)); - gc.tags.in_artifact = Some((loc, kind)); + gc.tags.disable = Some(Disable::Elem(loc, kind)); } pub fn handle_end( @@ -314,10 +319,11 @@ pub fn handle_end( return Ok(()); } - if let Some((l, _)) = gc.tags.in_artifact + if let Some(Disable::Elem(l, _)) = gc.tags.disable && l == loc { - pop_artifact(gc, surface); + surface.end_tagged(); + gc.tags.disable = None; return Ok(()); } @@ -513,17 +519,16 @@ fn pop_stack(gc: &mut GlobalContext, entry: StackEntry) { gc.tags.push(node); } -fn pop_artifact(gc: &mut GlobalContext, surface: &mut Surface) { - surface.end_tagged(); - gc.tags.in_artifact = None; -} - pub fn page_start(gc: &mut GlobalContext, surface: &mut Surface) { if gc.options.disable_tags { return; } - if let Some((_, kind)) = gc.tags.in_artifact { + if let Some(disable) = gc.tags.disable { + let kind = match disable { + Disable::Elem(_, kind) => kind, + Disable::Tiling => ArtifactKind::Other, + }; let ty = artifact_type(kind); surface.start_tagged(ContentTag::Artifact(ty)); } @@ -534,7 +539,7 @@ pub fn page_end(gc: &mut GlobalContext, surface: &mut Surface) { return; } - if gc.tags.in_artifact.is_some() { + if gc.tags.disable.is_some() { surface.end_tagged(); } } @@ -564,6 +569,43 @@ pub fn add_link_annotations( } } +pub struct DisableHandle<'a, 'b, 'c, 'd> { + gc: &'b mut GlobalContext<'a>, + surface: &'d mut Surface<'c>, + /// Whether this handle started the disabled range. + started: bool, +} + +impl Drop for DisableHandle<'_, '_, '_, '_> { + fn drop(&mut self) { + if self.started { + self.gc.tags.disable = None; + self.surface.end_tagged(); + } + } +} + +impl<'a, 'c> DisableHandle<'a, '_, 'c, '_> { + pub fn reborrow<'s>( + &'s mut self, + ) -> (&'s mut GlobalContext<'a>, &'s mut Surface<'c>) { + (self.gc, self.surface) + } +} + +pub fn disable<'a, 'b, 'c, 'd>( + gc: &'b mut GlobalContext<'a>, + surface: &'d mut Surface<'c>, + kind: Disable, +) -> DisableHandle<'a, 'b, 'c, 'd> { + let started = gc.tags.disable.is_none(); + if started { + gc.tags.disable = Some(kind); + surface.start_tagged(ContentTag::Artifact(ArtifactType::Other)); + } + DisableHandle { gc, surface, started } +} + pub fn text<'a, 'b>( gc: &mut GlobalContext, fc: &FrameContext, @@ -576,7 +618,7 @@ pub fn text<'a, 'b>( update_bbox(gc, fc, || text.bbox()); - if gc.tags.in_artifact.is_some() { + if gc.tags.disable.is_some() { return TagHandle { surface, started: false }; } @@ -662,16 +704,16 @@ fn start_content<'a, 'b>( surface: &'b mut Surface<'a>, content: ContentTag, ) -> TagHandle<'a, 'b> { - if gc.tags.in_artifact.is_some() { - TagHandle { surface, started: false } - } else { - let artifact = matches!(content, ContentTag::Artifact(_)); - let id = surface.start_tagged(content); - if !artifact { - gc.tags.push(TagNode::Leaf(id)); - } - TagHandle { surface, started: true } + if gc.tags.disable.is_some() { + return TagHandle { surface, started: false }; + } + + let artifact = matches!(content, ContentTag::Artifact(_)); + let id = surface.start_tagged(content); + if !artifact { + gc.tags.push(TagNode::Leaf(id)); } + TagHandle { surface, started: true } } fn artifact_type(kind: ArtifactKind) -> ArtifactType { diff --git a/tests/ref/pdftags/disable-tags-artifact.yml b/tests/ref/pdftags/disable-tags-artifact.yml new file mode 100644 index 0000000000..2f6317b834 --- /dev/null +++ b/tests/ref/pdftags/disable-tags-artifact.yml @@ -0,0 +1,8 @@ +- Tag: H1 + /T: "Heading 1" + /K: + - Content: page=0 mcid=0 +- Tag: H1 + /T: "Heading 2" + /K: + - Content: page=0 mcid=1 diff --git a/tests/ref/pdftags/disable-tags-hide.yml b/tests/ref/pdftags/disable-tags-hide.yml new file mode 100644 index 0000000000..8c4b9e4c3b --- /dev/null +++ b/tests/ref/pdftags/disable-tags-hide.yml @@ -0,0 +1,4 @@ +- Tag: H1 + /T: "Hidden" + /K: + - Content: page=0 mcid=0 diff --git a/tests/ref/pdftags/disable-tags-partially-hidden-list.yml b/tests/ref/pdftags/disable-tags-partially-hidden-list.yml new file mode 100644 index 0000000000..9986aecaf3 --- /dev/null +++ b/tests/ref/pdftags/disable-tags-partially-hidden-list.yml @@ -0,0 +1,34 @@ +- Tag: H1 + /T: "Tail hidden" + /K: + - Content: page=0 mcid=0 +- Tag: L + /Numbering: Circle + /K: + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=1 + - Tag: LBody + /K: + - Content: page=0 mcid=2 + - Tag: LI + /K: + - Tag: Lbl + /K: + - Content: page=0 mcid=3 + - Tag: LBody + /K: + - Tag: P + - Tag: L + /Numbering: Circle + /K: + - Tag: LI + /K: + - Tag: Lbl + - Tag: LBody +- Tag: H1 + /T: "Head hidden" + /K: + - Content: page=0 mcid=4 diff --git a/tests/ref/pdftags/disable-tags-tiling.yml b/tests/ref/pdftags/disable-tags-tiling.yml new file mode 100644 index 0000000000..0b34b34167 --- /dev/null +++ b/tests/ref/pdftags/disable-tags-tiling.yml @@ -0,0 +1,4 @@ +- Tag: H1 + /T: "Rectangle" + /K: + - Content: page=0 mcid=0 diff --git a/tests/suite/pdftags/disable.typ b/tests/suite/pdftags/disable.typ new file mode 100644 index 0000000000..7fc77149d4 --- /dev/null +++ b/tests/suite/pdftags/disable.typ @@ -0,0 +1,49 @@ +--- disable-tags-artifact pdftags --- += Heading 1 +#pdf.artifact[ + #table( + columns: 2, + [a], [b], + [c], [d], + ) +] + += Heading 2 + +--- disable-tags-tiling pdftags --- += Rectangle + +#let pat = tiling(size: (20pt, 20pt))[ + - a + - b + - c +] +#rect(fill: pat) + +--- disable-tags-hide pdftags --- += Hidden + +#hide[ + - a + - b + - c +] + +--- disable-tags-partially-hidden-list pdftags --- +// FIXME(accessibility): In realization, tags inside of list groupings aren't +// handled. Thus if the head of the list is visible, all tags of list items +// will be emitted before (outside) the hide element. And if the head is not +// visible, all tags of list items will be emitted inside the hide element. += Tail hidden +- a +#hide[ +- b + - c +] + += Head hidden +#hide[ +- a +] +- b + - c From ae4bdfc6ad6277c61bc20a22ad992b2e9b49e4d4 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Wed, 6 Aug 2025 20:45:10 +0200 Subject: [PATCH 421/558] Ensure proper reading order for grid cells --- crates/typst-layout/src/grid/mod.rs | 13 +- crates/typst-layout/src/rules.rs | 11 +- crates/typst-library/src/layout/grid/mod.rs | 4 + crates/typst-pdf/src/tags/context.rs | 26 +- .../typst-pdf/src/tags/{table.rs => grid.rs} | 230 +++++++++++------- crates/typst-pdf/src/tags/mod.rs | 47 +++- tests/ref/pdftags/grid-tags-rowspan.yml | 28 +++ tests/suite/pdftags/grid.typ | 11 + 8 files changed, 262 insertions(+), 108 deletions(-) rename crates/typst-pdf/src/tags/{table.rs => grid.rs} (69%) create mode 100644 tests/ref/pdftags/grid-tags-rowspan.yml create mode 100644 tests/suite/pdftags/grid.typ diff --git a/crates/typst-layout/src/grid/mod.rs b/crates/typst-layout/src/grid/mod.rs index 3ddf40ab5d..333cc33ca2 100644 --- a/crates/typst-layout/src/grid/mod.rs +++ b/crates/typst-layout/src/grid/mod.rs @@ -38,15 +38,16 @@ pub fn layout_cell( // considered significant for layouting. This hack together with a check in // the grid layouter makes the test suite pass. let mut locator = locator.split(); - let tags = if let Some(table_cell) = cell.body.to_packed::() { + let mut tags = None; + if let Some(table_cell) = cell.body.to_packed::() { let mut table_cell = table_cell.clone(); table_cell.is_repeated.set(is_repeated); - Some(generate_tags(table_cell, &mut locator, engine)) + tags = Some(generate_tags(table_cell, &mut locator, engine)); } else if let Some(grid_cell) = cell.body.to_packed::() { - Some(generate_tags(grid_cell.clone(), &mut locator, engine)) - } else { - None - }; + let mut grid_cell = grid_cell.clone(); + grid_cell.is_repeated.set(is_repeated); + tags = Some(generate_tags(grid_cell, &mut locator, engine)); + } let locator = locator.next(&cell.body.span()); let fragment = crate::layout_fragment(engine, &cell.body, locator, styles, regions)?; diff --git a/crates/typst-layout/src/rules.rs b/crates/typst-layout/src/rules.rs index cf2348c96d..692171e56a 100644 --- a/crates/typst-layout/src/rules.rs +++ b/crates/typst-layout/src/rules.rs @@ -499,11 +499,14 @@ const BIBLIOGRAPHY_RULE: ShowFn = |elem, engine, styles| { let grid = GridElem::new(cells) .with_columns(TrackSizings(smallvec![Sizing::Auto; 2])) .with_column_gutter(TrackSizings(smallvec![COLUMN_GUTTER.into()])) - .with_row_gutter(TrackSizings(smallvec![row_gutter.into()])) - .pack() - .spanned(span); + .with_row_gutter(TrackSizings(smallvec![row_gutter.into()])); + let packed = Packed::new(grid).spanned(span); + // Directly build the block element to avoid the show step for the grid + // element. This will not generate introspection tags for the element. + let block = BlockElem::multi_layouter(packed, crate::grid::layout_grid).pack(); + // TODO(accessibility): infer list numbering from style? - seq.push(PdfMarkerTag::Bibliography(true, grid)); + seq.push(PdfMarkerTag::Bibliography(true, block)); } else { let mut body = vec![]; for (_, reference, loc) in references { diff --git a/crates/typst-library/src/layout/grid/mod.rs b/crates/typst-library/src/layout/grid/mod.rs index 9bd2654053..a166d0dabd 100644 --- a/crates/typst-library/src/layout/grid/mod.rs +++ b/crates/typst-library/src/layout/grid/mod.rs @@ -826,6 +826,10 @@ pub struct GridCell { #[fold] pub stroke: Sides>>>, + #[internal] + #[parse(Some(false))] + pub is_repeated: bool, + /// Whether rows spanned by this cell can be placed in different pages. /// When equal to `{auto}`, a cell spanning only fixed-size rows is /// unbreakable, while a cell spanning at least one `{auto}`-sized row is diff --git a/crates/typst-pdf/src/tags/context.rs b/crates/typst-pdf/src/tags/context.rs index dccaf2b1cd..154898ebcf 100644 --- a/crates/typst-pdf/src/tags/context.rs +++ b/crates/typst-pdf/src/tags/context.rs @@ -6,16 +6,16 @@ use krilla::tagging::{BBox, Identifier, Node, TagKind, TagTree}; use rustc_hash::FxHashMap; use typst_library::foundations::Packed; use typst_library::introspection::Location; -use typst_library::layout::{Abs, Point, Rect}; +use typst_library::layout::{Abs, GridCell, Point, Rect}; use typst_library::model::{LinkMarker, OutlineEntry, TableCell}; use typst_library::pdf::ArtifactKind; use typst_library::text::Lang; use typst_syntax::Span; use crate::convert::FrameContext; +use crate::tags::grid::{GridCtx, GridData, TableData}; use crate::tags::list::ListCtx; use crate::tags::outline::OutlineCtx; -use crate::tags::table::TableCtx; use crate::tags::text::{ResolvedTextAttrs, TextAttrs}; use crate::tags::{Placeholder, TagNode}; use crate::util::AbsExt; @@ -263,10 +263,14 @@ impl TagStack { self.items.last_mut().map(|e| &mut e.kind) } - pub fn parent_table(&mut self) -> Option<&mut TableCtx> { + pub fn parent_table(&mut self) -> Option<&mut GridCtx> { self.parent()?.as_table_mut() } + pub fn parent_grid(&mut self) -> Option<&mut GridCtx> { + self.parent()?.as_grid_mut() + } + pub fn parent_list(&mut self) -> Option<&mut ListCtx> { self.parent()?.as_list_mut() } @@ -348,8 +352,10 @@ pub enum StackEntryKind { Standard(TagKind), Outline(OutlineCtx), OutlineEntry(Packed), - Table(TableCtx), + Table(GridCtx), TableCell(Packed), + Grid(GridCtx), + GridCell(Packed), List(ListCtx), ListItemLabel, ListItemBody, @@ -375,10 +381,14 @@ impl StackEntryKind { if let Self::OutlineEntry(v) = self { Some(v) } else { None } } - pub fn as_table_mut(&mut self) -> Option<&mut TableCtx> { + pub fn as_table_mut(&mut self) -> Option<&mut GridCtx> { if let Self::Table(v) = self { Some(v) } else { None } } + pub fn as_grid_mut(&mut self) -> Option<&mut GridCtx> { + if let Self::Grid(v) = self { Some(v) } else { None } + } + pub fn as_list_mut(&mut self) -> Option<&mut ListCtx> { if let Self::List(v) = self { Some(v) } else { None } } @@ -397,7 +407,7 @@ impl StackEntryKind { pub fn bbox(&self) -> Option<&BBoxCtx> { match self { - Self::Table(ctx) => Some(&ctx.bbox), + Self::Table(ctx) => Some(&ctx.data.bbox), Self::Figure(ctx) => Some(&ctx.bbox), Self::Formula(ctx) => Some(&ctx.bbox), _ => None, @@ -406,7 +416,7 @@ impl StackEntryKind { pub fn bbox_mut(&mut self) -> Option<&mut BBoxCtx> { match self { - Self::Table(ctx) => Some(&mut ctx.bbox), + Self::Table(ctx) => Some(&mut ctx.data.bbox), Self::Figure(ctx) => Some(&mut ctx.bbox), Self::Formula(ctx) => Some(&mut ctx.bbox), _ => None, @@ -459,6 +469,8 @@ impl StackEntryKind { StackEntryKind::OutlineEntry(_) => false, StackEntryKind::Table(_) => false, StackEntryKind::TableCell(_) => false, + StackEntryKind::Grid(_) => false, + StackEntryKind::GridCell(_) => false, StackEntryKind::List(_) => false, StackEntryKind::ListItemLabel => false, StackEntryKind::ListItemBody => false, diff --git a/crates/typst-pdf/src/tags/table.rs b/crates/typst-pdf/src/tags/grid.rs similarity index 69% rename from crates/typst-pdf/src/tags/table.rs rename to crates/typst-pdf/src/tags/grid.rs index 4ceb039d7e..82c8de16d0 100644 --- a/crates/typst-pdf/src/tags/table.rs +++ b/crates/typst-pdf/src/tags/grid.rs @@ -5,99 +5,63 @@ use az::SaturatingAs; use krilla::tagging::{Tag, TagId, TagKind}; use smallvec::SmallVec; use typst_library::foundations::{Packed, Smart}; +use typst_library::layout::GridCell; use typst_library::model::TableCell; use typst_library::pdf::{TableCellKind, TableHeaderScope}; use crate::tags::util::PropertyValCopied; use crate::tags::{BBoxCtx, GroupContents, TableId, TagNode}; +pub trait GridType { + type CellData: Clone; +} + #[derive(Clone, Debug)] -pub struct TableCtx { +pub struct TableData { pub id: TableId, pub summary: Option, pub bbox: BBoxCtx, - rows: Vec>, +} + +impl GridType for TableData { + type CellData = TableCellData; +} + +#[derive(Clone, Debug)] +pub struct GridData; + +impl GridType for GridData { + type CellData = GridCellData; +} + +#[derive(Clone, Debug)] +pub struct GridCtx { + pub data: T, + rows: Vec>>, min_width: usize, } -impl TableCtx { +impl GridCtx { pub fn new(id: TableId, summary: Option) -> Self { Self { - id, - summary, - bbox: BBoxCtx::new(), + data: TableData { id, summary, bbox: BBoxCtx::new() }, rows: Vec::new(), min_width: 0, } } - fn get(&self, x: usize, y: usize) -> Option<&TableCtxCell> { - let cell = self.rows.get(y)?.get(x)?; - self.resolve_cell(cell) - } - - fn get_mut(&mut self, x: usize, y: usize) -> Option<&mut TableCtxCell> { - let cell = self.rows.get_mut(y)?.get_mut(x)?; - match cell { - // Reborrow here, so the borrow of `cell` doesn't get returned from - // the function. Otherwise the borrow checker assumes `cell` borrows - // `self.rows` for the entirety of the function, not just this match - // arm, and doesn't allow the second mutable borrow in the match arm - // below. - GridCell::Cell(_) => self.rows[y][x].as_cell_mut(), - &mut GridCell::Spanned(x, y) => self.rows[y][x].as_cell_mut(), - GridCell::Missing => None, - } - } - - pub fn contains(&self, cell: &Packed) -> bool { - let x = cell.x.val().unwrap_or_else(|| unreachable!()); - let y = cell.y.val().unwrap_or_else(|| unreachable!()); - self.get(x, y).is_some() - } - - fn resolve_cell<'a>(&'a self, cell: &'a GridCell) -> Option<&'a TableCtxCell> { - match cell { - GridCell::Cell(cell) => Some(cell), - &GridCell::Spanned(x, y) => self.rows[y][x].as_cell(), - GridCell::Missing => None, - } - } - pub fn insert(&mut self, cell: &Packed, contents: GroupContents) { let x = cell.x.val().unwrap_or_else(|| unreachable!()); let y = cell.y.val().unwrap_or_else(|| unreachable!()); let rowspan = cell.rowspan.val(); let colspan = cell.colspan.val(); let kind = cell.kind.val(); - - // Extend the table grid to fit this cell. - let required_height = y + rowspan.get(); - self.min_width = self.min_width.max(x + colspan.get()); - if self.rows.len() < required_height { - self.rows - .resize(required_height, vec![GridCell::Missing; self.min_width]); - } - for row in self.rows.iter_mut() { - if row.len() < self.min_width { - row.resize_with(self.min_width, || GridCell::Missing); - } - } - - // Store references to the cell for all spanned cells. - for i in y..y + rowspan.get() { - for j in x..x + colspan.get() { - self.rows[i][j] = GridCell::Spanned(x, y); - } - } - - self.rows[y][x] = GridCell::Cell(TableCtxCell { + self.insert_cell(CtxCell { + data: TableCellData { kind, headers: SmallVec::new() }, x: x.saturating_as(), y: y.saturating_as(), rowspan: rowspan.try_into().unwrap_or(NonZeroU32::MAX), colspan: colspan.try_into().unwrap_or(NonZeroU32::MAX), - kind, - headers: SmallVec::new(), contents, }); } @@ -106,7 +70,7 @@ impl TableCtx { // Table layouting ensures that there are no overlapping cells, and that // any gaps left by the user are filled with empty cells. if self.rows.is_empty() { - return TagNode::group(Tag::Table.with_summary(self.summary), contents); + return TagNode::group(Tag::Table.with_summary(self.data.summary), contents); } let height = self.rows.len(); let width = self.rows[0].len(); @@ -118,7 +82,7 @@ impl TableCtx { .map(|row| { row.iter() .filter_map(|cell| self.resolve_cell(cell)) - .map(|cell| cell.kind) + .map(|cell| cell.data.kind) .fold(Smart::Auto, |a, b| { if let Smart::Custom(TableCellKind::Header(_, scope)) = b { gen_row_groups &= scope == TableHeaderScope::Column; @@ -138,7 +102,7 @@ impl TableCtx { if gen_row_groups { row_kind } else { TableCellKind::Data }; for cell in row.iter_mut() { let Some(cell) = cell.as_cell_mut() else { continue }; - cell.kind = cell.kind.or(Smart::Custom(default_kind)); + cell.data.kind = cell.data.kind.or(Smart::Custom(default_kind)); } } @@ -173,19 +137,19 @@ impl TableCtx { let cell = cell.into_cell()?; let rowspan = (cell.rowspan.get() != 1).then_some(cell.rowspan); let colspan = (cell.colspan.get() != 1).then_some(cell.colspan); - let tag: TagKind = match cell.unwrap_kind() { + let tag: TagKind = match cell.data.unwrap_kind() { TableCellKind::Header(_, scope) => { - let id = table_cell_id(self.id, cell.x, cell.y); + let id = table_cell_id(self.data.id, cell.x, cell.y); let scope = table_header_scope(scope); Tag::TH(scope) .with_id(Some(id)) - .with_headers(Some(cell.headers)) + .with_headers(Some(cell.data.headers)) .with_row_span(rowspan) .with_col_span(colspan) .into() } TableCellKind::Footer | TableCellKind::Data => Tag::TD - .with_headers(Some(cell.headers)) + .with_headers(Some(cell.data.headers)) .with_row_span(rowspan) .with_col_span(colspan) .into(), @@ -226,7 +190,9 @@ impl TableCtx { contents.nodes.push(TagNode::virtual_group(tag, row_chunk)); } - let tag = Tag::Table.with_summary(self.summary).with_bbox(self.bbox.get()); + let tag = Tag::Table + .with_summary(self.data.summary) + .with_bbox(self.data.bbox.get()); TagNode::group(tag, contents) } @@ -238,11 +204,11 @@ impl TableCtx { ) where F: Fn(&TableHeaderScope) -> bool, { - let table_id = self.id; + let table_id = self.data.id; let Some(cell) = self.get_mut(x, y) else { return }; let mut new_header = None; - if let TableCellKind::Header(level, scope) = cell.unwrap_kind() { + if let TableCellKind::Header(level, scope) = cell.data.unwrap_kind() { if refers_to_dir(&scope) { // Remove all headers that are the same or a lower level. while current_header.pop_if(|(l, _)| *l >= level).is_some() {} @@ -253,8 +219,8 @@ impl TableCtx { } if let Some((_, cell_id)) = current_header.last() { - if !cell.headers.contains(cell_id) { - cell.headers.push(cell_id.clone()); + if !cell.data.headers.contains(cell_id) { + cell.data.headers.push(cell_id.clone()); } } @@ -262,40 +228,136 @@ impl TableCtx { } } +impl GridCtx { + pub fn new() -> Self { + Self { data: GridData, rows: Vec::new(), min_width: 0 } + } + + pub fn insert(&mut self, cell: &Packed, contents: GroupContents) { + let x = cell.x.val().unwrap_or_else(|| unreachable!()); + let y = cell.y.val().unwrap_or_else(|| unreachable!()); + let rowspan = cell.rowspan.val(); + let colspan = cell.colspan.val(); + self.insert_cell(CtxCell { + data: GridCellData, + x: x.saturating_as(), + y: y.saturating_as(), + rowspan: rowspan.try_into().unwrap_or(NonZeroU32::MAX), + colspan: colspan.try_into().unwrap_or(NonZeroU32::MAX), + contents, + }); + } + + pub fn build_grid(self, mut contents: GroupContents) -> TagNode { + let cells = (self.rows.into_iter()) + .flat_map(|row| row.into_iter()) + .filter_map(GridField::into_cell) + .map(|cell| TagNode::group(Tag::Div, cell.contents)); + + contents.nodes.extend(cells); + + TagNode::group(Tag::Div, contents) + } +} + +impl GridCtx { + fn get_mut(&mut self, x: usize, y: usize) -> Option<&mut CtxCell> { + let cell = self.rows.get_mut(y)?.get_mut(x)?; + match cell { + // Reborrow here, so the borrow of `cell` doesn't get returned from + // the function. Otherwise the borrow checker assumes `cell` borrows + // `self.rows` for the entirety of the function, not just this match + // arm, and doesn't allow the second mutable borrow in the match arm + // below. + GridField::Cell(_) => self.rows[y][x].as_cell_mut(), + &mut GridField::Spanned(x, y) => self.rows[y][x].as_cell_mut(), + GridField::Missing => None, + } + } + + fn resolve_cell<'a>( + &'a self, + cell: &'a GridField, + ) -> Option<&'a CtxCell> { + match cell { + GridField::Cell(cell) => Some(cell), + &GridField::Spanned(x, y) => self.rows[y][x].as_cell(), + GridField::Missing => None, + } + } + + fn insert_cell(&mut self, cell: CtxCell) { + let x = cell.x as usize; + let y = cell.y as usize; + let rowspan = cell.rowspan.get() as usize; + let colspan = cell.colspan.get() as usize; + + // Extend the table grid to fit this cell. + let required_height = y + rowspan; + self.min_width = self.min_width.max(x + colspan); + if self.rows.len() < required_height { + self.rows + .resize(required_height, vec![GridField::Missing; self.min_width]); + } + for row in self.rows.iter_mut() { + if row.len() < self.min_width { + row.resize_with(self.min_width, || GridField::Missing); + } + } + + // Store references to the cell for all spanned cells. + for i in y..y + rowspan { + for j in x..x + colspan { + self.rows[i][j] = GridField::Spanned(x, y); + } + } + + self.rows[y][x] = GridField::Cell(cell); + } +} + #[derive(Clone, Debug, Default)] -enum GridCell { - Cell(TableCtxCell), +enum GridField { + Cell(CtxCell), Spanned(usize, usize), #[default] Missing, } -impl GridCell { - fn as_cell(&self) -> Option<&TableCtxCell> { +impl GridField { + fn as_cell(&self) -> Option<&CtxCell> { if let Self::Cell(v) = self { Some(v) } else { None } } - fn as_cell_mut(&mut self) -> Option<&mut TableCtxCell> { + fn as_cell_mut(&mut self) -> Option<&mut CtxCell> { if let Self::Cell(v) = self { Some(v) } else { None } } - fn into_cell(self) -> Option { + fn into_cell(self) -> Option> { if let Self::Cell(v) = self { Some(v) } else { None } } } #[derive(Clone, Debug)] -struct TableCtxCell { +struct CtxCell { + data: D, x: u32, y: u32, rowspan: NonZeroU32, colspan: NonZeroU32, + contents: GroupContents, +} + +#[derive(Clone, Debug)] +pub struct GridCellData; + +#[derive(Clone, Debug)] +pub struct TableCellData { kind: Smart, headers: SmallVec<[TagId; 1]>, - contents: GroupContents, } -impl TableCtxCell { +impl TableCellData { fn unwrap_kind(&self) -> TableCellKind { self.kind.unwrap_or_else(|| unreachable!()) } diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index 436d6432be..1363da4ad5 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -10,7 +10,9 @@ use krilla::tagging::{ use typst_library::diag::{SourceResult, bail}; use typst_library::foundations::Content; use typst_library::introspection::Location; -use typst_library::layout::{HideElem, Point, Rect, RepeatElem, Size}; +use typst_library::layout::{ + GridCell, GridElem, HideElem, Point, Rect, RepeatElem, Size, +}; use typst_library::math::EquationElem; use typst_library::model::{ EnumElem, FigureCaption, FigureElem, FootnoteEntry, HeadingElem, LinkMarker, @@ -27,18 +29,18 @@ use typst_syntax::Span; use crate::convert::{FrameContext, GlobalContext}; use crate::link::LinkAnnotation; +use crate::tags::grid::{GridCtx, GridData, TableData}; use crate::tags::list::ListCtx; use crate::tags::outline::OutlineCtx; -use crate::tags::table::TableCtx; use crate::tags::text::{ResolvedTextAttrs, TextDecoKind}; use crate::tags::util::{PropertyOptRef, PropertyValCloned, PropertyValCopied}; pub use context::*; mod context; +mod grid; mod list; mod outline; -mod table; mod text; mod util; @@ -203,22 +205,43 @@ pub fn handle_start( } else if let Some(table) = elem.to_packed::() { let table_id = gc.tags.next_table_id(); let summary = table.summary.opt_ref().map(|s| s.to_string()); - let ctx = TableCtx::new(table_id, summary); + let ctx = GridCtx::::new(table_id, summary); push_stack(gc, elem, StackEntryKind::Table(ctx)); return Ok(()); } else if let Some(cell) = elem.to_packed::() { - let table_ctx = gc.tags.stack.parent_table(); - // Only repeated table headers and footer cells are laid out multiple // times. Mark duplicate headers as artifacts, since they have no // semantic meaning in the tag tree, which doesn't use page breaks for // it's semantic structure. - if cell.is_repeated.val() || table_ctx.is_some_and(|ctx| ctx.contains(cell)) { + if cell.is_repeated.val() { push_disable(gc, surface, elem, ArtifactKind::Other); } else { push_stack(gc, elem, StackEntryKind::TableCell(cell.clone())); } return Ok(()); + } else if let Some(_) = elem.to_packed::() { + let ctx = GridCtx::::new(); + push_stack(gc, elem, StackEntryKind::Grid(ctx)); + return Ok(()); + } else if let Some(cell) = elem.to_packed::() { + // If there is no grid parent, this means a grid layouter is used + // internally. Don't generate a stack entry. + if gc.tags.stack.parent_grid().is_some() { + // The grid cells are collected into a grid to ensure proper reading + // order, even when using rowspans, which may be laid out later than + // other cells in the same row. + + // Only repeated grid headers and footer cells are laid out multiple + // times. Mark duplicate headers as artifacts, since they have no + // semantic meaning in the tag tree, which doesn't use page breaks for + // it's semantic structure. + if cell.is_repeated.val() { + push_disable(gc, surface, elem, ArtifactKind::Other); + } else { + push_stack(gc, elem, StackEntryKind::GridCell(cell.clone())); + } + } + return Ok(()); } else if let Some(heading) = elem.to_packed::() { let level = heading.level().try_into().unwrap_or(NonZeroU16::MAX); let name = heading.body.plain_text().to_string(); @@ -453,6 +476,16 @@ fn pop_stack(gc: &mut GlobalContext, entry: StackEntry) { TagNode::group(Tag::TD, contents) } } + StackEntryKind::Grid(ctx) => ctx.build_grid(contents), + StackEntryKind::GridCell(cell) => { + if let Some(grid_ctx) = gc.tags.stack.parent_grid() { + grid_ctx.insert(&cell, contents); + return; + } else { + // Avoid panicking, the nesting will be validated later. + TagNode::group(Tag::Div, contents) + } + } StackEntryKind::List(list) => list.build_list(contents), StackEntryKind::ListItemLabel => { let list_ctx = gc.tags.stack.parent_list().expect("parent list"); diff --git a/tests/ref/pdftags/grid-tags-rowspan.yml b/tests/ref/pdftags/grid-tags-rowspan.yml new file mode 100644 index 0000000000..e47b73e5c7 --- /dev/null +++ b/tests/ref/pdftags/grid-tags-rowspan.yml @@ -0,0 +1,28 @@ +- Tag: Div + /K: + - Tag: Div + /K: + - Tag: Code + /K: + - Content: page=0 mcid=4 + - Tag: Div + /K: + - Content: page=0 mcid=0 + - Tag: Div + /K: + - Content: page=0 mcid=1 + - Tag: Div + /K: + - Content: page=0 mcid=2 + - Tag: Div + /K: + - Content: page=0 mcid=3 + - Tag: Div + /K: + - Tag: Span + /TextDecorationType: Underline + /K: + - Content: page=0 mcid=5 + - Tag: Div + /K: + - Content: page=0 mcid=6 diff --git a/tests/suite/pdftags/grid.typ b/tests/suite/pdftags/grid.typ new file mode 100644 index 0000000000..0b514caee8 --- /dev/null +++ b/tests/suite/pdftags/grid.typ @@ -0,0 +1,11 @@ +--- grid-tags-rowspan pdftags --- +#grid( + columns: 4, + stroke: 1pt, + rows: 3, + // the code cell should come first in the reading order + grid.cell(rowspan: 3)[`code`], [b], [c], [d], + // the underline cell should come second to last + [b], grid.cell(x: 2, y: 1, colspan: 2, rowspan: 2, underline[text]), + [b], +) From 7c28c15eba4c0a06de15e8865cd00ca2e906f69a Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Thu, 7 Aug 2025 16:11:21 +0200 Subject: [PATCH 422/558] Generate tags for strong and emph --- crates/typst-pdf/src/tags/context.rs | 10 +- crates/typst-pdf/src/tags/mod.rs | 20 ++-- crates/typst-pdf/src/tags/text.rs | 99 +++++++++++++++---- tests/ref/pdftags/deco-tags-emph-basic.yml | 7 ++ tests/ref/pdftags/deco-tags-strong-and-em.yml | 7 ++ tests/ref/pdftags/deco-tags-strong-basic.yml | 8 ++ .../deco-tags-strong-em-and-more-attrs.yml | 11 +++ tests/ref/pdftags/grid-headers.yml | 24 +++-- tests/ref/pdftags/terms-constructor.yml | 8 +- tests/suite/pdftags/deco.typ | 12 +++ 10 files changed, 163 insertions(+), 43 deletions(-) create mode 100644 tests/ref/pdftags/deco-tags-emph-basic.yml create mode 100644 tests/ref/pdftags/deco-tags-strong-and-em.yml create mode 100644 tests/ref/pdftags/deco-tags-strong-basic.yml create mode 100644 tests/ref/pdftags/deco-tags-strong-em-and-more-attrs.yml diff --git a/crates/typst-pdf/src/tags/context.rs b/crates/typst-pdf/src/tags/context.rs index 154898ebcf..f2ad4bc8dc 100644 --- a/crates/typst-pdf/src/tags/context.rs +++ b/crates/typst-pdf/src/tags/context.rs @@ -76,8 +76,6 @@ impl Tags { return; } - // FIXME: Artifacts will force a split in the spans, and decoartions - // generate artifacts let last_node = if let Some(entry) = self.stack.last_mut() { entry.nodes.last_mut() } else { @@ -147,10 +145,8 @@ impl Tags { self.resolve_node(node) } TagNode::Text(attrs, ids) => { - let tag = attrs.to_tag(); let children = ids.into_iter().map(Node::Leaf).collect(); - let group = krilla::tagging::TagGroup::with_children(tag, children); - Node::Group(group) + attrs.build_node(children) } } } @@ -462,8 +458,8 @@ impl StackEntryKind { TagKind::Datetime(_) => !is_pdf_ua, TagKind::Terms(_) => !is_pdf_ua, TagKind::Title(_) => !is_pdf_ua, - TagKind::Strong(_) => !is_pdf_ua, - TagKind::Em(_) => !is_pdf_ua, + TagKind::Strong(_) => true, + TagKind::Em(_) => true, }, StackEntryKind::Outline(_) => false, StackEntryKind::OutlineEntry(_) => false, diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index 1363da4ad5..731ac5748d 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -15,9 +15,9 @@ use typst_library::layout::{ }; use typst_library::math::EquationElem; use typst_library::model::{ - EnumElem, FigureCaption, FigureElem, FootnoteEntry, HeadingElem, LinkMarker, - ListElem, Outlinable, OutlineEntry, ParElem, QuoteElem, TableCell, TableElem, - TermsElem, + EmphElem, EnumElem, FigureCaption, FigureElem, FootnoteEntry, HeadingElem, + LinkMarker, ListElem, Outlinable, OutlineEntry, ParElem, QuoteElem, StrongElem, + TableCell, TableElem, TermsElem, }; use typst_library::pdf::{ArtifactElem, ArtifactKind, PdfMarkerTag, PdfMarkerTagKind}; use typst_library::text::{ @@ -32,7 +32,7 @@ use crate::link::LinkAnnotation; use crate::tags::grid::{GridCtx, GridData, TableData}; use crate::tags::list::ListCtx; use crate::tags::outline::OutlineCtx; -use crate::tags::text::{ResolvedTextAttrs, TextDecoKind}; +use crate::tags::text::{ResolvedTextAttrs, TextAttr, TextDecoKind}; use crate::tags::util::{PropertyOptRef, PropertyValCloned, PropertyValCopied}; pub use context::*; @@ -272,15 +272,21 @@ pub fn handle_start( push_stack(gc, elem, StackEntryKind::CodeBlockLine); } return Ok(()); + } else if let Some(_) = elem.to_packed::() { + gc.tags.text_attrs.push(elem, TextAttr::Strong); + return Ok(()); + } else if let Some(_) = elem.to_packed::() { + gc.tags.text_attrs.push(elem, TextAttr::Emph); + return Ok(()); } else if let Some(sub) = elem.to_packed::() { let baseline_shift = sub.baseline.val(); let lineheight = sub.size.val(); let kind = ScriptKind::Sub; gc.tags.text_attrs.push_script(elem, kind, baseline_shift, lineheight); return Ok(()); - } else if let Some(sub) = elem.to_packed::() { - let baseline_shift = sub.baseline.val(); - let lineheight = sub.size.val(); + } else if let Some(sup) = elem.to_packed::() { + let baseline_shift = sup.baseline.val(); + let lineheight = sup.size.val(); let kind = ScriptKind::Super; gc.tags.text_attrs.push_script(elem, kind, baseline_shift, lineheight); return Ok(()); diff --git a/crates/typst-pdf/src/tags/text.rs b/crates/typst-pdf/src/tags/text.rs index 7428c32056..4ddcdec7b6 100644 --- a/crates/typst-pdf/src/tags/text.rs +++ b/crates/typst-pdf/src/tags/text.rs @@ -1,4 +1,4 @@ -use krilla::tagging::{LineHeight, NaiveRgbColor, Tag, TextDecorationType, kind}; +use krilla::tagging::{LineHeight, NaiveRgbColor, Node, Tag, TextDecorationType}; use typst_library::diag::{SourceResult, bail}; use typst_library::foundations::{Content, Smart}; use typst_library::introspection::Location; @@ -30,14 +30,12 @@ impl TextAttrs { lineheight: Smart, ) { let val = Script { kind, baseline_shift, lineheight }; - let loc = elem.location().unwrap(); - self.push(loc, TextAttr::Script(val)); + self.push(elem, TextAttr::Script(val)); } pub fn push_highlight(&mut self, elem: &Content, paint: Option<&Paint>) { let color = paint.and_then(color_from_paint); - let loc = elem.location().unwrap(); - self.push(loc, TextAttr::Highlight(color)); + self.push(elem, TextAttr::Highlight(color)); } pub fn push_deco( @@ -67,12 +65,12 @@ impl TextAttrs { ); } - let loc = elem.location().unwrap(); - self.push(loc, TextAttr::Deco(deco)); + self.push(elem, TextAttr::Deco(deco)); Ok(()) } - fn push(&mut self, loc: Location, attr: TextAttr) { + pub fn push(&mut self, elem: &Content, attr: TextAttr) { + let loc = elem.location().unwrap(); self.last_resolved = None; self.items.push((loc, attr)); } @@ -105,7 +103,9 @@ impl TextAttrs { } #[derive(Clone, Copy, Debug, PartialEq)] -enum TextAttr { +pub enum TextAttr { + Strong, + Emph, Script(Script), Highlight(Option), Deco(TextDeco), @@ -119,14 +119,14 @@ impl TextAttr { /// Sub- or super-script. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct Script { +pub struct Script { kind: ScriptKind, baseline_shift: Smart, lineheight: Smart, } #[derive(Clone, Copy, Debug, PartialEq)] -struct TextDeco { +pub struct TextDeco { kind: TextDecoKind, stroke: TextDecoStroke, } @@ -181,30 +181,81 @@ fn color_from_paint(paint: &Paint) -> Option { #[derive(Clone, Copy, Debug, PartialEq)] pub struct ResolvedTextAttrs { + strong: Option, + emph: Option, script: Option, background: Option>, deco: Option, } impl ResolvedTextAttrs { - pub const EMPTY: Self = Self { script: None, background: None, deco: None }; + pub const EMPTY: Self = Self { + strong: None, + emph: None, + script: None, + background: None, + deco: None, + }; pub fn is_empty(&self) -> bool { self == &Self::EMPTY } pub fn all_resolved(&self) -> bool { - self.script.is_some() && self.background.is_some() && self.deco.is_some() + self.strong.is_some() + && self.emph.is_some() + && self.script.is_some() + && self.background.is_some() + && self.deco.is_some() } - pub fn to_tag(self) -> Tag { - Tag::Span - .with_line_height(self.script.map(|s| s.lineheight)) - .with_baseline_shift(self.script.map(|s| s.baseline_shift)) - .with_background_color(self.background.flatten()) - .with_text_decoration_type(self.deco.map(|d| d.kind.to_krilla())) - .with_text_decoration_color(self.deco.and_then(|d| d.color)) - .with_text_decoration_thickness(self.deco.and_then(|d| d.thickness)) + pub fn build_node(self, children: Vec) -> Node { + enum Prev { + Children(Vec), + Group(krilla::tagging::TagGroup), + } + + impl Prev { + fn into_nodes(self) -> Vec { + match self { + Prev::Children(nodes) => nodes, + Prev::Group(group) => vec![Node::Group(group)], + } + } + } + + let mut prev = Prev::Children(children); + if self.script.is_some() || self.background.is_some() || self.deco.is_some() { + let tag = Tag::Span + .with_line_height(self.script.map(|s| s.lineheight)) + .with_baseline_shift(self.script.map(|s| s.baseline_shift)) + .with_background_color(self.background.flatten()) + .with_text_decoration_type(self.deco.map(|d| d.kind.to_krilla())) + .with_text_decoration_color(self.deco.and_then(|d| d.color)) + .with_text_decoration_thickness(self.deco.and_then(|d| d.thickness)); + + let group = krilla::tagging::TagGroup::with_children(tag, prev.into_nodes()); + prev = Prev::Group(group); + } + if self.strong == Some(true) { + let group = + krilla::tagging::TagGroup::with_children(Tag::Strong, prev.into_nodes()); + prev = Prev::Group(group); + } + if self.emph == Some(true) { + let group = + krilla::tagging::TagGroup::with_children(Tag::Em, prev.into_nodes()); + prev = Prev::Group(group); + } + + match prev { + Prev::Group(group) => Node::Group(group), + Prev::Children(nodes) => { + // This should not happen. It can only happen if an empty set of + // `ResolvedTextAttrs` was pushed into a `TagNode::Text`. + Node::Group(krilla::tagging::TagGroup::with_children(Tag::Span, nodes)) + } + } } } @@ -245,6 +296,12 @@ fn resolve_attrs( let mut attrs = ResolvedTextAttrs::EMPTY; for (_, attr) in items.iter().rev() { match *attr { + TextAttr::Strong => { + attrs.strong.get_or_insert(true); + } + TextAttr::Emph => { + attrs.emph.get_or_insert(true); + } TextAttr::Script(script) => { // TODO: The `typographic` setting is ignored for now. // Is it better to be accurate regarding the layouting, and diff --git a/tests/ref/pdftags/deco-tags-emph-basic.yml b/tests/ref/pdftags/deco-tags-emph-basic.yml new file mode 100644 index 0000000000..64296d1263 --- /dev/null +++ b/tests/ref/pdftags/deco-tags-emph-basic.yml @@ -0,0 +1,7 @@ +- Tag: P + /K: + - Content: page=0 mcid=0 + - Tag: Em + /K: + - Content: page=0 mcid=1 + - Content: page=0 mcid=2 diff --git a/tests/ref/pdftags/deco-tags-strong-and-em.yml b/tests/ref/pdftags/deco-tags-strong-and-em.yml new file mode 100644 index 0000000000..0b2d7f3460 --- /dev/null +++ b/tests/ref/pdftags/deco-tags-strong-and-em.yml @@ -0,0 +1,7 @@ +- Tag: P + /K: + - Tag: Em + /K: + - Tag: Strong + /K: + - Content: page=0 mcid=0 diff --git a/tests/ref/pdftags/deco-tags-strong-basic.yml b/tests/ref/pdftags/deco-tags-strong-basic.yml new file mode 100644 index 0000000000..2380a7c8e1 --- /dev/null +++ b/tests/ref/pdftags/deco-tags-strong-basic.yml @@ -0,0 +1,8 @@ +- Tag: P + /K: + - Content: page=0 mcid=0 + - Tag: Strong + /K: + - Content: page=0 mcid=1 + - Content: page=0 mcid=2 + - Content: page=0 mcid=3 diff --git a/tests/ref/pdftags/deco-tags-strong-em-and-more-attrs.yml b/tests/ref/pdftags/deco-tags-strong-em-and-more-attrs.yml new file mode 100644 index 0000000000..a3014e58c4 --- /dev/null +++ b/tests/ref/pdftags/deco-tags-strong-em-and-more-attrs.yml @@ -0,0 +1,11 @@ +- Tag: P + /K: + - Tag: Em + /K: + - Tag: Strong + /K: + - Tag: Span + /TextDecorationColor: #2ecc40 + /TextDecorationType: Underline + /K: + - Content: page=0 mcid=0 diff --git a/tests/ref/pdftags/grid-headers.yml b/tests/ref/pdftags/grid-headers.yml index 83883cae4d..5e035192bd 100644 --- a/tests/ref/pdftags/grid-headers.yml +++ b/tests/ref/pdftags/grid-headers.yml @@ -10,7 +10,9 @@ /Headers: [] /ColSpan: 5 /K: - - Content: page=0 mcid=0 + - Tag: Strong + /K: + - Content: page=0 mcid=0 - Tag: TR /K: - Tag: TH @@ -18,31 +20,41 @@ /Scope: Column /Headers: [] /K: - - Content: page=0 mcid=1 + - Tag: Strong + /K: + - Content: page=0 mcid=1 - Tag: TH /Id: "U1x1y1" /Scope: Column /Headers: [] /K: - - Content: page=0 mcid=2 + - Tag: Strong + /K: + - Content: page=0 mcid=2 - Tag: TH /Id: "U1x2y1" /Scope: Column /Headers: [] /K: - - Content: page=0 mcid=3 + - Tag: Strong + /K: + - Content: page=0 mcid=3 - Tag: TH /Id: "U1x3y1" /Scope: Column /Headers: [] /K: - - Content: page=0 mcid=4 + - Tag: Strong + /K: + - Content: page=0 mcid=4 - Tag: TH /Id: "U1x4y1" /Scope: Column /Headers: [] /K: - - Content: page=0 mcid=5 + - Tag: Strong + /K: + - Content: page=0 mcid=5 - Tag: TBody /K: - Tag: TR diff --git a/tests/ref/pdftags/terms-constructor.yml b/tests/ref/pdftags/terms-constructor.yml index ca41d74b04..60affce123 100644 --- a/tests/ref/pdftags/terms-constructor.yml +++ b/tests/ref/pdftags/terms-constructor.yml @@ -5,7 +5,9 @@ /K: - Tag: Lbl /K: - - Content: page=0 mcid=0 + - Tag: Strong + /K: + - Content: page=0 mcid=0 - Tag: LBody /K: - Content: page=0 mcid=1 @@ -13,7 +15,9 @@ /K: - Tag: Lbl /K: - - Content: page=0 mcid=2 + - Tag: Strong + /K: + - Content: page=0 mcid=2 - Tag: LBody /K: - Content: page=0 mcid=3 diff --git a/tests/suite/pdftags/deco.typ b/tests/suite/pdftags/deco.typ index 08b2dadb7e..a429a445aa 100644 --- a/tests/suite/pdftags/deco.typ +++ b/tests/suite/pdftags/deco.typ @@ -47,3 +47,15 @@ CI#super[-] has a negative charge. #set super(baseline: -9.5pt) #sub[sub] #super[super] + +--- deco-tags-emph-basic pdftags --- +Cats are _cute_ animals. + +--- deco-tags-strong-basic pdftags --- +This *HERE* is important! + +--- deco-tags-strong-and-em pdftags --- +_*strong and emph*_ + +--- deco-tags-strong-em-and-more-attrs pdftags --- +#underline(stroke: green)[_*strong and emph*_] From 68f4a209257a9baad3b127aa2f519c210737487d Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Mon, 18 Aug 2025 20:10:36 +0200 Subject: [PATCH 423/558] Properly resolve multi-row table headers Synthesize the CellGrid in table and grid elements, so it can be used outside of the grid layout code. --- crates/typst-html/src/rules.rs | 11 +- crates/typst-layout/src/grid/mod.rs | 10 +- crates/typst-layout/src/rules.rs | 5 +- crates/typst-library/src/layout/grid/mod.rs | 21 +- .../typst-library/src/layout/grid/resolve.rs | 34 +- crates/typst-library/src/model/table.rs | 26 +- crates/typst-pdf/src/tags/context.rs | 18 +- crates/typst-pdf/src/tags/grid.rs | 549 ++++++++++++------ crates/typst-pdf/src/tags/mod.rs | 10 +- tests/ref/pdftags/grid-headers.yml | 60 +- 10 files changed, 487 insertions(+), 257 deletions(-) diff --git a/crates/typst-html/src/rules.rs b/crates/typst-html/src/rules.rs index c4cc008edb..ce868d75f9 100644 --- a/crates/typst-html/src/rules.rs +++ b/crates/typst-html/src/rules.rs @@ -8,7 +8,7 @@ use typst_library::foundations::{ Content, Context, NativeElement, NativeRuleMap, ShowFn, Smart, StyleChain, Target, }; use typst_library::introspection::Counter; -use typst_library::layout::resolve::{Cell, CellGrid, Entry, table_to_cellgrid}; +use typst_library::layout::resolve::{Cell, CellGrid, Entry}; use typst_library::layout::{ BlockBody, BlockElem, BoxElem, HElem, OuterVAlignment, Sizing, }; @@ -529,11 +529,12 @@ const CSL_INDENT_RULE: ShowFn = |elem, _, _| { .pack()) }; -const TABLE_RULE: ShowFn = |elem, engine, styles| { - Ok(show_cellgrid(table_to_cellgrid(elem, engine, styles)?, styles)) +const TABLE_RULE: ShowFn = |elem, _, styles| { + let grid = elem.grid.as_ref().unwrap(); + Ok(show_cellgrid(grid, styles)) }; -fn show_cellgrid(grid: CellGrid, styles: StyleChain) -> Content { +fn show_cellgrid(grid: &CellGrid, styles: StyleChain) -> Content { let elem = |tag, body| HtmlElem::new(tag).with_body(Some(body)).pack(); let mut rows: Vec<_> = grid.entries.chunks(grid.non_gutter_column_count()).collect(); @@ -547,7 +548,7 @@ fn show_cellgrid(grid: CellGrid, styles: StyleChain) -> Content { // TODO(subfooters): similarly to headers, take consecutive footers from // the end for 'tfoot'. - let footer = grid.footer.map(|ft| { + let footer = grid.footer.as_ref().map(|ft| { let rows = rows.drain(ft.start..); elem(tag::tfoot, Content::sequence(rows.map(|row| tr(tag::td, row)))) }); diff --git a/crates/typst-layout/src/grid/mod.rs b/crates/typst-layout/src/grid/mod.rs index 333cc33ca2..84339343a0 100644 --- a/crates/typst-layout/src/grid/mod.rs +++ b/crates/typst-layout/src/grid/mod.rs @@ -9,7 +9,7 @@ use typst_library::diag::SourceResult; use typst_library::engine::Engine; use typst_library::foundations::{NativeElement, Packed, StyleChain}; use typst_library::introspection::{Locator, SplitLocator, Tag}; -use typst_library::layout::grid::resolve::{Cell, grid_to_cellgrid, table_to_cellgrid}; +use typst_library::layout::grid::resolve::Cell; use typst_library::layout::{Fragment, FrameItem, GridCell, GridElem, Point, Regions}; use typst_library::model::{TableCell, TableElem}; @@ -86,8 +86,8 @@ pub fn layout_grid( styles: StyleChain, regions: Regions, ) -> SourceResult { - let grid = grid_to_cellgrid(elem, engine, styles)?; - GridLayouter::new(&grid, regions, locator, styles, elem.span()).layout(engine) + let grid = elem.grid.as_ref().unwrap(); + GridLayouter::new(grid, regions, locator, styles, elem.span()).layout(engine) } /// Layout the table. @@ -99,6 +99,6 @@ pub fn layout_table( styles: StyleChain, regions: Regions, ) -> SourceResult { - let grid = table_to_cellgrid(elem, engine, styles)?; - GridLayouter::new(&grid, regions, locator, styles, elem.span()).layout(engine) + let grid = elem.grid.as_ref().unwrap(); + GridLayouter::new(grid, regions, locator, styles, elem.span()).layout(engine) } diff --git a/crates/typst-layout/src/rules.rs b/crates/typst-layout/src/rules.rs index 692171e56a..a69d6109d2 100644 --- a/crates/typst-layout/src/rules.rs +++ b/crates/typst-layout/src/rules.rs @@ -4,7 +4,7 @@ use smallvec::smallvec; use typst_library::diag::{At, SourceResult, bail}; use typst_library::foundations::{ Content, Context, NativeElement, NativeRuleMap, Packed, Resolve, ShowFn, Smart, - StyleChain, Target, dict, + StyleChain, Synthesize, Target, dict, }; use typst_library::introspection::{Counter, Locator, LocatorLink}; use typst_library::layout::{ @@ -500,7 +500,8 @@ const BIBLIOGRAPHY_RULE: ShowFn = |elem, engine, styles| { .with_columns(TrackSizings(smallvec![Sizing::Auto; 2])) .with_column_gutter(TrackSizings(smallvec![COLUMN_GUTTER.into()])) .with_row_gutter(TrackSizings(smallvec![row_gutter.into()])); - let packed = Packed::new(grid).spanned(span); + let mut packed = Packed::new(grid).spanned(span); + packed.synthesize(engine, styles)?; // Directly build the block element to avoid the show step for the grid // element. This will not generate introspection tags for the element. let block = BlockElem::multi_layouter(packed, crate::grid::layout_grid).pack(); diff --git a/crates/typst-library/src/layout/grid/mod.rs b/crates/typst-library/src/layout/grid/mod.rs index a166d0dabd..189062fbaf 100644 --- a/crates/typst-library/src/layout/grid/mod.rs +++ b/crates/typst-library/src/layout/grid/mod.rs @@ -11,9 +11,10 @@ use crate::diag::{At, HintedStrResult, HintedString, SourceResult, bail}; use crate::engine::Engine; use crate::foundations::{ Array, CastInfo, Content, Context, Fold, FromValue, Func, IntoValue, Packed, Reflect, - Resolve, Smart, StyleChain, Value, cast, elem, scope, + Resolve, Smart, StyleChain, Synthesize, Value, cast, elem, scope, }; use crate::introspection::Locatable; +use crate::layout::resolve::{CellGrid, grid_to_cellgrid}; use crate::layout::{ Alignment, Length, OuterHAlignment, OuterVAlignment, Rel, Sides, Sizing, }; @@ -169,7 +170,7 @@ use crate::visualize::{Paint, Stroke}; /// /// Furthermore, strokes of a repeated grid header or footer will take /// precedence over regular cell strokes. -#[elem(scope, Locatable)] +#[elem(scope, Synthesize, Locatable)] pub struct GridElem { /// The column sizes. /// @@ -393,6 +394,10 @@ pub struct GridElem { #[fold] pub stroke: Celled>>>>, + #[internal] + #[synthesized] + pub grid: Arc, + /// The contents of the grid cells, plus any extra grid lines specified with /// the [`grid.hline`] and [`grid.vline`] elements. /// @@ -419,6 +424,18 @@ impl GridElem { type GridFooter; } +impl Synthesize for Packed { + fn synthesize( + &mut self, + engine: &mut Engine, + styles: StyleChain, + ) -> SourceResult<()> { + let grid = grid_to_cellgrid(self, engine, styles)?; + self.grid = Some(Arc::new(grid)); + Ok(()) + } +} + /// Track sizing definitions. #[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] pub struct TrackSizings(pub SmallVec<[Sizing; 4]>); diff --git a/crates/typst-library/src/layout/grid/resolve.rs b/crates/typst-library/src/layout/grid/resolve.rs index 47bbb6e366..f3c50ccb39 100644 --- a/crates/typst-library/src/layout/grid/resolve.rs +++ b/crates/typst-library/src/layout/grid/resolve.rs @@ -393,6 +393,7 @@ impl ResolvableCell for Packed { /// Represents an explicit grid line (horizontal or vertical) specified by the /// user. +#[derive(Debug, PartialEq, Eq, Hash)] pub struct Line { /// The index of the track after this line. This will be the index of the /// row a horizontal line is above of, or of the column right after a @@ -422,7 +423,7 @@ pub struct Line { } /// A repeatable grid header. Starts at the first row. -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq, Hash)] pub struct Header { /// The range of rows included in this header. pub range: Range, @@ -431,7 +432,7 @@ pub struct Header { /// Higher level headers repeat together with lower level headers. If a /// lower level header stops repeating, all higher level headers do as /// well. - pub level: u32, + pub level: NonZeroU32, /// Whether this header cannot be repeated nor should have orphan /// prevention because it would be about to cease repetition, either /// because it is followed by headers of conflicting levels, or because @@ -441,7 +442,7 @@ pub struct Header { } /// A repeatable grid footer. Stops at the last row. -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq, Hash)] pub struct Footer { /// The first row included in this footer. pub start: usize, @@ -466,6 +467,7 @@ impl Footer { /// It still exists even when not repeatable, but must not have additional /// considerations by grid layout, other than for consistency (such as making /// a certain group of rows unbreakable). +#[derive(Debug, PartialEq, Eq, Hash)] pub struct Repeatable { inner: T, @@ -566,6 +568,7 @@ pub enum ResolvableGridItem { } /// Represents a cell in CellGrid, to be laid out by GridLayouter. +#[derive(Debug, PartialEq, Hash)] pub struct Cell { /// The cell's body. pub body: Content, @@ -613,7 +616,7 @@ impl Cell { /// its index. This is mostly only relevant when gutter is used, since, then, /// the position after a track is not the same as before the next /// non-gutter track. -#[derive(Copy, Clone, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub enum LinePosition { /// The line should be drawn before its track (e.g. hline on top of a row). Before, @@ -622,6 +625,7 @@ pub enum LinePosition { } /// A grid entry. +#[derive(Debug, PartialEq, Hash)] pub enum Entry { /// An entry which holds a cell. Cell(Cell), @@ -650,6 +654,7 @@ pub enum ResolvableGridChild { } /// A grid of cells, including the columns, rows, and cell data. +#[derive(Debug, PartialEq, Hash)] pub struct CellGrid { /// The grid cells. pub entries: Vec, @@ -872,6 +877,21 @@ impl CellGrid { } } + #[inline] + pub fn non_gutter_row_count(&self) -> usize { + if self.has_gutter { + // Calculation: With gutters, we have + // 'rows = 2 * (non-gutter rows) - 1', since there is a gutter + // row between each regular row. Therefore, + // 'floor(rows / 2)' will be equal to + // 'floor(non-gutter rows - 1/2) = non-gutter-rows - 1', + // so 'non-gutter rows = 1 + floor(rows / 2)'. + 1 + self.rows.len() / 2 + } else { + self.rows.len() + } + } + #[inline] pub fn has_repeated_headers(&self) -> bool { self.headers.iter().any(|h| h.repeated) @@ -1587,7 +1607,7 @@ impl CellGridResolver<'_, '_> { // below. range: group_range.clone(), - level: row_group.repeatable_level.get(), + level: row_group.repeatable_level, // This can only change at a later iteration, if we // find a conflicting header or footer right away. @@ -1815,11 +1835,11 @@ impl CellGridResolver<'_, '_> { let mut last_consec_level = 0; for header in headers.iter_mut().rev() { if header.range.end == consecutive_header_start - && header.level >= last_consec_level + && header.level.get() >= last_consec_level { header.short_lived = true; } else { - last_consec_level = header.level; + last_consec_level = header.level.get(); } consecutive_header_start = header.range.start; diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs index 3c687dec86..cf7121d826 100644 --- a/crates/typst-library/src/model/table.rs +++ b/crates/typst-library/src/model/table.rs @@ -4,9 +4,13 @@ use std::sync::Arc; use ecow::EcoString; use typst_utils::NonZeroExt; -use crate::diag::{HintedStrResult, HintedString, bail}; -use crate::foundations::{Content, Packed, Smart, cast, elem, scope}; +use crate::diag::{HintedStrResult, HintedString, SourceResult, bail}; +use crate::engine::Engine; +use crate::foundations::{ + Content, Packed, Smart, StyleChain, Synthesize, cast, elem, scope, +}; use crate::introspection::Locatable; +use crate::layout::resolve::{CellGrid, table_to_cellgrid}; use crate::layout::{ Abs, Alignment, Celled, GridCell, GridFooter, GridHLine, GridHeader, GridVLine, Length, OuterHAlignment, OuterVAlignment, Rel, Sides, TrackSizings, @@ -119,7 +123,7 @@ use crate::visualize::{Paint, Stroke}; /// [Robert], b, a, b, /// ) /// ``` -#[elem(scope, Locatable, LocalName, Figurable)] +#[elem(scope, Synthesize, Locatable, LocalName, Figurable)] pub struct TableElem { /// The column sizes. See the [grid documentation]($grid/#track-size) for /// more information on track sizing. @@ -260,6 +264,10 @@ pub struct TableElem { /// This will be available for assistive techonologies (such as screen readers). pub summary: Option, + #[internal] + #[synthesized] + pub grid: Arc, + /// The contents of the table cells, plus any extra table lines specified /// with the [`table.hline`] and [`table.vline`] elements. #[variadic] @@ -284,6 +292,18 @@ impl TableElem { type TableFooter; } +impl Synthesize for Packed { + fn synthesize( + &mut self, + engine: &mut Engine, + styles: StyleChain, + ) -> SourceResult<()> { + let grid = table_to_cellgrid(self, engine, styles)?; + self.grid = Some(Arc::new(grid)); + Ok(()) + } +} + impl LocalName for Packed { const KEY: &'static str = "table"; } diff --git a/crates/typst-pdf/src/tags/context.rs b/crates/typst-pdf/src/tags/context.rs index f2ad4bc8dc..586ece80c6 100644 --- a/crates/typst-pdf/src/tags/context.rs +++ b/crates/typst-pdf/src/tags/context.rs @@ -13,7 +13,7 @@ use typst_library::text::Lang; use typst_syntax::Span; use crate::convert::FrameContext; -use crate::tags::grid::{GridCtx, GridData, TableData}; +use crate::tags::grid::{GridCtx, TableCtx}; use crate::tags::list::ListCtx; use crate::tags::outline::OutlineCtx; use crate::tags::text::{ResolvedTextAttrs, TextAttrs}; @@ -259,11 +259,11 @@ impl TagStack { self.items.last_mut().map(|e| &mut e.kind) } - pub fn parent_table(&mut self) -> Option<&mut GridCtx> { + pub fn parent_table(&mut self) -> Option<&mut TableCtx> { self.parent()?.as_table_mut() } - pub fn parent_grid(&mut self) -> Option<&mut GridCtx> { + pub fn parent_grid(&mut self) -> Option<&mut GridCtx> { self.parent()?.as_grid_mut() } @@ -348,9 +348,9 @@ pub enum StackEntryKind { Standard(TagKind), Outline(OutlineCtx), OutlineEntry(Packed), - Table(GridCtx), + Table(TableCtx), TableCell(Packed), - Grid(GridCtx), + Grid(GridCtx), GridCell(Packed), List(ListCtx), ListItemLabel, @@ -377,11 +377,11 @@ impl StackEntryKind { if let Self::OutlineEntry(v) = self { Some(v) } else { None } } - pub fn as_table_mut(&mut self) -> Option<&mut GridCtx> { + pub fn as_table_mut(&mut self) -> Option<&mut TableCtx> { if let Self::Table(v) = self { Some(v) } else { None } } - pub fn as_grid_mut(&mut self) -> Option<&mut GridCtx> { + pub fn as_grid_mut(&mut self) -> Option<&mut GridCtx> { if let Self::Grid(v) = self { Some(v) } else { None } } @@ -403,7 +403,7 @@ impl StackEntryKind { pub fn bbox(&self) -> Option<&BBoxCtx> { match self { - Self::Table(ctx) => Some(&ctx.data.bbox), + Self::Table(ctx) => Some(&ctx.bbox), Self::Figure(ctx) => Some(&ctx.bbox), Self::Formula(ctx) => Some(&ctx.bbox), _ => None, @@ -412,7 +412,7 @@ impl StackEntryKind { pub fn bbox_mut(&mut self) -> Option<&mut BBoxCtx> { match self { - Self::Table(ctx) => Some(&mut ctx.data.bbox), + Self::Table(ctx) => Some(&mut ctx.bbox), Self::Figure(ctx) => Some(&mut ctx.bbox), Self::Formula(ctx) => Some(&mut ctx.bbox), _ => None, diff --git a/crates/typst-pdf/src/tags/grid.rs b/crates/typst-pdf/src/tags/grid.rs index 82c8de16d0..7db53cf2da 100644 --- a/crates/typst-pdf/src/tags/grid.rs +++ b/crates/typst-pdf/src/tags/grid.rs @@ -1,65 +1,82 @@ use std::io::Write as _; use std::num::NonZeroU32; +use std::ops::Range; +use std::sync::Arc; use az::SaturatingAs; use krilla::tagging::{Tag, TagId, TagKind}; use smallvec::SmallVec; -use typst_library::foundations::{Packed, Smart}; +use typst_library::foundations::Packed; use typst_library::layout::GridCell; +use typst_library::layout::resolve::CellGrid; use typst_library::model::TableCell; use typst_library::pdf::{TableCellKind, TableHeaderScope}; use crate::tags::util::PropertyValCopied; use crate::tags::{BBoxCtx, GroupContents, TableId, TagNode}; -pub trait GridType { - type CellData: Clone; -} - #[derive(Clone, Debug)] -pub struct TableData { +pub struct TableCtx { pub id: TableId, pub summary: Option, pub bbox: BBoxCtx, + pub default_row_kinds: Vec, + grid: Arc, + cells: GridCells, } -impl GridType for TableData { - type CellData = TableCellData; +#[derive(Clone, Debug)] +pub struct TableCellData { + kind: TableCellKind, + headers: SmallVec<[TagId; 1]>, } -#[derive(Clone, Debug)] -pub struct GridData; +impl TableCtx { + pub fn new(grid: Arc, id: TableId, summary: Option) -> Self { + let width = grid.non_gutter_column_count(); + let height = grid.non_gutter_row_count(); + + let mut grid_headers = grid.headers.iter().peekable(); + let row_kinds = (0..height).map(|y| { + let grid_y = if grid.has_gutter { 2 * y + 1 } else { y }; + + // Find current header + while grid_headers.next_if(|h| h.range.end <= grid_y).is_some() {} + if let Some(header) = grid_headers.peek() + && header.range.contains(&grid_y) + { + return TableCellKind::Header(header.level, TableHeaderScope::Column); + } -impl GridType for GridData { - type CellData = GridCellData; -} + if let Some(footer) = &grid.footer + && footer.range().contains(&grid_y) + { + return TableCellKind::Footer; + } -#[derive(Clone, Debug)] -pub struct GridCtx { - pub data: T, - rows: Vec>>, - min_width: usize, -} + TableCellKind::Data + }); -impl GridCtx { - pub fn new(id: TableId, summary: Option) -> Self { Self { - data: TableData { id, summary, bbox: BBoxCtx::new() }, - rows: Vec::new(), - min_width: 0, + id, + summary, + bbox: BBoxCtx::new(), + default_row_kinds: row_kinds.collect(), + grid, + cells: GridCells::new(width, height), } } pub fn insert(&mut self, cell: &Packed, contents: GroupContents) { - let x = cell.x.val().unwrap_or_else(|| unreachable!()); - let y = cell.y.val().unwrap_or_else(|| unreachable!()); + let x = cell.x.val().unwrap_or_else(|| unreachable!()).saturating_as(); + let y = cell.y.val().unwrap_or_else(|| unreachable!()).saturating_as(); let rowspan = cell.rowspan.val(); let colspan = cell.colspan.val(); - let kind = cell.kind.val(); - self.insert_cell(CtxCell { + let kind = cell.kind.val().unwrap_or_else(|| self.default_row_kinds[y as usize]); + self.cells.insert(CtxCell { data: TableCellData { kind, headers: SmallVec::new() }, - x: x.saturating_as(), - y: y.saturating_as(), + x, + y, rowspan: rowspan.try_into().unwrap_or(NonZeroU32::MAX), colspan: colspan.try_into().unwrap_or(NonZeroU32::MAX), contents, @@ -69,77 +86,100 @@ impl GridCtx { pub fn build_table(mut self, mut contents: GroupContents) -> TagNode { // Table layouting ensures that there are no overlapping cells, and that // any gaps left by the user are filled with empty cells. - if self.rows.is_empty() { - return TagNode::group(Tag::Table.with_summary(self.data.summary), contents); + if self.cells.entries.is_empty() { + return TagNode::group(Tag::Table.with_summary(self.summary), contents); } - let height = self.rows.len(); - let width = self.rows[0].len(); + + let width = self.cells.width(); + let height = self.cells.height(); // Only generate row groups such as `THead`, `TFoot`, and `TBody` if - // there are no rows with mixed cell kinds. - let mut gen_row_groups = true; - let row_kinds = (self.rows.iter()) - .map(|row| { - row.iter() - .filter_map(|cell| self.resolve_cell(cell)) - .map(|cell| cell.data.kind) - .fold(Smart::Auto, |a, b| { - if let Smart::Custom(TableCellKind::Header(_, scope)) = b { - gen_row_groups &= scope == TableHeaderScope::Column; - } - if let (Smart::Custom(a), Smart::Custom(b)) = (a, b) { - gen_row_groups &= a == b; - } - a.or(b) - }) - .unwrap_or(TableCellKind::Data) - }) - .collect::>(); - - // Fixup all missing cell kinds. - for (row, row_kind) in self.rows.iter_mut().zip(row_kinds.iter().copied()) { - let default_kind = - if gen_row_groups { row_kind } else { TableCellKind::Data }; - for cell in row.iter_mut() { - let Some(cell) = cell.as_cell_mut() else { continue }; - cell.data.kind = cell.data.kind.or(Smart::Custom(default_kind)); + // there are no rows with mixed cell kinds, and there is at least one + // header or a footer. + let mut row_kinds = self.default_row_kinds; + let gen_row_groups = { + let mut uniform_rows = true; + let mut has_header_or_footer = false; + 'outer: for (row, row_kind) in self.cells.rows().zip(row_kinds.iter_mut()) { + *row_kind = self.cells.resolve(row.first().unwrap()).unwrap().data.kind; + has_header_or_footer |= *row_kind != TableCellKind::Data; + for cell in row.iter().filter_map(|cell| self.cells.resolve(cell)) { + if let TableCellKind::Header(_, scope) = cell.data.kind + && scope != TableHeaderScope::Column + { + uniform_rows = false; + break 'outer; + } + + if *row_kind != cell.data.kind { + uniform_rows = false; + break 'outer; + } + } } - } - // Explicitly set the headers attribute for cells. + uniform_rows && has_header_or_footer + }; + + // Compute the headers attribute column-wise. for x in 0..width { - let mut column_header = Vec::new(); + let mut column_headers = Vec::new(); + let mut grid_headers = self.grid.headers.iter().peekable(); for y in 0..height { - self.resolve_cell_headers( - (x, y), - &mut column_header, + // Find current header region + let grid_y = + if self.grid.has_gutter { 2 * y as usize + 1 } else { y as usize }; + while grid_headers.next_if(|h| h.range.end <= grid_y).is_some() {} + let region_range = grid_headers.peek().and_then(|header| { + if !header.range.contains(&grid_y) { + return None; + } + + // Convert from the `CellGrid` coordinates to normal ones. + let from_effective = + |i: usize| if self.grid.has_gutter { i / 2 } else { i } as u32; + let start = from_effective(header.range.start); + let end = from_effective(header.range.end); + Some(start..end) + }); + + resolve_cell_headers( + self.id, + &mut self.cells, + &mut column_headers, + region_range, TableHeaderScope::refers_to_column, + (x, y), ); } } + // Compute the headers attribute row-wise. for y in 0..height { - let mut row_header = Vec::new(); + let mut row_headers = Vec::new(); for x in 0..width { - self.resolve_cell_headers( - (x, y), - &mut row_header, + resolve_cell_headers( + self.id, + &mut self.cells, + &mut row_headers, + None, TableHeaderScope::refers_to_row, + (x, y), ); } } - let mut chunk_kind = row_kinds[0]; + let mut chunk_kind = self.cells.get(0, 0).unwrap().data.kind; let mut row_chunk = Vec::new(); - for (row, row_kind) in self.rows.into_iter().zip(row_kinds) { + let mut row_iter = self.cells.into_rows(); + while let Some((y, row)) = row_iter.row() { let row_nodes = row - .into_iter() - .filter_map(|cell| { - let cell = cell.into_cell()?; + .filter_map(|entry| { + let cell = entry.into_cell()?; let rowspan = (cell.rowspan.get() != 1).then_some(cell.rowspan); let colspan = (cell.colspan.get() != 1).then_some(cell.colspan); - let tag: TagKind = match cell.data.unwrap_kind() { + let tag: TagKind = match cell.data.kind { TableCellKind::Header(_, scope) => { - let id = table_cell_id(self.data.id, cell.x, cell.y); + let id = table_cell_id(self.id, cell.x, cell.y); let scope = table_header_scope(scope); Tag::TH(scope) .with_id(Some(id)) @@ -167,6 +207,7 @@ impl GridCtx { } // Generate row groups. + let row_kind = row_kinds[y as usize]; if !should_group_rows(chunk_kind, row_kind) { let tag: TagKind = match chunk_kind { TableCellKind::Header(..) => Tag::THead.into(), @@ -190,47 +231,132 @@ impl GridCtx { contents.nodes.push(TagNode::virtual_group(tag, row_chunk)); } - let tag = Tag::Table - .with_summary(self.data.summary) - .with_bbox(self.data.bbox.get()); + let tag = Tag::Table.with_summary(self.summary).with_bbox(self.bbox.get()); TagNode::group(tag, contents) } +} - fn resolve_cell_headers( - &mut self, - (x, y): (usize, usize), - current_header: &mut Vec<(NonZeroU32, TagId)>, - refers_to_dir: F, - ) where - F: Fn(&TableHeaderScope) -> bool, - { - let table_id = self.data.id; - let Some(cell) = self.get_mut(x, y) else { return }; - - let mut new_header = None; - if let TableCellKind::Header(level, scope) = cell.data.unwrap_kind() { - if refers_to_dir(&scope) { - // Remove all headers that are the same or a lower level. - while current_header.pop_if(|(l, _)| *l >= level).is_some() {} - - let tag_id = table_cell_id(table_id, cell.x, cell.y); - new_header = Some((level, tag_id)); - } - } +struct HeaderCells { + /// If this header is inside a table header regions defined by a + /// `table.header()` call, this is the range of that region. + /// Currently this is only supported for multi row headers. + region_range: Option>, + level: NonZeroU32, + cell_ids: SmallVec<[TagId; 1]>, +} - if let Some((_, cell_id)) = current_header.last() { - if !cell.data.headers.contains(cell_id) { - cell.data.headers.push(cell_id.clone()); +fn resolve_cell_headers( + table_id: TableId, + cells: &mut GridCells, + header_stack: &mut Vec, + region_range: Option>, + refers_to_dir: F, + (x, y): (u32, u32), +) where + F: Fn(&TableHeaderScope) -> bool, +{ + let Some(cell) = cells.get_mut(x, y) else { return }; + + let cell_ids = resolve_cell_header_ids( + table_id, + header_stack, + region_range, + refers_to_dir, + cell, + ); + + if let Some(header) = cell_ids { + for id in header.cell_ids.iter() { + if !cell.data.headers.contains(id) { + cell.data.headers.push(id.clone()); } } + } +} - current_header.extend(new_header); +fn resolve_cell_header_ids<'a, F>( + table_id: TableId, + header_stack: &'a mut Vec, + region_range: Option>, + refers_to_dir: F, + cell: &CtxCell, +) -> Option<&'a HeaderCells> +where + F: Fn(&TableHeaderScope) -> bool, +{ + let TableCellKind::Header(level, scope) = cell.data.kind else { + return header_stack.last(); + }; + if !refers_to_dir(&scope) { + return header_stack.last(); } + + // Remove all headers with a higher level. + while header_stack.pop_if(|h| h.level > level).is_some() {} + + let tag_id = table_cell_id(table_id, cell.x, cell.y); + + // Check for multi-row header regions with the same level. + let Some(prev) = header_stack.last_mut().filter(|h| h.level == level) else { + header_stack.push(HeaderCells { + region_range, + level, + cell_ids: SmallVec::from_buf([tag_id]), + }); + return header_stack.iter().rev().nth(1); + }; + + // If the current header region encompasses the cell, add the cell id to + // the header. This way multiple consecutive header cells in a single header + // region will be listed for the next cells. + if prev.region_range.clone().is_some_and(|r| r.contains(&cell.y)) { + prev.cell_ids.push(tag_id); + } else { + // The current region doesn't encompass the cell. + // Replace the previous heading that had the same level. + *prev = HeaderCells { + region_range, + level, + cell_ids: SmallVec::from_buf([tag_id]), + }; + } + + header_stack.iter().rev().nth(1) +} + +fn should_group_rows(a: TableCellKind, b: TableCellKind) -> bool { + match (a, b) { + (TableCellKind::Header(..), TableCellKind::Header(..)) => true, + (TableCellKind::Footer, TableCellKind::Footer) => true, + (TableCellKind::Data, TableCellKind::Data) => true, + (_, _) => false, + } +} + +fn table_cell_id(table_id: TableId, x: u32, y: u32) -> TagId { + let mut buf = SmallVec::<[u8; 32]>::new(); + _ = write!(&mut buf, "{}x{x}y{y}", table_id.get()); + TagId::from(buf) } -impl GridCtx { - pub fn new() -> Self { - Self { data: GridData, rows: Vec::new(), min_width: 0 } +fn table_header_scope(scope: TableHeaderScope) -> krilla::tagging::TableHeaderScope { + match scope { + TableHeaderScope::Both => krilla::tagging::TableHeaderScope::Both, + TableHeaderScope::Column => krilla::tagging::TableHeaderScope::Column, + TableHeaderScope::Row => krilla::tagging::TableHeaderScope::Row, + } +} + +#[derive(Clone, Debug)] +pub struct GridCtx { + cells: GridCells<()>, +} + +impl GridCtx { + pub fn new(grid: Arc) -> Self { + let width = grid.non_gutter_column_count(); + let height = grid.non_gutter_row_count(); + Self { cells: GridCells::new(width, height) } } pub fn insert(&mut self, cell: &Packed, contents: GroupContents) { @@ -238,8 +364,8 @@ impl GridCtx { let y = cell.y.val().unwrap_or_else(|| unreachable!()); let rowspan = cell.rowspan.val(); let colspan = cell.colspan.val(); - self.insert_cell(CtxCell { - data: GridCellData, + self.cells.insert(CtxCell { + data: (), x: x.saturating_as(), y: y.saturating_as(), rowspan: rowspan.try_into().unwrap_or(NonZeroU32::MAX), @@ -249,9 +375,8 @@ impl GridCtx { } pub fn build_grid(self, mut contents: GroupContents) -> TagNode { - let cells = (self.rows.into_iter()) - .flat_map(|row| row.into_iter()) - .filter_map(GridField::into_cell) + let cells = (self.cells.entries.into_iter()) + .filter_map(GridEntry::into_cell) .map(|cell| TagNode::group(Tag::Div, cell.contents)); contents.nodes.extend(cells); @@ -260,71 +385,149 @@ impl GridCtx { } } -impl GridCtx { - fn get_mut(&mut self, x: usize, y: usize) -> Option<&mut CtxCell> { - let cell = self.rows.get_mut(y)?.get_mut(x)?; +#[derive(Clone, Debug)] +struct GridCells { + width: usize, + entries: Vec>, +} + +struct RowIter { + width: u32, + height: u32, + consumed: u32, + inner: std::vec::IntoIter, +} + +impl RowIter { + fn row<'a>(&'a mut self) -> Option<(u32, RowEntryIter<'a, T>)> { + if self.consumed < self.height { + let y = self.consumed; + self.consumed += 1; + Some((y, RowEntryIter { consumed: 0, parent: self })) + } else { + None + } + } +} + +struct RowEntryIter<'a, T> { + consumed: u32, + parent: &'a mut RowIter, +} + +// Make sure this iterator consumes the whole row. +impl Drop for RowEntryIter<'_, T> { + fn drop(&mut self) { + while self.next().is_some() {} + } +} + +impl<'a, T> Iterator for RowEntryIter<'a, T> { + type Item = T; + + fn next(&mut self) -> Option { + if self.consumed < self.parent.width { + self.consumed += 1; + self.parent.inner.next() + } else { + None + } + } +} + +impl GridCells { + fn new(width: usize, height: usize) -> Self { + Self { + width, + entries: vec![GridEntry::Missing; width * height], + } + } + + fn width(&self) -> u32 { + self.width as u32 + } + + fn height(&self) -> u32 { + (self.entries.len() / self.width) as u32 + } + + fn rows(&self) -> impl Iterator]> { + self.entries.chunks(self.width) + } + + fn into_rows(self) -> RowIter> { + RowIter { + width: self.width(), + height: self.height(), + consumed: 0, + inner: self.entries.into_iter(), + } + } + + fn get(&self, x: u32, y: u32) -> Option<&CtxCell> { + let cell = &self.entries[self.cell_idx(x, y)]; + self.resolve(cell) + } + + fn get_mut(&mut self, x: u32, y: u32) -> Option<&mut CtxCell> { + let idx = self.cell_idx(x, y); + let cell = &mut self.entries[idx]; match cell { // Reborrow here, so the borrow of `cell` doesn't get returned from // the function. Otherwise the borrow checker assumes `cell` borrows // `self.rows` for the entirety of the function, not just this match // arm, and doesn't allow the second mutable borrow in the match arm // below. - GridField::Cell(_) => self.rows[y][x].as_cell_mut(), - &mut GridField::Spanned(x, y) => self.rows[y][x].as_cell_mut(), - GridField::Missing => None, + GridEntry::Cell(_) => self.entries[idx].as_cell_mut(), + &mut GridEntry::Spanned(idx) => self.entries[idx].as_cell_mut(), + GridEntry::Missing => None, } } - fn resolve_cell<'a>( - &'a self, - cell: &'a GridField, - ) -> Option<&'a CtxCell> { + fn resolve<'a>(&'a self, cell: &'a GridEntry) -> Option<&'a CtxCell> { match cell { - GridField::Cell(cell) => Some(cell), - &GridField::Spanned(x, y) => self.rows[y][x].as_cell(), - GridField::Missing => None, + GridEntry::Cell(cell) => Some(cell), + &GridEntry::Spanned(idx) => self.entries[idx].as_cell(), + GridEntry::Missing => None, } } - fn insert_cell(&mut self, cell: CtxCell) { - let x = cell.x as usize; - let y = cell.y as usize; - let rowspan = cell.rowspan.get() as usize; - let colspan = cell.colspan.get() as usize; - - // Extend the table grid to fit this cell. - let required_height = y + rowspan; - self.min_width = self.min_width.max(x + colspan); - if self.rows.len() < required_height { - self.rows - .resize(required_height, vec![GridField::Missing; self.min_width]); - } - for row in self.rows.iter_mut() { - if row.len() < self.min_width { - row.resize_with(self.min_width, || GridField::Missing); - } - } + fn insert(&mut self, cell: CtxCell) { + let x = cell.x; + let y = cell.y; + let rowspan = cell.rowspan.get(); + let colspan = cell.colspan.get(); + let parent_idx = self.cell_idx(x, y); + + // Repeated cells should have their `is_repeated` flag set and be marked + // as artifacts. + debug_assert!(self.entries[parent_idx].is_missing()); // Store references to the cell for all spanned cells. - for i in y..y + rowspan { - for j in x..x + colspan { - self.rows[i][j] = GridField::Spanned(x, y); + for j in y..y + rowspan { + for i in x..x + colspan { + let idx = self.cell_idx(i, j); + self.entries[idx] = GridEntry::Spanned(parent_idx); } } - self.rows[y][x] = GridField::Cell(cell); + self.entries[parent_idx] = GridEntry::Cell(cell); + } + + fn cell_idx(&self, x: u32, y: u32) -> usize { + y as usize * self.width + x as usize } } #[derive(Clone, Debug, Default)] -enum GridField { +enum GridEntry { Cell(CtxCell), - Spanned(usize, usize), + Spanned(usize), #[default] Missing, } -impl GridField { +impl GridEntry { fn as_cell(&self) -> Option<&CtxCell> { if let Self::Cell(v) = self { Some(v) } else { None } } @@ -336,6 +539,10 @@ impl GridField { fn into_cell(self) -> Option> { if let Self::Cell(v) = self { Some(v) } else { None } } + + fn is_missing(&self) -> bool { + matches!(self, Self::Missing) + } } #[derive(Clone, Debug)] @@ -347,41 +554,3 @@ struct CtxCell { colspan: NonZeroU32, contents: GroupContents, } - -#[derive(Clone, Debug)] -pub struct GridCellData; - -#[derive(Clone, Debug)] -pub struct TableCellData { - kind: Smart, - headers: SmallVec<[TagId; 1]>, -} - -impl TableCellData { - fn unwrap_kind(&self) -> TableCellKind { - self.kind.unwrap_or_else(|| unreachable!()) - } -} - -fn should_group_rows(a: TableCellKind, b: TableCellKind) -> bool { - match (a, b) { - (TableCellKind::Header(..), TableCellKind::Header(..)) => true, - (TableCellKind::Footer, TableCellKind::Footer) => true, - (TableCellKind::Data, TableCellKind::Data) => true, - (_, _) => false, - } -} - -fn table_cell_id(table_id: TableId, x: u32, y: u32) -> TagId { - let mut buf = SmallVec::<[u8; 32]>::new(); - _ = write!(&mut buf, "{}x{x}y{y}", table_id.get()); - TagId::from(buf) -} - -fn table_header_scope(scope: TableHeaderScope) -> krilla::tagging::TableHeaderScope { - match scope { - TableHeaderScope::Both => krilla::tagging::TableHeaderScope::Both, - TableHeaderScope::Column => krilla::tagging::TableHeaderScope::Column, - TableHeaderScope::Row => krilla::tagging::TableHeaderScope::Row, - } -} diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index 731ac5748d..a432ed6308 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -29,7 +29,7 @@ use typst_syntax::Span; use crate::convert::{FrameContext, GlobalContext}; use crate::link::LinkAnnotation; -use crate::tags::grid::{GridCtx, GridData, TableData}; +use crate::tags::grid::{GridCtx, TableCtx}; use crate::tags::list::ListCtx; use crate::tags::outline::OutlineCtx; use crate::tags::text::{ResolvedTextAttrs, TextAttr, TextDecoKind}; @@ -205,7 +205,8 @@ pub fn handle_start( } else if let Some(table) = elem.to_packed::() { let table_id = gc.tags.next_table_id(); let summary = table.summary.opt_ref().map(|s| s.to_string()); - let ctx = GridCtx::::new(table_id, summary); + let grid = table.grid.clone().unwrap(); + let ctx = TableCtx::new(grid, table_id, summary); push_stack(gc, elem, StackEntryKind::Table(ctx)); return Ok(()); } else if let Some(cell) = elem.to_packed::() { @@ -219,8 +220,9 @@ pub fn handle_start( push_stack(gc, elem, StackEntryKind::TableCell(cell.clone())); } return Ok(()); - } else if let Some(_) = elem.to_packed::() { - let ctx = GridCtx::::new(); + } else if let Some(grid) = elem.to_packed::() { + let grid = grid.grid.clone().unwrap(); + let ctx = GridCtx::new(grid); push_stack(gc, elem, StackEntryKind::Grid(ctx)); return Ok(()); } else if let Some(cell) = elem.to_packed::() { diff --git a/tests/ref/pdftags/grid-headers.yml b/tests/ref/pdftags/grid-headers.yml index 5e035192bd..7e04a0c090 100644 --- a/tests/ref/pdftags/grid-headers.yml +++ b/tests/ref/pdftags/grid-headers.yml @@ -60,132 +60,132 @@ - Tag: TR /K: - Tag: TD - /Headers: ["U1x0y1"] + /Headers: ["U1x0y0", "U1x0y1"] /K: - Content: page=0 mcid=6 - Tag: TD - /Headers: ["U1x1y1"] + /Headers: ["U1x0y0", "U1x1y1"] /K: - Content: page=0 mcid=7 - Tag: TD - /Headers: ["U1x2y1"] + /Headers: ["U1x0y0", "U1x2y1"] /K: - Content: page=0 mcid=8 - Tag: TD - /Headers: ["U1x3y1"] + /Headers: ["U1x0y0", "U1x3y1"] /K: - Content: page=0 mcid=9 - Tag: TD - /Headers: ["U1x4y1"] + /Headers: ["U1x0y0", "U1x4y1"] /K: - Content: page=0 mcid=10 - Tag: TR /K: - Tag: TD - /Headers: ["U1x0y1"] + /Headers: ["U1x0y0", "U1x0y1"] /K: - Content: page=0 mcid=11 - Tag: TD - /Headers: ["U1x1y1"] + /Headers: ["U1x0y0", "U1x1y1"] /K: - Content: page=0 mcid=12 - Tag: TD - /Headers: ["U1x2y1"] + /Headers: ["U1x0y0", "U1x2y1"] /K: - Content: page=0 mcid=13 - Tag: TD - /Headers: ["U1x3y1"] + /Headers: ["U1x0y0", "U1x3y1"] /K: - Content: page=0 mcid=14 - Tag: TD - /Headers: ["U1x4y1"] + /Headers: ["U1x0y0", "U1x4y1"] /K: - Content: page=0 mcid=15 - Tag: TR /K: - Tag: TD - /Headers: ["U1x0y1"] + /Headers: ["U1x0y0", "U1x0y1"] /K: - Content: page=0 mcid=16 - Tag: TD - /Headers: ["U1x1y1"] + /Headers: ["U1x0y0", "U1x1y1"] /K: - Content: page=0 mcid=17 - Tag: TD - /Headers: ["U1x2y1"] + /Headers: ["U1x0y0", "U1x2y1"] /K: - Content: page=0 mcid=18 - Tag: TD - /Headers: ["U1x3y1"] + /Headers: ["U1x0y0", "U1x3y1"] /K: - Content: page=0 mcid=19 - Tag: TD - /Headers: ["U1x4y1"] + /Headers: ["U1x0y0", "U1x4y1"] /K: - Content: page=0 mcid=20 - Tag: TR /K: - Tag: TD - /Headers: ["U1x0y1"] + /Headers: ["U1x0y0", "U1x0y1"] /K: - Content: page=0 mcid=21 - Tag: TD - /Headers: ["U1x1y1"] + /Headers: ["U1x0y0", "U1x1y1"] /K: - Content: page=0 mcid=22 - Tag: TD - /Headers: ["U1x2y1"] + /Headers: ["U1x0y0", "U1x2y1"] /K: - Content: page=0 mcid=23 - Tag: TD - /Headers: ["U1x3y1"] + /Headers: ["U1x0y0", "U1x3y1"] /K: - Content: page=0 mcid=24 - Tag: TD - /Headers: ["U1x4y1"] + /Headers: ["U1x0y0", "U1x4y1"] /K: - Content: page=0 mcid=25 - Tag: TR /K: - Tag: TD - /Headers: ["U1x0y1"] + /Headers: ["U1x0y0", "U1x0y1"] /K: - Content: page=1 mcid=0 - Tag: TD - /Headers: ["U1x1y1"] + /Headers: ["U1x0y0", "U1x1y1"] /K: - Content: page=1 mcid=1 - Tag: TD - /Headers: ["U1x2y1"] + /Headers: ["U1x0y0", "U1x2y1"] /K: - Content: page=1 mcid=2 - Tag: TD - /Headers: ["U1x3y1"] + /Headers: ["U1x0y0", "U1x3y1"] /K: - Content: page=1 mcid=3 - Tag: TD - /Headers: ["U1x4y1"] + /Headers: ["U1x0y0", "U1x4y1"] /K: - Content: page=1 mcid=4 - Tag: TR /K: - Tag: TD - /Headers: ["U1x0y1"] + /Headers: ["U1x0y0", "U1x0y1"] /K: - Content: page=1 mcid=5 - Tag: TD - /Headers: ["U1x1y1"] + /Headers: ["U1x0y0", "U1x1y1"] /K: - Content: page=1 mcid=6 - Tag: TD - /Headers: ["U1x2y1"] + /Headers: ["U1x0y0", "U1x2y1"] /K: - Content: page=1 mcid=7 - Tag: TD - /Headers: ["U1x3y1"] + /Headers: ["U1x0y0", "U1x3y1"] /K: - Content: page=1 mcid=8 - Tag: TD - /Headers: ["U1x4y1"] + /Headers: ["U1x0y0", "U1x4y1"] /K: - Content: page=1 mcid=9 From 71776ff01641e0ef636b4a7a9a7ec6670c4f47b7 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Tue, 19 Aug 2025 19:41:56 +0200 Subject: [PATCH 424/558] Generate table border and background attributes --- crates/typst-library/src/layout/sides.rs | 8 + crates/typst-pdf/src/tags/context.rs | 5 +- crates/typst-pdf/src/tags/convert.rs | 45 ++ crates/typst-pdf/src/tags/grid.rs | 391 ++++++++++++++++-- crates/typst-pdf/src/tags/mod.rs | 21 +- crates/typst-pdf/src/tags/text.rs | 32 +- crates/typst-pdf/src/util.rs | 19 +- tests/ref/pdftags/grid-headers.yml | 218 ++++++++++ tests/ref/pdftags/table-tags-basic.yml | 11 + .../table-tags-column-and-row-header.yml | 11 + .../table-tags-different-default-border.yml | 87 ++++ .../ref/pdftags/table-tags-explicit-lines.yml | 57 +++ .../ref/pdftags/table-tags-missing-cells.yml | 20 + tests/ref/pdftags/table-tags-show-rule.yml | 15 + .../pdftags/table-tags-unset-bottom-line.yml | 53 +++ tests/suite/pdftags/table.typ | 34 ++ 16 files changed, 951 insertions(+), 76 deletions(-) create mode 100644 crates/typst-pdf/src/tags/convert.rs create mode 100644 tests/ref/pdftags/table-tags-different-default-border.yml create mode 100644 tests/ref/pdftags/table-tags-explicit-lines.yml create mode 100644 tests/ref/pdftags/table-tags-show-rule.yml create mode 100644 tests/ref/pdftags/table-tags-unset-bottom-line.yml diff --git a/crates/typst-library/src/layout/sides.rs b/crates/typst-library/src/layout/sides.rs index fc2f1114e7..78054dca2c 100644 --- a/crates/typst-library/src/layout/sides.rs +++ b/crates/typst-library/src/layout/sides.rs @@ -97,6 +97,14 @@ impl Sides { } impl Sides> { + /// Unwrap-or the individual sides. + pub fn unwrap_or(self, default: T) -> Sides + where + T: Clone, + { + self.map(|v| v.unwrap_or(default.clone())) + } + /// Unwrap-or-default the individual sides. pub fn unwrap_or_default(self) -> Sides where diff --git a/crates/typst-pdf/src/tags/context.rs b/crates/typst-pdf/src/tags/context.rs index 586ece80c6..7bfe389500 100644 --- a/crates/typst-pdf/src/tags/context.rs +++ b/crates/typst-pdf/src/tags/context.rs @@ -2,6 +2,7 @@ use std::cell::OnceCell; use std::slice::SliceIndex; use krilla::geom as kg; +use krilla::tagging as kt; use krilla::tagging::{BBox, Identifier, Node, TagKind, TagTree}; use rustc_hash::FxHashMap; use typst_library::foundations::Packed; @@ -133,7 +134,7 @@ impl Tags { let nodes = (group.nodes.into_iter()) .map(|node| self.resolve_node(node)) .collect(); - let group = krilla::tagging::TagGroup::with_children(group.tag, nodes); + let group = kt::TagGroup::with_children(group.tag, nodes); Node::Group(group) } TagNode::Leaf(identifier) => Node::Leaf(identifier), @@ -576,7 +577,7 @@ impl BBoxCtx { bbox.max = bbox.max.max(rect.max); } - pub fn get(&self) -> Option { + pub fn to_krilla(&self) -> Option { let (page_idx, rect) = self.rect?; let rect = kg::Rect::from_ltrb( rect.min.x.to_f32(), diff --git a/crates/typst-pdf/src/tags/convert.rs b/crates/typst-pdf/src/tags/convert.rs new file mode 100644 index 0000000000..951340cf45 --- /dev/null +++ b/crates/typst-pdf/src/tags/convert.rs @@ -0,0 +1,45 @@ +use krilla::tagging as kt; +use krilla::tagging::{ArtifactType, NaiveRgbColor}; +use typst_library::pdf::{ArtifactKind, TableHeaderScope}; +use typst_library::visualize::Paint; + +// Best effort fallible conversion. +pub fn paint_to_color(paint: &Paint) -> Option { + match paint { + Paint::Solid(color) => { + let c = color.to_rgb(); + Some(NaiveRgbColor::new_f32(c.red, c.green, c.blue)) + } + Paint::Gradient(_) => None, + Paint::Tiling(_) => None, + } +} + +pub trait ArtifactKindExt { + fn to_krilla(self) -> ArtifactType; +} + +impl ArtifactKindExt for ArtifactKind { + fn to_krilla(self) -> ArtifactType { + match self { + Self::Header => ArtifactType::Header, + Self::Footer => ArtifactType::Footer, + Self::Page => ArtifactType::Page, + Self::Other => ArtifactType::Other, + } + } +} + +pub trait TableHeaderScopeExt { + fn to_krilla(self) -> kt::TableHeaderScope; +} + +impl TableHeaderScopeExt for TableHeaderScope { + fn to_krilla(self) -> kt::TableHeaderScope { + match self { + Self::Both => kt::TableHeaderScope::Both, + Self::Column => kt::TableHeaderScope::Column, + Self::Row => kt::TableHeaderScope::Row, + } + } +} diff --git a/crates/typst-pdf/src/tags/grid.rs b/crates/typst-pdf/src/tags/grid.rs index 7db53cf2da..8b4df30de2 100644 --- a/crates/typst-pdf/src/tags/grid.rs +++ b/crates/typst-pdf/src/tags/grid.rs @@ -4,16 +4,43 @@ use std::ops::Range; use std::sync::Arc; use az::SaturatingAs; +use krilla::tagging::{self as kt, NaiveRgbColor}; use krilla::tagging::{Tag, TagId, TagKind}; +use rustc_hash::FxHashMap; use smallvec::SmallVec; use typst_library::foundations::Packed; -use typst_library::layout::GridCell; -use typst_library::layout::resolve::CellGrid; +use typst_library::layout::resolve::{CellGrid, Line, LinePosition}; +use typst_library::layout::{Abs, GridCell, Sides}; use typst_library::model::TableCell; use typst_library::pdf::{TableCellKind, TableHeaderScope}; +use typst_library::visualize::{FixedStroke, Stroke}; +use crate::tags::convert::TableHeaderScopeExt; use crate::tags::util::PropertyValCopied; -use crate::tags::{BBoxCtx, GroupContents, TableId, TagNode}; +use crate::tags::{BBoxCtx, GroupContents, TableId, TagNode, convert}; +use crate::util::{AbsExt, SidesExt}; + +trait GridExt { + /// Convert from "effective" positions inside the cell grid, which may + /// include gutter tracks in addition to the cells, to conventional + /// positions. + #[allow(clippy::wrong_self_convention)] + fn from_effective(&self, i: usize) -> u32; + + /// Convert from conventional positions to "effective" positions inside the + /// cell grid, which may include gutter tracks in addition to the cells. + fn to_effective(&self, i: u32) -> usize; +} + +impl GridExt for CellGrid { + fn from_effective(&self, i: usize) -> u32 { + if self.has_gutter { (i / 2) as u32 } else { i as u32 } + } + + fn to_effective(&self, i: u32) -> usize { + if self.has_gutter { 2 * i as usize } else { i as usize } + } +} #[derive(Clone, Debug)] pub struct TableCtx { @@ -29,6 +56,20 @@ pub struct TableCtx { pub struct TableCellData { kind: TableCellKind, headers: SmallVec<[TagId; 1]>, + stroke: Sides, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct PrioritzedStroke { + stroke: Option>>, + priority: StrokePriority, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum StrokePriority { + GridStroke = 0, + CellStroke = 1, + ExplicitLine = 2, } impl TableCtx { @@ -37,8 +78,8 @@ impl TableCtx { let height = grid.non_gutter_row_count(); let mut grid_headers = grid.headers.iter().peekable(); - let row_kinds = (0..height).map(|y| { - let grid_y = if grid.has_gutter { 2 * y + 1 } else { y }; + let row_kinds = (0..height as u32).map(|y| { + let grid_y = grid.to_effective(y); // Find current header while grid_headers.next_if(|h| h.range.end <= grid_y).is_some() {} @@ -73,8 +114,21 @@ impl TableCtx { let rowspan = cell.rowspan.val(); let colspan = cell.colspan.val(); let kind = cell.kind.val().unwrap_or_else(|| self.default_row_kinds[y as usize]); + + let [grid_x, grid_y] = [x, y].map(|i| self.grid.to_effective(i)); + let grid_cell = self.grid.cell(grid_x, grid_y).unwrap(); + let stroke = grid_cell.stroke.clone().zip(grid_cell.stroke_overridden).map( + |(stroke, overriden)| { + let priority = if overriden { + StrokePriority::CellStroke + } else { + StrokePriority::GridStroke + }; + PrioritzedStroke { stroke, priority } + }, + ); self.cells.insert(CtxCell { - data: TableCellData { kind, headers: SmallVec::new() }, + data: TableCellData { kind, headers: SmallVec::new(), stroke }, x, y, rowspan: rowspan.try_into().unwrap_or(NonZeroU32::MAX), @@ -86,7 +140,10 @@ impl TableCtx { pub fn build_table(mut self, mut contents: GroupContents) -> TagNode { // Table layouting ensures that there are no overlapping cells, and that // any gaps left by the user are filled with empty cells. - if self.cells.entries.is_empty() { + // A show rule, can prevent the table from being layed out, in which case + // all cells will be missing, in that case just return whatever contents + // that were generated in the show rule. + if self.cells.entries.iter().all(GridEntry::is_missing) { return TagNode::group(Tag::Table.with_summary(self.summary), contents); } @@ -127,8 +184,7 @@ impl TableCtx { let mut grid_headers = self.grid.headers.iter().peekable(); for y in 0..height { // Find current header region - let grid_y = - if self.grid.has_gutter { 2 * y as usize + 1 } else { y as usize }; + let grid_y = self.grid.to_effective(y); while grid_headers.next_if(|h| h.range.end <= grid_y).is_some() {} let region_range = grid_headers.peek().and_then(|header| { if !header.range.contains(&grid_y) { @@ -136,10 +192,8 @@ impl TableCtx { } // Convert from the `CellGrid` coordinates to normal ones. - let from_effective = - |i: usize| if self.grid.has_gutter { i / 2 } else { i } as u32; - let start = from_effective(header.range.start); - let end = from_effective(header.range.end); + let start = self.grid.from_effective(header.range.start); + let end = self.grid.from_effective(header.range.end); Some(start..end) }); @@ -168,20 +222,68 @@ impl TableCtx { } } - let mut chunk_kind = self.cells.get(0, 0).unwrap().data.kind; + // Place h-lines, overwriting the cells stroke. + place_explicit_lines( + &mut self.cells, + &self.grid.hlines, + height, + width, + |cells, (y, x), pos| { + let cell = cells.cell_mut(x, y)?; + Some(match pos { + LinePosition::Before => &mut cell.data.stroke.bottom, + LinePosition::After => &mut cell.data.stroke.top, + }) + }, + ); + // Place v-lines, overwriting the cells stroke. + place_explicit_lines( + &mut self.cells, + &self.grid.vlines, + width, + height, + |cells, (x, y), pos| { + let cell = cells.cell_mut(x, y)?; + Some(match pos { + LinePosition::Before => &mut cell.data.stroke.right, + LinePosition::After => &mut cell.data.stroke.left, + }) + }, + ); + + // Remove overlapping border strokes between cells. + for y in 0..self.cells.height() { + for x in 0..self.cells.width().saturating_sub(1) { + prioritize_strokes(&mut self.cells, (x, y), (x + 1, y), |a, b| { + (&mut a.stroke.right, &mut b.stroke.left) + }); + } + } + for x in 0..self.cells.width() { + for y in 0..self.cells.height().saturating_sub(1) { + prioritize_strokes(&mut self.cells, (x, y), (x, y + 1), |a, b| { + (&mut a.stroke.bottom, &mut b.stroke.top) + }); + } + } + + let (parent_border_thickness, parent_border_color) = + try_resolve_table_stroke(&self.cells); + + let mut chunk_kind = self.cells.cell(0, 0).unwrap().data.kind; let mut row_chunk = Vec::new(); let mut row_iter = self.cells.into_rows(); + while let Some((y, row)) = row_iter.row() { let row_nodes = row .filter_map(|entry| { let cell = entry.into_cell()?; let rowspan = (cell.rowspan.get() != 1).then_some(cell.rowspan); let colspan = (cell.colspan.get() != 1).then_some(cell.colspan); - let tag: TagKind = match cell.data.kind { + let mut tag: TagKind = match cell.data.kind { TableCellKind::Header(_, scope) => { let id = table_cell_id(self.id, cell.x, cell.y); - let scope = table_header_scope(scope); - Tag::TH(scope) + Tag::TH(scope.to_krilla()) .with_id(Some(id)) .with_headers(Some(cell.data.headers)) .with_row_span(rowspan) @@ -194,6 +296,16 @@ impl TableCtx { .with_col_span(colspan) .into(), }; + + resolve_cell_border_and_background( + &self.grid, + parent_border_thickness, + parent_border_color, + [cell.x, cell.y], + cell.data.stroke, + &mut tag, + ); + Some(TagNode::group(tag, cell.contents)) }) .collect(); @@ -231,11 +343,24 @@ impl TableCtx { contents.nodes.push(TagNode::virtual_group(tag, row_chunk)); } - let tag = Tag::Table.with_summary(self.summary).with_bbox(self.bbox.get()); + let tag = Tag::Table + .with_summary(self.summary) + .with_bbox(self.bbox.to_krilla()) + .with_border_thickness(parent_border_thickness.map(kt::Sides::uniform)) + .with_border_color(parent_border_color.map(kt::Sides::uniform)); TagNode::group(tag, contents) } } +fn should_group_rows(a: TableCellKind, b: TableCellKind) -> bool { + match (a, b) { + (TableCellKind::Header(..), TableCellKind::Header(..)) => true, + (TableCellKind::Footer, TableCellKind::Footer) => true, + (TableCellKind::Data, TableCellKind::Data) => true, + (_, _) => false, + } +} + struct HeaderCells { /// If this header is inside a table header regions defined by a /// `table.header()` call, this is the range of that region. @@ -255,7 +380,7 @@ fn resolve_cell_headers( ) where F: Fn(&TableHeaderScope) -> bool, { - let Some(cell) = cells.get_mut(x, y) else { return }; + let Some(cell) = cells.cell_mut(x, y) else { return }; let cell_ids = resolve_cell_header_ids( table_id, @@ -324,29 +449,200 @@ where header_stack.iter().rev().nth(1) } -fn should_group_rows(a: TableCellKind, b: TableCellKind) -> bool { - match (a, b) { - (TableCellKind::Header(..), TableCellKind::Header(..)) => true, - (TableCellKind::Footer, TableCellKind::Footer) => true, - (TableCellKind::Data, TableCellKind::Data) => true, - (_, _) => false, - } -} - fn table_cell_id(table_id: TableId, x: u32, y: u32) -> TagId { let mut buf = SmallVec::<[u8; 32]>::new(); _ = write!(&mut buf, "{}x{x}y{y}", table_id.get()); TagId::from(buf) } -fn table_header_scope(scope: TableHeaderScope) -> krilla::tagging::TableHeaderScope { - match scope { - TableHeaderScope::Both => krilla::tagging::TableHeaderScope::Both, - TableHeaderScope::Column => krilla::tagging::TableHeaderScope::Column, - TableHeaderScope::Row => krilla::tagging::TableHeaderScope::Row, +fn place_explicit_lines( + cells: &mut GridCells, + lines: &[Vec], + block_end: u32, + inline_end: u32, + get_side: F, +) where + F: Fn( + &mut GridCells, + (u32, u32), + LinePosition, + ) -> Option<&mut PrioritzedStroke>, +{ + for line in lines.iter().flat_map(|lines| lines.iter()) { + let end = line.end.map(|n| n.get() as u32).unwrap_or(inline_end); + let explicit_stroke = || PrioritzedStroke { + stroke: line.stroke.clone(), + priority: StrokePriority::ExplicitLine, + }; + + // Fixup line positions before the first, or after the last cell. + let mut pos = line.position; + if line.index == 0 { + pos = LinePosition::After; + } else if line.index + 1 == block_end as usize { + pos = LinePosition::Before; + }; + + let block_idx = match pos { + LinePosition::Before => (line.index - 1) as u32, + LinePosition::After => line.index as u32, + }; + for inline_idx in line.start as u32..end { + if let Some(side) = get_side(cells, (block_idx, inline_idx), pos) { + *side = explicit_stroke(); + } + } + } +} + +/// PDF tables don't support gutters, remove all overlapping strokes, +/// that aren't equal. Leave strokes that would overlap but are the same +/// because then only a single value has to be written for `BorderStyle`, +/// `BorderThickness`, and `BorderColor` instead of an array for each. +fn prioritize_strokes( + cells: &mut GridCells, + a: (u32, u32), + b: (u32, u32), + get_sides: F, +) where + F: for<'a> Fn( + &'a mut TableCellData, + &'a mut TableCellData, + ) -> (&'a mut PrioritzedStroke, &'a mut PrioritzedStroke), +{ + let Some([a, b]) = cells.cells_disjoint_mut([a, b]) else { return }; + + let (a, b) = get_sides(&mut a.data, &mut b.data); + + // Only remove contesting (different) edge strokes. + if a.stroke != b.stroke { + // Prefer the right stroke on same priorities. + if a.priority <= b.priority { + a.stroke = b.stroke.clone(); + } else { + b.stroke = a.stroke.clone(); + } } } +/// Try to resolve a table border stroke color and thickness that is inherited +/// by the cells. In acrobat cells cannot override the border thickness or color +/// of the outer border around the table if the thickness is set. +fn try_resolve_table_stroke( + cells: &GridCells, +) -> (Option, Option) { + // Omitted strokes are counted too for reasons explained above. + let mut strokes = FxHashMap::<_, usize>::default(); + for cell in cells.entries.iter().filter_map(GridEntry::as_cell) { + for stroke in cell.data.stroke.iter() { + *strokes.entry(stroke.stroke.as_ref()).or_default() += 1; + } + } + + let uniform_stroke = strokes.len() == 1; + + // Find the most used stroke and convert it to a fixed stroke. + let stroke = strokes.into_iter().max_by_key(|(_, num)| *num).and_then(|(s, _)| { + let s = (**s?).clone(); + Some(s.unwrap_or_default()) + }); + let Some(stroke) = stroke else { return (None, None) }; + + // Only set a parent stroke width if the table uses one uniform stroke. + let thickness = uniform_stroke.then_some(stroke.thickness.to_f32()); + let color = convert::paint_to_color(&stroke.paint); + + (thickness, color) +} + +fn resolve_cell_border_and_background( + grid: &CellGrid, + parent_border_thickness: Option, + parent_border_color: Option, + pos: [u32; 2], + stroke: Sides, + tag: &mut TagKind, +) { + // Resolve border attributes. + let fixed = stroke + .as_ref() + .map(|s| s.stroke.as_ref().map(|s| (**s).clone().unwrap_or_default())); + + // Acrobat completely ignores the border style attribute, but the spec + // defines `BorderStyle::None` as the default. So make sure to write + // the correct border styles. + let border_style = resolve_sides(&fixed, None, Some(kt::BorderStyle::None), |s| { + s.map(|s| match s.dash { + Some(_) => kt::BorderStyle::Dashed, + None => kt::BorderStyle::Solid, + }) + }); + + // In acrobat `BorderThickness` takes precedence over `BorderStyle`. If + // A `BorderThickness != 0` is specified for a side the border is drawn + // even if `BorderStyle::None` is set. So explicitly write zeros for + // sides that should be omitted. + let border_thickness = + resolve_sides(&fixed, parent_border_thickness, Some(0.0), |s| { + s.map(|s| s.thickness.to_f32()) + }); + + let border_color = resolve_sides(&fixed, parent_border_color, None, |s| { + s.and_then(|s| convert::paint_to_color(&s.paint)) + }); + + tag.set_border_style(border_style); + tag.set_border_thickness(border_thickness); + tag.set_border_color(border_color); + + let [grid_x, grid_y] = pos.map(|i| grid.to_effective(i)); + let grid_cell = grid.cell(grid_x, grid_y).unwrap(); + let background_color = grid_cell.fill.as_ref().and_then(convert::paint_to_color); + tag.set_background_color(background_color); +} + +/// Try to minimize the attributes written per cell. +/// The parent value will be set on the table tag and is inherited by all table +/// cells. If all present values match the parent or all are missing, the +/// attribute can be omitted, and thus `None` is returned. +/// If one of the present values differs from the parent value, the the cell +/// attribute needs to override the parent attribute, fill up the remaining +/// sides with a `default` value if provided, or any other present value. +/// +/// Using an already present value has the benefit of saving storage space in +/// the resulting PDF, if all sides have the same value, because then a +/// [kt::Sides::uniform] value can be written instead of an 4-element array. +fn resolve_sides( + sides: &Sides>, + parent: Option, + default: Option, + map: F, +) -> Option> +where + T: Copy + PartialEq, + F: Copy + Fn(Option<&FixedStroke>) -> Option, +{ + let mapped = sides.as_ref().map(|s| map(s.as_ref())); + + if mapped.iter().flatten().all(|v| Some(*v) == parent) { + // All present values are equal to the parent value. + return None; + } + + let Some(first) = mapped.iter().flatten().next() else { + // All values are missing + return None; + }; + + // At least one value is different from the parent, fill up the remaining + // sides with a replacement value. + let replacement = default.unwrap_or(*first); + let sides = mapped.unwrap_or(replacement); + + // TODO(accessibility): handle `text(dir: rtl)` + Some(sides.to_lrtb_krilla()) +} + #[derive(Clone, Debug)] pub struct GridCtx { cells: GridCells<()>, @@ -464,12 +760,12 @@ impl GridCells { } } - fn get(&self, x: u32, y: u32) -> Option<&CtxCell> { + fn cell(&self, x: u32, y: u32) -> Option<&CtxCell> { let cell = &self.entries[self.cell_idx(x, y)]; self.resolve(cell) } - fn get_mut(&mut self, x: u32, y: u32) -> Option<&mut CtxCell> { + fn cell_mut(&mut self, x: u32, y: u32) -> Option<&mut CtxCell> { let idx = self.cell_idx(x, y); let cell = &mut self.entries[idx]; match cell { @@ -484,6 +780,31 @@ impl GridCells { } } + /// Mutably borrows disjoint cells. Cells are considered disjoint if their + /// positions don't resolve to the same parent cell in case of a + /// [`GridEntry::Cell`] or indirectly through a [`GridEntry::Spanned`]. + /// + /// # Panics + /// + /// If one of the positions points to a [`GridEntry::Missing`]. + fn cells_disjoint_mut( + &mut self, + positions: [(u32, u32); N], + ) -> Option<[&mut CtxCell; N]> { + let indices = positions.map(|(x, y)| { + let idx = self.cell_idx(x, y); + let cell = &self.entries[idx]; + match cell { + GridEntry::Cell(_) => idx, + &GridEntry::Spanned(idx) => idx, + GridEntry::Missing => unreachable!(""), + } + }); + + let entries = self.entries.get_disjoint_mut(indices).ok()?; + Some(entries.map(|entry| entry.as_cell_mut().unwrap())) + } + fn resolve<'a>(&'a self, cell: &'a GridEntry) -> Option<&'a CtxCell> { match cell { GridEntry::Cell(cell) => Some(cell), diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index a432ed6308..fdc01ba43b 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -29,6 +29,7 @@ use typst_syntax::Span; use crate::convert::{FrameContext, GlobalContext}; use crate::link::LinkAnnotation; +use crate::tags::convert::ArtifactKindExt; use crate::tags::grid::{GridCtx, TableCtx}; use crate::tags::list::ListCtx; use crate::tags::outline::OutlineCtx; @@ -38,6 +39,7 @@ use crate::tags::util::{PropertyOptRef, PropertyValCloned, PropertyValCopied}; pub use context::*; mod context; +mod convert; mod grid; mod list; mod outline; @@ -336,8 +338,7 @@ fn push_disable( kind: ArtifactKind, ) { let loc = elem.location().expect("elem to be locatable"); - let ty = artifact_type(kind); - surface.start_tagged(ContentTag::Artifact(ty)); + surface.start_tagged(ContentTag::Artifact(kind.to_krilla())); gc.tags.disable = Some(Disable::Elem(loc, kind)); } @@ -511,11 +512,11 @@ fn pop_stack(gc: &mut GlobalContext, entry: StackEntry) { return; } StackEntryKind::Figure(ctx) => { - let tag = Tag::Figure(ctx.alt).with_bbox(ctx.bbox.get()); + let tag = Tag::Figure(ctx.alt).with_bbox(ctx.bbox.to_krilla()); TagNode::group(tag, contents) } StackEntryKind::Formula(ctx) => { - let tag = Tag::Formula(ctx.alt).with_bbox(ctx.bbox.get()); + let tag = Tag::Formula(ctx.alt).with_bbox(ctx.bbox.to_krilla()); TagNode::group(tag, contents) } StackEntryKind::Link(_, _) => { @@ -570,8 +571,7 @@ pub fn page_start(gc: &mut GlobalContext, surface: &mut Surface) { Disable::Elem(_, kind) => kind, Disable::Tiling => ArtifactKind::Other, }; - let ty = artifact_type(kind); - surface.start_tagged(ContentTag::Artifact(ty)); + surface.start_tagged(ContentTag::Artifact(kind.to_krilla())); } } @@ -756,12 +756,3 @@ fn start_content<'a, 'b>( } TagHandle { surface, started: true } } - -fn artifact_type(kind: ArtifactKind) -> ArtifactType { - match kind { - ArtifactKind::Header => ArtifactType::Header, - ArtifactKind::Footer => ArtifactType::Footer, - ArtifactKind::Page => ArtifactType::Page, - ArtifactKind::Other => ArtifactType::Other, - } -} diff --git a/crates/typst-pdf/src/tags/text.rs b/crates/typst-pdf/src/tags/text.rs index 4ddcdec7b6..38ebf7f612 100644 --- a/crates/typst-pdf/src/tags/text.rs +++ b/crates/typst-pdf/src/tags/text.rs @@ -1,3 +1,4 @@ +use krilla::tagging as kt; use krilla::tagging::{LineHeight, NaiveRgbColor, Node, Tag, TextDecorationType}; use typst_library::diag::{SourceResult, bail}; use typst_library::foundations::{Content, Smart}; @@ -7,6 +8,7 @@ use typst_library::text::{Font, ScriptKind, TextItem, TextSize}; use typst_library::visualize::{Paint, Stroke}; use crate::PdfOptions; +use crate::tags::convert; use crate::util::AbsExt; #[derive(Clone, Debug)] @@ -34,7 +36,7 @@ impl TextAttrs { } pub fn push_highlight(&mut self, elem: &Content, paint: Option<&Paint>) { - let color = paint.and_then(color_from_paint); + let color = paint.and_then(convert::paint_to_color); self.push(elem, TextAttr::Highlight(color)); } @@ -159,26 +161,12 @@ impl TextDecoStroke { let Smart::Custom(stroke) = stroke else { return TextDecoStroke::default(); }; - let color = match stroke.paint.custom() { - Some(paint) => color_from_paint(&paint), - None => None, - }; + let color = stroke.paint.custom().as_ref().and_then(convert::paint_to_color); let thickness = stroke.thickness.custom(); TextDecoStroke { color, thickness } } } -fn color_from_paint(paint: &Paint) -> Option { - match paint { - Paint::Solid(color) => { - let c = color.to_rgb(); - Some(NaiveRgbColor::new_f32(c.red, c.green, c.blue)) - } - Paint::Gradient(_) => None, - Paint::Tiling(_) => None, - } -} - #[derive(Clone, Copy, Debug, PartialEq)] pub struct ResolvedTextAttrs { strong: Option, @@ -212,7 +200,7 @@ impl ResolvedTextAttrs { pub fn build_node(self, children: Vec) -> Node { enum Prev { Children(Vec), - Group(krilla::tagging::TagGroup), + Group(kt::TagGroup), } impl Prev { @@ -234,17 +222,15 @@ impl ResolvedTextAttrs { .with_text_decoration_color(self.deco.and_then(|d| d.color)) .with_text_decoration_thickness(self.deco.and_then(|d| d.thickness)); - let group = krilla::tagging::TagGroup::with_children(tag, prev.into_nodes()); + let group = kt::TagGroup::with_children(tag, prev.into_nodes()); prev = Prev::Group(group); } if self.strong == Some(true) { - let group = - krilla::tagging::TagGroup::with_children(Tag::Strong, prev.into_nodes()); + let group = kt::TagGroup::with_children(Tag::Strong, prev.into_nodes()); prev = Prev::Group(group); } if self.emph == Some(true) { - let group = - krilla::tagging::TagGroup::with_children(Tag::Em, prev.into_nodes()); + let group = kt::TagGroup::with_children(Tag::Em, prev.into_nodes()); prev = Prev::Group(group); } @@ -253,7 +239,7 @@ impl ResolvedTextAttrs { Prev::Children(nodes) => { // This should not happen. It can only happen if an empty set of // `ResolvedTextAttrs` was pushed into a `TagNode::Text`. - Node::Group(krilla::tagging::TagGroup::with_children(Tag::Span, nodes)) + Node::Group(kt::TagGroup::with_children(Tag::Span, nodes)) } } } diff --git a/crates/typst-pdf/src/util.rs b/crates/typst-pdf/src/util.rs index 3b85d0b8a7..62e80cea91 100644 --- a/crates/typst-pdf/src/util.rs +++ b/crates/typst-pdf/src/util.rs @@ -3,10 +3,27 @@ use krilla::geom as kg; use krilla::geom::PathBuilder; use krilla::paint as kp; -use typst_library::layout::{Abs, Point, Size, Transform}; +use krilla::tagging as kt; +use typst_library::layout::{Abs, Point, Sides, Size, Transform}; use typst_library::text::Font; use typst_library::visualize::{Curve, CurveItem, FillRule, LineCap, LineJoin}; +pub(crate) trait SidesExt { + /// Map to the [`kt::Sides`] struct assuming [`kt::WritingMode::LrTb`]. + fn to_lrtb_krilla(self) -> kt::Sides; +} + +impl SidesExt for Sides { + fn to_lrtb_krilla(self) -> kt::Sides { + kt::Sides { + before: self.top, + after: self.bottom, + start: self.left, + end: self.right, + } + } +} + pub(crate) trait SizeExt { fn to_krilla(&self) -> kg::Size; } diff --git a/tests/ref/pdftags/grid-headers.yml b/tests/ref/pdftags/grid-headers.yml index 7e04a0c090..4249de4735 100644 --- a/tests/ref/pdftags/grid-headers.yml +++ b/tests/ref/pdftags/grid-headers.yml @@ -1,4 +1,5 @@ - Tag: Table + /BorderColor: #000000 /K: - Tag: THead /K: @@ -9,6 +10,8 @@ /Scope: Column /Headers: [] /ColSpan: 5 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Tag: Strong /K: @@ -19,6 +22,13 @@ /Id: "U1x0y1" /Scope: Column /Headers: [] + /BorderColor: + before: #ff4136 + after: #ff4136 + start: #ff4136 + end: #7fdbff + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Tag: Strong /K: @@ -27,6 +37,13 @@ /Id: "U1x1y1" /Scope: Column /Headers: [] + /BorderColor: + before: #7fdbff + after: #2ecc40 + start: #7fdbff + end: #7fdbff + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Tag: Strong /K: @@ -35,6 +52,13 @@ /Id: "U1x2y1" /Scope: Column /Headers: [] + /BorderColor: + before: #000000 + after: #ffdc00 + start: #7fdbff + end: #000000 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Tag: Strong /K: @@ -43,6 +67,8 @@ /Id: "U1x3y1" /Scope: Column /Headers: [] + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Tag: Strong /K: @@ -51,6 +77,8 @@ /Id: "U1x4y1" /Scope: Column /Headers: [] + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Tag: Strong /K: @@ -61,131 +89,321 @@ /K: - Tag: TD /Headers: ["U1x0y0", "U1x0y1"] + /BorderColor: + before: #ff4136 + after: #000000 + start: #000000 + end: #2ecc40 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=0 mcid=6 - Tag: TD /Headers: ["U1x0y0", "U1x1y1"] + /BorderColor: + before: #2ecc40 + after: #2ecc40 + start: #2ecc40 + end: #0074d9 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=0 mcid=7 - Tag: TD /Headers: ["U1x0y0", "U1x2y1"] + /BorderColor: + before: #ffdc00 + after: #0074d9 + start: #0074d9 + end: #0074d9 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=0 mcid=8 - Tag: TD /Headers: ["U1x0y0", "U1x3y1"] + /BorderColor: + before: #000000 + after: #000000 + start: #0074d9 + end: #000000 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=0 mcid=9 - Tag: TD /Headers: ["U1x0y0", "U1x4y1"] + /BorderColor: + before: #000000 + after: #ff4136 + start: #000000 + end: #000000 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=0 mcid=10 - Tag: TR /K: - Tag: TD /Headers: ["U1x0y0", "U1x0y1"] + /BorderColor: + before: #000000 + after: #000000 + start: #000000 + end: #2ecc40 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=0 mcid=11 - Tag: TD /Headers: ["U1x0y0", "U1x1y1"] + /BorderColor: + before: #2ecc40 + after: #2ecc40 + start: #2ecc40 + end: #0074d9 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=0 mcid=12 - Tag: TD /Headers: ["U1x0y0", "U1x2y1"] + /BorderColor: #0074d9 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=0 mcid=13 - Tag: TD /Headers: ["U1x0y0", "U1x3y1"] + /BorderColor: + before: #000000 + after: #000000 + start: #0074d9 + end: #000000 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=0 mcid=14 - Tag: TD /Headers: ["U1x0y0", "U1x4y1"] + /BorderColor: + before: #ff4136 + after: #ff4136 + start: #000000 + end: #000000 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=0 mcid=15 - Tag: TR /K: - Tag: TD /Headers: ["U1x0y0", "U1x0y1"] + /BorderColor: + before: #000000 + after: #000000 + start: #000000 + end: #2ecc40 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=0 mcid=16 - Tag: TD /Headers: ["U1x0y0", "U1x1y1"] + /BorderColor: + before: #2ecc40 + after: #2ecc40 + start: #2ecc40 + end: #0074d9 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=0 mcid=17 - Tag: TD /Headers: ["U1x0y0", "U1x2y1"] + /BorderColor: #0074d9 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=0 mcid=18 - Tag: TD /Headers: ["U1x0y0", "U1x3y1"] + /BorderColor: + before: #000000 + after: #000000 + start: #0074d9 + end: #000000 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=0 mcid=19 - Tag: TD /Headers: ["U1x0y0", "U1x4y1"] + /BorderColor: + before: #ff4136 + after: #ff4136 + start: #000000 + end: #000000 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=0 mcid=20 - Tag: TR /K: - Tag: TD /Headers: ["U1x0y0", "U1x0y1"] + /BorderColor: + before: #000000 + after: #000000 + start: #000000 + end: #2ecc40 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=0 mcid=21 - Tag: TD /Headers: ["U1x0y0", "U1x1y1"] + /BorderColor: + before: #2ecc40 + after: #2ecc40 + start: #2ecc40 + end: #0074d9 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=0 mcid=22 - Tag: TD /Headers: ["U1x0y0", "U1x2y1"] + /BorderColor: #0074d9 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=0 mcid=23 - Tag: TD /Headers: ["U1x0y0", "U1x3y1"] + /BorderColor: + before: #000000 + after: #000000 + start: #0074d9 + end: #000000 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=0 mcid=24 - Tag: TD /Headers: ["U1x0y0", "U1x4y1"] + /BorderColor: + before: #ff4136 + after: #ff4136 + start: #000000 + end: #000000 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=0 mcid=25 - Tag: TR /K: - Tag: TD /Headers: ["U1x0y0", "U1x0y1"] + /BorderColor: + before: #000000 + after: #000000 + start: #000000 + end: #2ecc40 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=1 mcid=0 - Tag: TD /Headers: ["U1x0y0", "U1x1y1"] + /BorderColor: + before: #2ecc40 + after: #2ecc40 + start: #2ecc40 + end: #0074d9 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=1 mcid=1 - Tag: TD /Headers: ["U1x0y0", "U1x2y1"] + /BorderColor: #0074d9 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=1 mcid=2 - Tag: TD /Headers: ["U1x0y0", "U1x3y1"] + /BorderColor: + before: #000000 + after: #000000 + start: #0074d9 + end: #000000 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=1 mcid=3 - Tag: TD /Headers: ["U1x0y0", "U1x4y1"] + /BorderColor: + before: #ff4136 + after: #ff4136 + start: #000000 + end: #000000 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=1 mcid=4 - Tag: TR /K: - Tag: TD /Headers: ["U1x0y0", "U1x0y1"] + /BorderColor: + before: #000000 + after: #000000 + start: #000000 + end: #2ecc40 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=1 mcid=5 - Tag: TD /Headers: ["U1x0y0", "U1x1y1"] + /BorderColor: + before: #2ecc40 + after: #2ecc40 + start: #2ecc40 + end: #0074d9 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=1 mcid=6 - Tag: TD /Headers: ["U1x0y0", "U1x2y1"] + /BorderColor: #0074d9 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=1 mcid=7 - Tag: TD /Headers: ["U1x0y0", "U1x3y1"] + /BorderColor: + before: #000000 + after: #000000 + start: #0074d9 + end: #000000 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=1 mcid=8 - Tag: TD /Headers: ["U1x0y0", "U1x4y1"] + /BorderColor: + before: #ff4136 + after: #ff4136 + start: #000000 + end: #000000 + /BorderStyle: Solid + /BorderThickness: 1.000 /K: - Content: page=1 mcid=9 diff --git a/tests/ref/pdftags/table-tags-basic.yml b/tests/ref/pdftags/table-tags-basic.yml index d8616c888a..ac4ef301c2 100644 --- a/tests/ref/pdftags/table-tags-basic.yml +++ b/tests/ref/pdftags/table-tags-basic.yml @@ -5,6 +5,8 @@ top: 9.500 right: 76.350 bottom: 60.240 + /BorderColor: #000000 + /BorderThickness: 1.000 /K: - Tag: THead /K: @@ -14,18 +16,21 @@ /Id: "U1x0y0" /Scope: Column /Headers: [] + /BorderStyle: Solid /K: - Content: page=0 mcid=0 - Tag: TH /Id: "U1x1y0" /Scope: Column /Headers: [] + /BorderStyle: Solid /K: - Content: page=0 mcid=1 - Tag: TH /Id: "U1x2y0" /Scope: Column /Headers: [] + /BorderStyle: Solid /K: - Content: page=0 mcid=2 - Tag: TBody @@ -34,27 +39,33 @@ /K: - Tag: TD /Headers: ["U1x0y0"] + /BorderStyle: Solid /K: - Content: page=0 mcid=3 - Tag: TD /Headers: ["U1x1y0"] + /BorderStyle: Solid /K: - Content: page=0 mcid=4 - Tag: TD /Headers: ["U1x2y0"] + /BorderStyle: Solid /K: - Content: page=0 mcid=5 - Tag: TR /K: - Tag: TD /Headers: ["U1x0y0"] + /BorderStyle: Solid /K: - Content: page=0 mcid=6 - Tag: TD /Headers: ["U1x1y0"] + /BorderStyle: Solid /K: - Content: page=0 mcid=7 - Tag: TD /Headers: ["U1x2y0"] + /BorderStyle: Solid /K: - Content: page=0 mcid=8 diff --git a/tests/ref/pdftags/table-tags-column-and-row-header.yml b/tests/ref/pdftags/table-tags-column-and-row-header.yml index c5741e5f2f..7029965fa2 100644 --- a/tests/ref/pdftags/table-tags-column-and-row-header.yml +++ b/tests/ref/pdftags/table-tags-column-and-row-header.yml @@ -5,6 +5,8 @@ top: 9.500 right: 85.360 bottom: 60.240 + /BorderColor: #000000 + /BorderThickness: 1.000 /K: - Tag: TR /K: @@ -12,18 +14,21 @@ /Id: "U1x0y0" /Scope: Column /Headers: [] + /BorderStyle: Solid /K: - Content: page=0 mcid=0 - Tag: TH /Id: "U1x1y0" /Scope: Column /Headers: [] + /BorderStyle: Solid /K: - Content: page=0 mcid=1 - Tag: TH /Id: "U1x2y0" /Scope: Column /Headers: [] + /BorderStyle: Solid /K: - Content: page=0 mcid=2 - Tag: TR @@ -32,14 +37,17 @@ /Id: "U1x0y1" /Scope: Row /Headers: ["U1x0y0"] + /BorderStyle: Solid /K: - Content: page=0 mcid=3 - Tag: TD /Headers: ["U1x1y0", "U1x0y1"] + /BorderStyle: Solid /K: - Content: page=0 mcid=4 - Tag: TD /Headers: ["U1x2y0", "U1x0y1"] + /BorderStyle: Solid /K: - Content: page=0 mcid=5 - Tag: TR @@ -48,13 +56,16 @@ /Id: "U1x0y2" /Scope: Row /Headers: ["U1x0y0"] + /BorderStyle: Solid /K: - Content: page=0 mcid=6 - Tag: TD /Headers: ["U1x1y0", "U1x0y2"] + /BorderStyle: Solid /K: - Content: page=0 mcid=7 - Tag: TD /Headers: ["U1x2y0", "U1x0y2"] + /BorderStyle: Solid /K: - Content: page=0 mcid=8 diff --git a/tests/ref/pdftags/table-tags-different-default-border.yml b/tests/ref/pdftags/table-tags-different-default-border.yml new file mode 100644 index 0000000000..579b40f846 --- /dev/null +++ b/tests/ref/pdftags/table-tags-different-default-border.yml @@ -0,0 +1,87 @@ +- Tag: Table + /BBox: + page: 0 + left: 9.000 + top: 9.000 + right: 40.630 + bottom: 60.740 + /BorderColor: #ff4136 + /K: + - Tag: TR + /K: + - Tag: TD + /Headers: [] + /BorderColor: + before: #000000 + after: #ff4136 + start: #ff4136 + end: #ff4136 + /BorderStyle: Solid + /BorderThickness: + before: 1.000 + after: 2.000 + start: 2.000 + end: 2.000 + /K: + - Content: page=0 mcid=0 + - Tag: TD + /Headers: [] + /BorderColor: + before: #000000 + after: #ff4136 + start: #ff4136 + end: #ff4136 + /BorderStyle: Solid + /BorderThickness: + before: 1.000 + after: 2.000 + start: 2.000 + end: 2.000 + /K: + - Content: page=0 mcid=1 + - Tag: TR + /K: + - Tag: TD + /Headers: [] + /BorderStyle: Solid + /BorderThickness: 2.000 + /K: + - Content: page=0 mcid=2 + - Tag: TD + /Headers: [] + /BorderStyle: Solid + /BorderThickness: 2.000 + /K: + - Content: page=0 mcid=3 + - Tag: TR + /K: + - Tag: TD + /Headers: [] + /BorderColor: + before: #ff4136 + after: #000000 + start: #ff4136 + end: #ff4136 + /BorderStyle: Solid + /BorderThickness: + before: 2.000 + after: 1.000 + start: 2.000 + end: 2.000 + /K: + - Content: page=0 mcid=4 + - Tag: TD + /Headers: [] + /BorderColor: + before: #ff4136 + after: #000000 + start: #ff4136 + end: #ff4136 + /BorderStyle: Solid + /BorderThickness: + before: 2.000 + after: 1.000 + start: 2.000 + end: 2.000 + /K: + - Content: page=0 mcid=5 diff --git a/tests/ref/pdftags/table-tags-explicit-lines.yml b/tests/ref/pdftags/table-tags-explicit-lines.yml new file mode 100644 index 0000000000..b3387274fd --- /dev/null +++ b/tests/ref/pdftags/table-tags-explicit-lines.yml @@ -0,0 +1,57 @@ +- Tag: Table + /BBox: + page: 0 + left: 9.500 + top: 9.500 + right: 40.130 + bottom: 43.660 + /BorderColor: #000000 + /K: + - Tag: TR + /K: + - Tag: TD + /Headers: [] + /BorderColor: + before: #000000 + after: #ff4136 + start: #000000 + end: #2ecc40 + /BorderStyle: Solid + /BorderThickness: 1.000 + /K: + - Content: page=0 mcid=0 + - Tag: TD + /Headers: [] + /BorderColor: + before: #000000 + after: #ff4136 + start: #2ecc40 + end: #000000 + /BorderStyle: Solid + /BorderThickness: 1.000 + /K: + - Content: page=0 mcid=1 + - Tag: TR + /K: + - Tag: TD + /Headers: [] + /BorderColor: + before: #ff4136 + after: #0074d9 + start: #000000 + end: #2ecc40 + /BorderStyle: Solid + /BorderThickness: 1.000 + /K: + - Content: page=0 mcid=2 + - Tag: TD + /Headers: [] + /BorderColor: + before: #ff4136 + after: #0074d9 + start: #2ecc40 + end: #000000 + /BorderStyle: Solid + /BorderThickness: 1.000 + /K: + - Content: page=0 mcid=3 diff --git a/tests/ref/pdftags/table-tags-missing-cells.yml b/tests/ref/pdftags/table-tags-missing-cells.yml index 66330ee42b..75394b47fc 100644 --- a/tests/ref/pdftags/table-tags-missing-cells.yml +++ b/tests/ref/pdftags/table-tags-missing-cells.yml @@ -5,6 +5,8 @@ top: 9.500 right: 76.350 bottom: 96.820 + /BorderColor: #000000 + /BorderThickness: 1.000 /K: - Tag: THead /K: @@ -14,18 +16,21 @@ /Id: "U1x0y0" /Scope: Column /Headers: [] + /BorderStyle: Solid /K: - Content: page=0 mcid=0 - Tag: TH /Id: "U1x1y0" /Scope: Column /Headers: [] + /BorderStyle: Solid /K: - Content: page=0 mcid=1 - Tag: TH /Id: "U1x2y0" /Scope: Column /Headers: [] + /BorderStyle: Solid /K: - Content: page=0 mcid=2 - Tag: TR @@ -34,18 +39,21 @@ /Id: "U1x0y1" /Scope: Column /Headers: ["U1x0y0"] + /BorderStyle: Solid /K: - Content: page=0 mcid=3 - Tag: TH /Id: "U1x1y1" /Scope: Column /Headers: ["U1x1y0"] + /BorderStyle: Solid /K: - Content: page=0 mcid=4 - Tag: TH /Id: "U1x2y1" /Scope: Column /Headers: ["U1x2y0"] + /BorderStyle: Solid /K: - Content: page=0 mcid=5 - Tag: TBody @@ -54,10 +62,13 @@ /K: - Tag: TD /Headers: ["U1x0y1"] + /BorderStyle: Solid - Tag: TD /Headers: ["U1x1y1"] + /BorderStyle: Solid - Tag: TD /Headers: ["U1x2y1"] + /BorderStyle: Solid - Tag: THead /K: - Tag: TR @@ -66,39 +77,48 @@ /Id: "U1x0y3" /Scope: Column /Headers: ["U1x0y0"] + /BorderStyle: Solid /K: - Content: page=0 mcid=6 - Tag: TH /Id: "U1x1y3" /Scope: Column /Headers: ["U1x1y0"] + /BorderStyle: Solid /K: - Content: page=0 mcid=7 - Tag: TH /Id: "U1x2y3" /Scope: Column /Headers: ["U1x2y0"] + /BorderStyle: Solid - Tag: TBody /K: - Tag: TR /K: - Tag: TD /Headers: ["U1x0y3"] + /BorderStyle: Solid - Tag: TD /Headers: ["U1x1y3"] + /BorderStyle: Solid - Tag: TD /Headers: ["U1x2y3"] + /BorderStyle: Solid - Tag: TFoot /K: - Tag: TR /K: - Tag: TD /Headers: ["U1x0y3"] + /BorderStyle: Solid - Tag: TD /Headers: ["U1x1y3"] + /BorderStyle: Solid /K: - Content: page=0 mcid=8 - Tag: TD /Headers: ["U1x2y3"] + /BorderStyle: Solid /K: - Content: page=0 mcid=9 diff --git a/tests/ref/pdftags/table-tags-show-rule.yml b/tests/ref/pdftags/table-tags-show-rule.yml new file mode 100644 index 0000000000..1450e6ab88 --- /dev/null +++ b/tests/ref/pdftags/table-tags-show-rule.yml @@ -0,0 +1,15 @@ +- Tag: P + /K: + - Tag: Table + /K: + - Tag: Code + /K: + - Content: page=0 mcid=0 + - Content: page=0 mcid=1 + - Content: page=0 mcid=2 + - Content: page=0 mcid=3 + - Content: page=0 mcid=4 + - Content: page=0 mcid=5 + - Content: page=0 mcid=6 + - Content: page=0 mcid=7 + - Content: page=0 mcid=8 diff --git a/tests/ref/pdftags/table-tags-unset-bottom-line.yml b/tests/ref/pdftags/table-tags-unset-bottom-line.yml new file mode 100644 index 0000000000..0954137ea7 --- /dev/null +++ b/tests/ref/pdftags/table-tags-unset-bottom-line.yml @@ -0,0 +1,53 @@ +- Tag: Table + /BBox: + page: 0 + left: 9.500 + top: 9.500 + right: 40.130 + bottom: 43.660 + /BorderColor: #000000 + /K: + - Tag: TR + /K: + - Tag: TD + /Headers: [] + /BorderStyle: Solid + /BorderThickness: 1.000 + /K: + - Content: page=0 mcid=0 + - Tag: TD + /Headers: [] + /BorderStyle: Solid + /BorderThickness: 1.000 + /K: + - Content: page=0 mcid=1 + - Tag: TR + /K: + - Tag: TD + /Headers: [] + /BorderStyle: + before: Solid + after: None + start: Solid + end: Solid + /BorderThickness: + before: 1.000 + after: 0.000 + start: 1.000 + end: 1.000 + /K: + - Content: page=0 mcid=2 + - Tag: TD + /Headers: [] + /BorderStyle: + before: Solid + after: None + start: Solid + end: Solid + /BorderThickness: + before: 1.000 + after: 0.000 + start: 1.000 + end: 1.000 + /K: + - Content: page=0 mcid=3 diff --git a/tests/suite/pdftags/table.typ b/tests/suite/pdftags/table.typ index c053ee991f..1a6bbcd0e6 100644 --- a/tests/suite/pdftags/table.typ +++ b/tests/suite/pdftags/table.typ @@ -35,3 +35,37 @@ table.cell(x: 2)[F], ), ) + +--- table-tags-explicit-lines pdftags --- +#table( + columns: 2, + [a], table.vline(stroke: green), [b], + table.hline(stroke: red), + [c], [d], + table.hline(stroke: blue), +) + +--- table-tags-unset-bottom-line pdftags --- +#table( + columns: 2, + [a], [b], + [c], [d], + table.hline(stroke: none), +) + +--- table-tags-different-default-border pdftags --- +#table( + columns: 2, + stroke: red + 2pt, + table.hline(stroke: black), + [a], [b], + [c], [d], + [e], [f], + table.hline(stroke: black), +) + +--- table-tags-show-rule pdftags --- +// The table contents are transformed by a show rule. +#set table(columns: (10pt, auto)) +#show table: it => it.columns +#table[A][B][C][D] From 9aa5dd7134e22a361c6f20302ef3e8605d46ff24 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Mon, 25 Aug 2025 17:49:20 +0200 Subject: [PATCH 425/558] Resolve logical parent of frame groups in the tag tree - This replaces the previous mechanism used for footnotes - Correctly handles Table/grid cells that are broken across multiple regions - Handles `place` elements with floating placement --- crates/typst-layout/src/grid/mod.rs | 22 +- crates/typst-layout/src/rules.rs | 4 +- crates/typst-library/src/pdf/accessibility.rs | 4 +- crates/typst-pdf/src/convert.rs | 10 +- crates/typst-pdf/src/link.rs | 3 +- crates/typst-pdf/src/tags/context.rs | 453 ++++++++++++++---- crates/typst-pdf/src/tags/grid.rs | 41 +- crates/typst-pdf/src/tags/list.rs | 48 +- crates/typst-pdf/src/tags/mod.rs | 350 ++++++++------ crates/typst-pdf/src/tags/outline.rs | 35 +- crates/typst-pdf/src/tags/text.rs | 14 +- tests/ref/pdftags/footnote-tags-basic.yml | 27 ++ .../pdftags/footnote-tags-different-lang.yml | 29 ++ .../footnote-tags-ref-to-other-footnote.yml | 38 ++ tests/ref/pdftags/grid-tags-cell-breaking.yml | 33 ++ tests/ref/pdftags/lang-tags-propagation.yml | 6 +- .../layout-tags-placement-different-lang.yml | 28 ++ .../pdftags/query-tags-duplicate-heading.yml | 8 + .../query-tags-duplicate-labelled-element.yml | 24 + .../pdftags/table-tags-rowspan-split-1.yml | 34 ++ .../pdftags/table-tags-rowspan-split-2.yml | 50 ++ tests/suite/pdftags/footnote.typ | 12 + tests/suite/pdftags/grid.typ | 16 + tests/suite/pdftags/lang.typ | 3 +- tests/suite/pdftags/layout.typ | 24 + tests/suite/pdftags/query.typ | 25 + tests/suite/pdftags/table.typ | 19 + 27 files changed, 1024 insertions(+), 336 deletions(-) create mode 100644 tests/ref/pdftags/footnote-tags-basic.yml create mode 100644 tests/ref/pdftags/footnote-tags-different-lang.yml create mode 100644 tests/ref/pdftags/footnote-tags-ref-to-other-footnote.yml create mode 100644 tests/ref/pdftags/grid-tags-cell-breaking.yml create mode 100644 tests/ref/pdftags/layout-tags-placement-different-lang.yml create mode 100644 tests/ref/pdftags/query-tags-duplicate-heading.yml create mode 100644 tests/ref/pdftags/query-tags-duplicate-labelled-element.yml create mode 100644 tests/ref/pdftags/table-tags-rowspan-split-1.yml create mode 100644 tests/ref/pdftags/table-tags-rowspan-split-2.yml create mode 100644 tests/suite/pdftags/footnote.typ create mode 100644 tests/suite/pdftags/layout.typ create mode 100644 tests/suite/pdftags/query.typ diff --git a/crates/typst-layout/src/grid/mod.rs b/crates/typst-layout/src/grid/mod.rs index 84339343a0..8cf12cf96f 100644 --- a/crates/typst-layout/src/grid/mod.rs +++ b/crates/typst-layout/src/grid/mod.rs @@ -7,8 +7,8 @@ pub use self::layouter::GridLayouter; use typst_library::diag::SourceResult; use typst_library::engine::Engine; -use typst_library::foundations::{NativeElement, Packed, StyleChain}; -use typst_library::introspection::{Locator, SplitLocator, Tag}; +use typst_library::foundations::{Content, NativeElement, Packed, StyleChain}; +use typst_library::introspection::{Location, Locator, SplitLocator, Tag}; use typst_library::layout::grid::resolve::Cell; use typst_library::layout::{Fragment, FrameItem, GridCell, GridElem, Point, Regions}; use typst_library::model::{TableCell, TableElem}; @@ -54,12 +54,14 @@ pub fn layout_cell( // Manually insert tags. let mut frames = fragment.into_frames(); - if let Some((start, end)) = tags { - if let Some(first) = frames.first_mut() { - first.prepend(Point::zero(), FrameItem::Tag(start)); - } - if let Some(last) = frames.last_mut() { - last.push(Point::zero(), FrameItem::Tag(end)); + if let Some((elem, loc, key)) = tags + && let Some((first, remainder)) = frames.split_first_mut() + { + first.prepend(Point::zero(), FrameItem::Tag(Tag::Start(elem))); + first.push(Point::zero(), FrameItem::Tag(Tag::End(loc, key))); + + for frame in remainder.iter_mut() { + frame.set_parent(loc); } } @@ -70,11 +72,11 @@ fn generate_tags( mut cell: Packed, locator: &mut SplitLocator, engine: &mut Engine, -) -> (Tag, Tag) { +) -> (Content, Location, u128) { let key = typst_utils::hash128(&cell); let loc = locator.next_location(engine.introspector, key); cell.set_location(loc); - (Tag::Start(cell.pack()), Tag::End(loc, key)) + (cell.pack(), loc, key) } /// Layout the grid. diff --git a/crates/typst-layout/src/rules.rs b/crates/typst-layout/src/rules.rs index a69d6109d2..df127c26bf 100644 --- a/crates/typst-layout/src/rules.rs +++ b/crates/typst-layout/src/rules.rs @@ -399,9 +399,7 @@ const FOOTNOTE_RULE: ShowFn = |elem, engine, styles| { let (dest, num) = elem.realize(engine, styles)?; let alt = FootnoteElem::alt_text(styles, &num.plain_text()); let sup = SuperElem::new(num).pack().spanned(span).linked(dest, Some(alt)); - let note = HElem::hole().clone() + PdfMarkerTag::Label(sup); - let decl_loc = elem.declaration_location(engine).unwrap(); - Ok(PdfMarkerTag::FootnoteRef(decl_loc, note)) + Ok(HElem::hole().clone() + PdfMarkerTag::Label(sup)) }; const FOOTNOTE_ENTRY_RULE: ShowFn = |elem, engine, styles| { diff --git a/crates/typst-library/src/pdf/accessibility.rs b/crates/typst-library/src/pdf/accessibility.rs index d5c9a43ec9..206a7cc590 100644 --- a/crates/typst-library/src/pdf/accessibility.rs +++ b/crates/typst-library/src/pdf/accessibility.rs @@ -8,7 +8,7 @@ use crate::diag::SourceResult; use crate::diag::bail; use crate::engine::Engine; use crate::foundations::{Args, Construct, Content, NativeElement, Smart}; -use crate::introspection::{Locatable, Location}; +use crate::introspection::Locatable; use crate::model::TableCell; /// Mark content as a PDF artifact. @@ -150,8 +150,6 @@ pdf_marker_tag! { OutlineBody, /// `Figure` FigureBody(alt: Option), - /// `Note` footnote reference - FootnoteRef(decl_loc: Location), /// `L` bibliography list Bibliography(numbered: bool), /// `LBody` wrapping `BibEntry` diff --git a/crates/typst-pdf/src/convert.rs b/crates/typst-pdf/src/convert.rs index 369e273ca4..7e7257ae03 100644 --- a/crates/typst-pdf/src/convert.rs +++ b/crates/typst-pdf/src/convert.rs @@ -44,9 +44,11 @@ pub fn convert( convert_pages(&mut gc, &mut document)?; attach_files(&gc, &mut document)?; + let tree = gc.tags.finish(); + document.set_outline(build_outline(&gc)); document.set_metadata(build_metadata(&gc)); - document.set_tag_tree(gc.tags.build_tree()); + document.set_tag_tree(tree); finish(document, gc, options.standards.config) } @@ -59,7 +61,8 @@ pub fn tag_tree( convert_pages(&mut gc, &mut document)?; attach_files(&gc, &mut document)?; - let tree = gc.tags.build_tree(); + let tree = gc.tags.finish(); + let mut output = String::new(); if let Some(lang) = gc.tags.doc_lang && lang != Lang::ENGLISH @@ -352,6 +355,9 @@ pub(crate) fn handle_group( fc.push(); fc.state_mut().pre_concat(group.transform); + let mut handle = tags::logical_child(gc, group.parent)?; + let gc = handle.gc(); + let clip_path = group .clip .as_ref() diff --git a/crates/typst-pdf/src/link.rs b/crates/typst-pdf/src/link.rs index 58d865dc66..4bf308547a 100644 --- a/crates/typst-pdf/src/link.rs +++ b/crates/typst-pdf/src/link.rs @@ -75,7 +75,8 @@ pub(crate) fn handle_link( Some(annotation) if join_annotations => annotation.quad_points.push(quad), _ => { let placeholder = gc.tags.placeholders.reserve(); - let (alt, span) = if let Some((link, nodes)) = tagging_ctx { + let (alt, span) = if let Some((link, id)) = tagging_ctx { + let nodes = &mut gc.tags.groups.get_mut(id).nodes; nodes.push(TagNode::Placeholder(placeholder)); let alt = link.alt.as_ref().map(EcoString::to_string); (alt, link.span()) diff --git a/crates/typst-pdf/src/tags/context.rs b/crates/typst-pdf/src/tags/context.rs index 7bfe389500..5de06c6ad3 100644 --- a/crates/typst-pdf/src/tags/context.rs +++ b/crates/typst-pdf/src/tags/context.rs @@ -1,10 +1,12 @@ use std::cell::OnceCell; +use std::collections::hash_map::Entry; use std::slice::SliceIndex; use krilla::geom as kg; use krilla::tagging as kt; use krilla::tagging::{BBox, Identifier, Node, TagKind, TagTree}; use rustc_hash::FxHashMap; +use typst_library::diag::{SourceResult, bail}; use typst_library::foundations::Packed; use typst_library::introspection::Location; use typst_library::layout::{Abs, GridCell, Point, Rect}; @@ -13,12 +15,12 @@ use typst_library::pdf::ArtifactKind; use typst_library::text::Lang; use typst_syntax::Span; +use crate::PdfOptions; use crate::convert::FrameContext; use crate::tags::grid::{GridCtx, TableCtx}; use crate::tags::list::ListCtx; use crate::tags::outline::OutlineCtx; use crate::tags::text::{ResolvedTextAttrs, TextAttrs}; -use crate::tags::{Placeholder, TagNode}; use crate::util::AbsExt; pub struct Tags { @@ -30,11 +32,11 @@ pub struct Tags { pub stack: TagStack, /// A list of placeholders corresponding to a [`TagNode::Placeholder`]. pub placeholders: Placeholders, - /// Footnotes are inserted directly after the footenote reference in the - /// reading order. Because of some layouting bugs, the entry might appear - /// before the reference in the text, so we only resolve them once tags - /// for the whole document are generated. - pub footnotes: FxHashMap, + pub groups: Groups, + /// Logical parent markers for elements that are not directly associated + /// with a PDF tag. They are inserted at the end introspection tag to mark + /// the point where logical children are inserted. + pub logical_parents: FxHashMap, pub disable: Option, /// Used to group multiple link annotations using quad points. link_id: LinkId, @@ -53,7 +55,8 @@ impl Tags { text_attrs: TextAttrs::new(), stack: TagStack::new(), placeholders: Placeholders(Vec::new()), - footnotes: FxHashMap::default(), + groups: Groups::new(), + logical_parents: FxHashMap::default(), disable: None, link_id: LinkId(0), @@ -65,7 +68,7 @@ impl Tags { pub fn push(&mut self, node: TagNode) { if let Some(entry) = self.stack.last_mut() { - entry.nodes.push(node); + self.groups.get_mut(entry.id).nodes.push(node); } else { self.tree.push(node); } @@ -78,7 +81,7 @@ impl Tags { } let last_node = if let Some(entry) = self.stack.last_mut() { - entry.nodes.last_mut() + self.groups.get_mut(entry.id).nodes.last_mut() } else { self.tree.last_mut() }; @@ -91,67 +94,44 @@ impl Tags { } } - pub fn extend(&mut self, nodes: impl IntoIterator) { - if let Some(entry) = self.stack.last_mut() { - entry.nodes.extend(nodes); - } else { - self.tree.extend(nodes); - } - } - - pub fn build_tree(&mut self) -> TagTree { + pub fn finish(&mut self) -> TagTree { assert!(self.stack.items.is_empty(), "tags weren't properly closed"); - let children = std::mem::take(&mut self.tree) - .into_iter() - .map(|node| self.resolve_node(node)) - .collect::>(); + let mut children = Vec::with_capacity(self.tree.len()); + + for child in std::mem::take(&mut self.tree) { + resolve_node( + &mut self.groups, + &mut self.placeholders, + &mut self.doc_lang, + &mut children, + child, + ); + } + TagTree::from(children) } - /// Try to set the language of a parent tag, or the entire document. + /// Try to set the language of the direct parent tag, or the entire document. /// If the language couldn't be set and is different from the existing one, /// this will return `Some`, and the language should be specified on the /// marked content directly. pub fn try_set_lang(&mut self, lang: Lang) -> Option { - if self.doc_lang.is_none_or(|l| l == lang) { + if let Some(last) = self.stack.last_mut() { + let group = &mut self.groups.get_mut(last.id); + if let GroupState::Tagged(_, parent_lang) = &mut group.state + && parent_lang.is_none_or(|l| l == lang) + { + *parent_lang = Some(lang); + return None; + } + } else if self.doc_lang.is_none_or(|l| l == lang) { self.doc_lang = Some(lang); return None; } - if let Some(last) = self.stack.last_mut() - && last.lang.is_none_or(|l| l == lang) - { - last.lang = Some(lang); - return None; - } Some(lang) } - /// Resolves nodes into an accumulator. - fn resolve_node(&mut self, node: TagNode) -> Node { - match node { - TagNode::Group(group) => { - let nodes = (group.nodes.into_iter()) - .map(|node| self.resolve_node(node)) - .collect(); - let group = kt::TagGroup::with_children(group.tag, nodes); - Node::Group(group) - } - TagNode::Leaf(identifier) => Node::Leaf(identifier), - TagNode::Placeholder(placeholder) => self.placeholders.take(placeholder), - TagNode::FootnoteEntry(loc) => { - let node = (self.footnotes.remove(&loc)) - .and_then(|ctx| ctx.entry) - .expect("footnote"); - self.resolve_node(node) - } - TagNode::Text(attrs, ids) => { - let children = ids.into_iter().map(Node::Leaf).collect(); - attrs.build_node(children) - } - } - } - pub fn next_link_id(&mut self) -> LinkId { self.link_id.0 += 1; self.link_id @@ -163,6 +143,61 @@ impl Tags { } } +/// Resolves nodes into an accumulator. +fn resolve_node( + groups: &mut Groups, + placeholders: &mut Placeholders, + mut parent_lang: &mut Option, + accum: &mut Vec, + node: TagNode, +) { + match node { + TagNode::Group(id) => { + let mut group = groups.take(id); + + assert!(group.unfinished_stack.is_empty()); + + let mut nodes = Vec::with_capacity(group.nodes.len()); + let lang = group.state.lang_mut().unwrap_or(&mut parent_lang); + for child in group.nodes.into_iter() { + resolve_node(groups, placeholders, lang, &mut nodes, child); + } + + match group.state { + GroupState::Tagged(tag, mut group_lang) => { + // Try to propagate the groups language to the parent tag. + if let Some(lang) = group_lang + && parent_lang.is_none_or(|l| l == lang) + { + *parent_lang = Some(lang); + group_lang = None; + } + + let tag = tag + .expect("tag to be initialized") + .with_lang(group_lang.map(|l| l.as_str().to_string())) + .with_location(group.span.map(Span::into_raw)); + + let group = kt::TagGroup::with_children(tag, nodes); + accum.push(Node::Group(group)); + } + GroupState::Transparent => { + accum.extend(nodes); + } + } + } + TagNode::Leaf(identifier) => { + accum.push(Node::Leaf(identifier)); + } + TagNode::Placeholder(placeholder) => { + accum.push(placeholders.take(placeholder)); + } + TagNode::Text(attrs, ids) => { + attrs.resolve_nodes(accum, ids); + } + } +} + #[derive(Clone, Copy, Debug)] pub enum Disable { /// Either an artifact or a hide element. @@ -202,6 +237,10 @@ impl TagStack { self.items.len() } + pub fn get(&self, idx: usize) -> Option<&StackEntry> { + self.items.get(idx) + } + pub fn last_mut(&mut self) -> Option<&mut StackEntry> { self.items.last_mut() } @@ -256,6 +295,29 @@ impl TagStack { Some(removed) } + /// Remove all stack entries after the idx. + /// This takes care of updating the parent bboxes. + pub fn stash_unfinished_stack( + &mut self, + idx: usize, + ) -> std::vec::Drain<'_, StackEntry> { + if self.bbox_idx.is_some() { + // The inner tags are broken across regions (pages), which invalidates all bounding boxes. + for entry in self.items.iter_mut() { + if let Some(bbox) = entry.kind.bbox_mut() { + bbox.multi_page = true; + } + } + self.bbox_idx = self.items[..idx] + .iter() + .enumerate() + .rev() + .find(|(_, entry)| entry.kind.bbox().is_some()) + .map(|(idx, _)| idx); + } + self.items.drain(idx + 1..) + } + pub fn parent(&mut self) -> Option<&mut StackEntryKind> { self.items.last_mut().map(|e| &mut e.kind) } @@ -276,10 +338,10 @@ impl TagStack { self.parent()?.as_figure_mut() } - pub fn parent_outline(&mut self) -> Option<(&mut OutlineCtx, &mut Vec)> { + pub fn parent_outline(&mut self) -> Option<(&mut OutlineCtx, GroupId)> { self.items.last_mut().and_then(|e| { let ctx = e.kind.as_outline_mut()?; - Some((ctx, &mut e.nodes)) + Some((ctx, e.id)) }) } @@ -287,12 +349,10 @@ impl TagStack { self.parent()?.as_outline_entry_mut() } - pub fn find_parent_link( - &mut self, - ) -> Option<(LinkId, &Packed, &mut Vec)> { + pub fn find_parent_link(&mut self) -> Option<(LinkId, &Packed, GroupId)> { self.items.iter_mut().rev().find_map(|e| { let (link_id, link) = e.kind.as_link()?; - Some((link_id, link, &mut e.nodes)) + Some((link_id, link, e.id)) }) } @@ -323,6 +383,9 @@ impl Placeholders { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Placeholder(usize); + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct TableId(u32); @@ -337,16 +400,18 @@ pub struct LinkId(u32); #[derive(Debug)] pub struct StackEntry { - pub loc: Location, + /// The location of the stack entry. If this is `None` the stack entry has + /// to be manually popped. + pub loc: Option, pub span: Span, - pub lang: Option, + pub id: GroupId, pub kind: StackEntryKind, - pub nodes: Vec, } #[derive(Clone, Debug)] pub enum StackEntryKind { Standard(TagKind), + LogicalChild, Outline(OutlineCtx), OutlineEntry(Packed), Table(TableCtx), @@ -360,11 +425,6 @@ pub enum StackEntryKind { Figure(FigureCtx), Formula(FigureCtx), Link(LinkId, Packed), - /// The footnote reference in the text, contains the declaration location. - FootnoteRef(Location), - /// The footnote entry at the end of the page. Contains the [`Location`] of - /// the [`FootnoteElem`](typst_library::model::FootnoteElem). - FootnoteEntry(Location), CodeBlock, CodeBlockLine, } @@ -462,6 +522,7 @@ impl StackEntryKind { TagKind::Strong(_) => true, TagKind::Em(_) => true, }, + StackEntryKind::LogicalChild => false, StackEntryKind::Outline(_) => false, StackEntryKind::OutlineEntry(_) => false, StackEntryKind::Table(_) => false, @@ -475,30 +536,12 @@ impl StackEntryKind { StackEntryKind::Figure(_) => false, StackEntryKind::Formula(_) => false, StackEntryKind::Link(..) => !is_pdf_ua, - StackEntryKind::FootnoteRef(_) => false, - StackEntryKind::FootnoteEntry(_) => false, StackEntryKind::CodeBlock => false, StackEntryKind::CodeBlockLine => false, } } } -#[derive(Debug, Clone, PartialEq)] -pub struct FootnoteCtx { - /// Whether this footenote has been referenced inside the document. The - /// entry will be inserted inside the reading order after the first - /// reference. All other references will still have links to the footnote. - pub is_referenced: bool, - /// The nodes that make up the footnote entry. - pub entry: Option, -} - -impl FootnoteCtx { - pub const fn new() -> Self { - Self { is_referenced: false, entry: None } - } -} - /// Figure/Formula context #[derive(Debug, Clone, PartialEq)] pub struct FigureCtx { @@ -589,3 +632,239 @@ impl BBoxCtx { Some(BBox::new(page_idx, rect)) } } + +#[derive(Debug)] +pub struct Groups { + locations: FxHashMap, + list: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GroupId(u32); + +impl Groups { + pub fn new() -> Self { + Self { locations: FxHashMap::default(), list: Vec::new() } + } + + pub fn get(&self, id: GroupId) -> &Group { + &self.list[id.0 as usize] + } + + pub fn get_mut(&mut self, id: GroupId) -> &mut Group { + &mut self.list[id.0 as usize] + } + + pub fn take(&mut self, id: GroupId) -> Group { + std::mem::take(&mut self.list[id.0 as usize]) + } + + /// Reserves a located group, if the location hasn't already been reserved, + /// otherwise returns the already reserved id. + pub fn reserve_located( + &mut self, + options: &PdfOptions, + loc: Location, + kind: GroupKind, + ) -> SourceResult { + match self.locations.entry(loc) { + Entry::Occupied(occupied) => { + let id = *occupied.get(); + let group = &mut self.list[id.0 as usize]; + + let is_child = kind == GroupKind::LogicalChild; + if is_child { + group.has_children = true; + } else { + group.num_parents += 1 + } + + if group.span.is_none() { + group.span = kind.span(); + } + + if options.is_pdf_ua() && group.num_parents > 1 && group.has_children { + let validator = options.standards.config.validator(); + let validator = validator.as_str(); + let span = kind.span().or(group.span).unwrap_or(Span::detached()); + bail!( + span, + "{validator} error: ambiguous logical parent"; + hint: "please report this as a bug" + ); + } + + if !is_child { + if group.num_parents == 1 { + group.state = kind.into(); + } else { + // Multiple introspection tags have the same location, + // for example because an element was queried and then + // placed again. Create a new group that doesn't have + // a location mapping. + return Ok(self.reserve_virtual(kind)); + } + } + + Ok(id) + } + Entry::Vacant(vacant) => { + let id = GroupId(self.list.len() as u32); + vacant.insert(id); + self.list.push(Group::new(kind)); + Ok(id) + } + } + } + + /// Reserves a virtual group not associated with any [`Location`]. + pub fn reserve_virtual(&mut self, kind: GroupKind) -> GroupId { + let id = GroupId(self.list.len() as u32); + self.list.push(Group::new(kind)); + id + } + + /// Directly create a virtual group, which didn't originate directly from a + /// typst element. It has no [`Location`] associated with it, and thus + /// cannot be found by logical children. + pub fn new_virtual( + &mut self, + tag: impl Into, + nodes: Vec, + ) -> TagNode { + let id = GroupId(self.list.len() as u32); + self.list.push(Group { + state: GroupState::Tagged(Some(tag.into()), None), + nodes, + span: None, + num_parents: 1, + has_children: false, + unfinished_stack: Vec::new(), + }); + TagNode::Group(id) + } + + /// Creaate an empty virtual group. See [`Self::new_virtual`]. + pub fn new_empty(&mut self, tag: impl Into) -> TagNode { + self.new_virtual(tag, Vec::new()) + } + + /// Initialize a group that has been reserved using either + /// [`Self::reserve_located`] or [`Self::reserve_virtual`]. + pub fn init_tag( + &mut self, + tag: impl Into, + contents: GroupContents, + ) -> TagNode { + let tag = tag.into(); + let group = self.get_mut(contents.id); + + match &mut group.state { + GroupState::Tagged(t, _) => { + assert!(t.is_none()); + *t = Some(tag); + } + GroupState::Transparent => unreachable!(), + } + TagNode::Group(contents.id) + } +} + +#[derive(Debug, Default)] +pub struct Group { + pub state: GroupState, + pub nodes: Vec, + pub span: Option, + + pub num_parents: u32, + pub has_children: bool, + + /// Currently only used for table/grid cells that are broken across multiple + /// regions, and thus can have opening/closing introspection tags that are + /// in completely different frames, due to the logical parenting mechanism. + pub unfinished_stack: Vec, +} + +impl Group { + fn new(kind: GroupKind) -> Self { + let is_child = kind == GroupKind::LogicalChild; + Group { + state: kind.into(), + span: kind.span(), + num_parents: if is_child { 0 } else { 1 }, + has_children: is_child, + nodes: Vec::new(), + unfinished_stack: Vec::new(), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GroupKind { + /// A tagged group that will produce a PDF tag. + Tagged(Span), + /// A logical parent group, that is transparently inserted after the element + /// content. For example to mark where a place element should be inserted. + /// This won't produce a PDF tag. + LogcialParent(Span), + /// A logical child that is added to a located group. + LogicalChild, +} + +impl GroupKind { + fn span(self) -> Option { + match self { + GroupKind::Tagged(span) => Some(span), + GroupKind::LogcialParent(span) => Some(span), + GroupKind::LogicalChild => None, + } + } +} + +impl From for GroupState { + fn from(val: GroupKind) -> Self { + match val { + GroupKind::Tagged(_) => GroupState::Tagged(None, None), + GroupKind::LogcialParent(_) | GroupKind::LogicalChild => { + GroupState::Transparent + } + } + } +} + +#[derive(Debug, Default)] +pub enum GroupState { + Tagged(Option, Option), + #[default] + Transparent, +} + +impl GroupState { + pub fn tag(&self) -> Option<&TagKind> { + match self { + Self::Tagged(tag, _) => tag.as_ref(), + Self::Transparent => None, + } + } + + pub fn lang_mut(&mut self) -> Option<&mut Option> { + if let Self::Tagged(_, l) = self { Some(l) } else { None } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct GroupContents { + pub id: GroupId, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum TagNode { + Group(GroupId), + Leaf(Identifier), + /// Allows inserting a placeholder into the tag tree. + /// Currently used for [`krilla::page::Page::add_tagged_annotation`]. + Placeholder(Placeholder), + /// If the attributes are non-empty this will resolve to a [`Tag::Span`], + /// otherwise the items are inserted directly. + Text(ResolvedTextAttrs, Vec), +} diff --git a/crates/typst-pdf/src/tags/grid.rs b/crates/typst-pdf/src/tags/grid.rs index 8b4df30de2..d830c1e528 100644 --- a/crates/typst-pdf/src/tags/grid.rs +++ b/crates/typst-pdf/src/tags/grid.rs @@ -17,7 +17,7 @@ use typst_library::visualize::{FixedStroke, Stroke}; use crate::tags::convert::TableHeaderScopeExt; use crate::tags::util::PropertyValCopied; -use crate::tags::{BBoxCtx, GroupContents, TableId, TagNode, convert}; +use crate::tags::{BBoxCtx, GroupContents, Groups, TableId, TagNode, convert}; use crate::util::{AbsExt, SidesExt}; trait GridExt { @@ -137,14 +137,18 @@ impl TableCtx { }); } - pub fn build_table(mut self, mut contents: GroupContents) -> TagNode { + pub fn build_table( + mut self, + groups: &mut Groups, + contents: GroupContents, + ) -> TagNode { // Table layouting ensures that there are no overlapping cells, and that // any gaps left by the user are filled with empty cells. // A show rule, can prevent the table from being layed out, in which case // all cells will be missing, in that case just return whatever contents // that were generated in the show rule. if self.cells.entries.iter().all(GridEntry::is_missing) { - return TagNode::group(Tag::Table.with_summary(self.summary), contents); + return groups.init_tag(Tag::Table.with_summary(self.summary), contents); } let width = self.cells.width(); @@ -306,15 +310,15 @@ impl TableCtx { &mut tag, ); - Some(TagNode::group(tag, cell.contents)) + Some(groups.init_tag(tag, cell.contents)) }) .collect(); - let row = TagNode::virtual_group(Tag::TR, row_nodes); + let row = groups.new_virtual(Tag::TR, row_nodes); // Push the `TR` tags directly. if !gen_row_groups { - contents.nodes.push(row); + groups.get_mut(contents.id).nodes.push(row); continue; } @@ -327,7 +331,8 @@ impl TableCtx { TableCellKind::Data => Tag::TBody.into(), }; let chunk_nodes = std::mem::take(&mut row_chunk); - contents.nodes.push(TagNode::virtual_group(tag, chunk_nodes)); + let node = groups.new_virtual(tag, chunk_nodes); + groups.get_mut(contents.id).nodes.push(node); chunk_kind = row_kind; } @@ -340,7 +345,8 @@ impl TableCtx { TableCellKind::Footer => Tag::TFoot.into(), TableCellKind::Data => Tag::TBody.into(), }; - contents.nodes.push(TagNode::virtual_group(tag, row_chunk)); + let node = groups.new_virtual(tag, row_chunk); + groups.get_mut(contents.id).nodes.push(node); } let tag = Tag::Table @@ -348,7 +354,7 @@ impl TableCtx { .with_bbox(self.bbox.to_krilla()) .with_border_thickness(parent_border_thickness.map(kt::Sides::uniform)) .with_border_color(parent_border_color.map(kt::Sides::uniform)); - TagNode::group(tag, contents) + groups.init_tag(tag, contents) } } @@ -670,14 +676,13 @@ impl GridCtx { }); } - pub fn build_grid(self, mut contents: GroupContents) -> TagNode { - let cells = (self.cells.entries.into_iter()) - .filter_map(GridEntry::into_cell) - .map(|cell| TagNode::group(Tag::Div, cell.contents)); - - contents.nodes.extend(cells); + pub fn build_grid(self, groups: &mut Groups, contents: GroupContents) -> TagNode { + for cell in self.cells.entries.into_iter().filter_map(GridEntry::into_cell) { + let node = groups.init_tag(Tag::Div, cell.contents); + groups.get_mut(contents.id).nodes.push(node); + } - TagNode::group(Tag::Div, contents) + groups.init_tag(Tag::Div, contents) } } @@ -820,9 +825,7 @@ impl GridCells { let colspan = cell.colspan.get(); let parent_idx = self.cell_idx(x, y); - // Repeated cells should have their `is_repeated` flag set and be marked - // as artifacts. - debug_assert!(self.entries[parent_idx].is_missing()); + assert!(self.entries[parent_idx].is_missing()); // Store references to the cell for all spanned cells. for j in y..y + rowspan { diff --git a/crates/typst-pdf/src/tags/list.rs b/crates/typst-pdf/src/tags/list.rs index 5af3e39720..a4bda06988 100644 --- a/crates/typst-pdf/src/tags/list.rs +++ b/crates/typst-pdf/src/tags/list.rs @@ -1,6 +1,6 @@ use krilla::tagging::{ListNumbering, Tag, TagKind}; -use crate::tags::{GroupContents, TagNode}; +use crate::tags::{GroupContents, Groups, TagNode}; #[derive(Clone, Debug)] pub struct ListCtx { @@ -20,15 +20,12 @@ impl ListCtx { Self { numbering, items: Vec::new() } } - pub fn push_label(&mut self, contents: GroupContents) { - self.items.push(ListItem { - label: TagNode::group(Tag::Lbl, contents), - body: None, - sub_list: None, - }); + pub fn push_label(&mut self, groups: &mut Groups, contents: GroupContents) { + let label = groups.init_tag(Tag::Lbl, contents); + self.items.push(ListItem { label, body: None, sub_list: None }); } - pub fn push_body(&mut self, mut contents: GroupContents) { + pub fn push_body(&mut self, groups: &mut Groups, contents: GroupContents) { let item = self.items.last_mut().expect("ListItemLabel"); // Nested lists are expected to have the following structure: @@ -64,43 +61,42 @@ impl ListCtx { // ``` // // So move the nested list out of the list item. - if let [.., TagNode::Group(group)] = contents.nodes.as_slice() - && let TagKind::L(_) = group.tag + if let [.., TagNode::Group(id)] = groups.get(contents.id).nodes.as_slice() + && let Some(TagKind::L(_)) = groups.get(*id).state.tag() { - item.sub_list = contents.nodes.pop(); + item.sub_list = groups.get_mut(contents.id).nodes.pop(); } - item.body = Some(TagNode::group(Tag::LBody, contents)); + item.body = Some(groups.init_tag(Tag::LBody, contents)); } - pub fn push_bib_entry(&mut self, contents: GroupContents) { - let nodes = vec![TagNode::group(Tag::BibEntry, contents)]; + pub fn push_bib_entry(&mut self, groups: &mut Groups, contents: GroupContents) { + let nodes = vec![groups.init_tag(Tag::BibEntry, contents)]; // Bibliography lists cannot be nested, but may be missing labels. - let body = TagNode::virtual_group(Tag::LBody, nodes); + let body = groups.new_virtual(Tag::LBody, nodes); if let Some(item) = self.items.last_mut().filter(|item| item.body.is_none()) { item.body = Some(body); } else { self.items.push(ListItem { - label: TagNode::empty_group(Tag::Lbl), + label: groups.new_empty(Tag::Lbl), body: Some(body), sub_list: None, }); } } - pub fn build_list(self, mut contents: GroupContents) -> TagNode { + pub fn build_list(self, groups: &mut Groups, contents: GroupContents) -> TagNode { for item in self.items.into_iter() { - contents.nodes.push(TagNode::virtual_group( - Tag::LI, - vec![ - item.label, - item.body.unwrap_or_else(|| TagNode::empty_group(Tag::LBody)), - ], - )); + let nodes = vec![ + item.label, + item.body.unwrap_or_else(|| groups.new_empty(Tag::LBody)), + ]; + let node = groups.new_virtual(Tag::LI, nodes); + groups.get_mut(contents.id).nodes.push(node); if let Some(sub_list) = item.sub_list { - contents.nodes.push(sub_list); + groups.get_mut(contents.id).nodes.push(sub_list); } } - TagNode::group(Tag::L(self.numbering), contents) + groups.init_tag(Tag::L(self.numbering), contents) } } diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index fdc01ba43b..c3f6d198d8 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -4,20 +4,18 @@ use krilla::configure::Validator; use krilla::page::Page; use krilla::surface::Surface; use krilla::tagging as kt; -use krilla::tagging::{ - ArtifactType, ContentTag, Identifier, ListNumbering, Node, SpanTag, Tag, TagKind, -}; +use krilla::tagging::{ArtifactType, ContentTag, ListNumbering, Node, SpanTag, Tag}; use typst_library::diag::{SourceResult, bail}; use typst_library::foundations::Content; use typst_library::introspection::Location; use typst_library::layout::{ - GridCell, GridElem, HideElem, Point, Rect, RepeatElem, Size, + GridCell, GridElem, HideElem, PlaceElem, Point, Rect, RepeatElem, Size, }; use typst_library::math::EquationElem; use typst_library::model::{ - EmphElem, EnumElem, FigureCaption, FigureElem, FootnoteEntry, HeadingElem, - LinkMarker, ListElem, Outlinable, OutlineEntry, ParElem, QuoteElem, StrongElem, - TableCell, TableElem, TermsElem, + EmphElem, EnumElem, FigureCaption, FigureElem, FootnoteElem, FootnoteEntry, + HeadingElem, LinkMarker, ListElem, Outlinable, OutlineEntry, ParElem, QuoteElem, + StrongElem, TableCell, TableElem, TermsElem, }; use typst_library::pdf::{ArtifactElem, ArtifactKind, PdfMarkerTag, PdfMarkerTagKind}; use typst_library::text::{ @@ -33,7 +31,7 @@ use crate::tags::convert::ArtifactKindExt; use crate::tags::grid::{GridCtx, TableCtx}; use crate::tags::list::ListCtx; use crate::tags::outline::OutlineCtx; -use crate::tags::text::{ResolvedTextAttrs, TextAttr, TextDecoKind}; +use crate::tags::text::{TextAttr, TextDecoKind}; use crate::tags::util::{PropertyOptRef, PropertyValCloned, PropertyValCopied}; pub use context::*; @@ -46,57 +44,6 @@ mod outline; mod text; mod util; -#[derive(Debug, Clone, PartialEq)] -pub enum TagNode { - Group(TagGroup), - Leaf(Identifier), - /// Allows inserting a placeholder into the tag tree. - /// Currently used for [`krilla::page::Page::add_tagged_annotation`]. - Placeholder(Placeholder), - FootnoteEntry(Location), - /// If the attributes are non-empty this will resolve to a [`Tag::Span`], - /// otherwise the items are inserted directly. - Text(ResolvedTextAttrs, Vec), -} - -impl TagNode { - pub fn group(tag: impl Into, contents: GroupContents) -> Self { - let lang = contents.lang.map(|l| l.as_str().to_string()); - let tag = tag - .into() - .with_lang(lang) - .with_location(Some(contents.span.into_raw())); - TagNode::Group(TagGroup { tag, nodes: contents.nodes }) - } - - /// A tag group not directly related to a typst element, generated to - /// accomodate the tag structure. - pub fn virtual_group(tag: impl Into, nodes: Vec) -> Self { - let tag = tag.into(); - TagNode::Group(TagGroup { tag, nodes }) - } - - pub fn empty_group(tag: impl Into) -> Self { - Self::virtual_group(tag, Vec::new()) - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct TagGroup { - tag: TagKind, - nodes: Vec, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct GroupContents { - span: Span, - lang: Option, - nodes: Vec, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct Placeholder(usize); - pub fn handle_start( gc: &mut GlobalContext, surface: &mut Surface, @@ -125,61 +72,61 @@ pub fn handle_start( } #[allow(clippy::redundant_pattern_matching)] - let tag: TagKind = if let Some(tag) = elem.to_packed::() { + let tag = if let Some(tag) = elem.to_packed::() { match &tag.kind { PdfMarkerTagKind::OutlineBody => { - push_stack(gc, elem, StackEntryKind::Outline(OutlineCtx::new())); + push_stack(gc, elem, StackEntryKind::Outline(OutlineCtx::new()))?; return Ok(()); } PdfMarkerTagKind::FigureBody(alt) => { let alt = alt.as_ref().map(|s| s.to_string()); - push_stack(gc, elem, StackEntryKind::Figure(FigureCtx::new(alt))); - return Ok(()); - } - PdfMarkerTagKind::FootnoteRef(decl_loc) => { - push_stack(gc, elem, StackEntryKind::FootnoteRef(*decl_loc)); + push_stack(gc, elem, StackEntryKind::Figure(FigureCtx::new(alt)))?; return Ok(()); } PdfMarkerTagKind::Bibliography(numbered) => { let numbering = if *numbered { ListNumbering::Decimal } else { ListNumbering::None }; - push_stack(gc, elem, StackEntryKind::List(ListCtx::new(numbering))); + push_stack(gc, elem, StackEntryKind::List(ListCtx::new(numbering)))?; return Ok(()); } PdfMarkerTagKind::BibEntry => { - push_stack(gc, elem, StackEntryKind::BibEntry); + push_stack(gc, elem, StackEntryKind::BibEntry)?; return Ok(()); } PdfMarkerTagKind::ListItemLabel => { - push_stack(gc, elem, StackEntryKind::ListItemLabel); + push_stack(gc, elem, StackEntryKind::ListItemLabel)?; return Ok(()); } PdfMarkerTagKind::ListItemBody => { - push_stack(gc, elem, StackEntryKind::ListItemBody); + push_stack(gc, elem, StackEntryKind::ListItemBody)?; return Ok(()); } PdfMarkerTagKind::Label => Tag::Lbl.into(), } } else if let Some(entry) = elem.to_packed::() { - push_stack(gc, elem, StackEntryKind::OutlineEntry(entry.clone())); + push_stack(gc, elem, StackEntryKind::OutlineEntry(entry.clone()))?; return Ok(()); } else if let Some(_list) = elem.to_packed::() { let numbering = ListNumbering::Circle; // TODO: infer numbering from `list.marker` - push_stack(gc, elem, StackEntryKind::List(ListCtx::new(numbering))); + push_stack(gc, elem, StackEntryKind::List(ListCtx::new(numbering)))?; return Ok(()); } else if let Some(_enumeration) = elem.to_packed::() { let numbering = ListNumbering::Decimal; // TODO: infer numbering from `enum.numbering` - push_stack(gc, elem, StackEntryKind::List(ListCtx::new(numbering))); + push_stack(gc, elem, StackEntryKind::List(ListCtx::new(numbering)))?; return Ok(()); } else if let Some(_terms) = elem.to_packed::() { let numbering = ListNumbering::None; - push_stack(gc, elem, StackEntryKind::List(ListCtx::new(numbering))); + push_stack(gc, elem, StackEntryKind::List(ListCtx::new(numbering)))?; return Ok(()); - } else if let Some(_) = elem.to_packed::() { - // Wrap the figure tag and the sibling caption in a container, if the - // caption is contained within the figure like recommended for tables - // screen readers might ignore it. - Tag::NonStruct.into() + } else if let Some(figure) = elem.to_packed::() { + if figure.caption.opt_ref().is_none() { + return Ok(()); + } else { + // Wrap the figure tag and the sibling caption in a container, if + // the caption is contained within the figure, like recommended for + // tables, screen readers might ignore it. + Tag::NonStruct.into() + } } else if let Some(_) = elem.to_packed::() { Tag::Caption.into() } else if let Some(image) = elem.to_packed::() { @@ -191,7 +138,7 @@ pub fn handle_start( figure_ctx.alt = alt; } } else { - push_stack(gc, elem, StackEntryKind::Figure(FigureCtx::new(alt))); + push_stack(gc, elem, StackEntryKind::Figure(FigureCtx::new(alt)))?; } return Ok(()); } else if let Some(equation) = elem.to_packed::() { @@ -202,14 +149,14 @@ pub fn handle_start( figure_ctx.alt = alt.clone(); } } - push_stack(gc, elem, StackEntryKind::Formula(FigureCtx::new(alt))); + push_stack(gc, elem, StackEntryKind::Formula(FigureCtx::new(alt)))?; return Ok(()); } else if let Some(table) = elem.to_packed::() { let table_id = gc.tags.next_table_id(); let summary = table.summary.opt_ref().map(|s| s.to_string()); let grid = table.grid.clone().unwrap(); let ctx = TableCtx::new(grid, table_id, summary); - push_stack(gc, elem, StackEntryKind::Table(ctx)); + push_stack(gc, elem, StackEntryKind::Table(ctx))?; return Ok(()); } else if let Some(cell) = elem.to_packed::() { // Only repeated table headers and footer cells are laid out multiple @@ -219,13 +166,13 @@ pub fn handle_start( if cell.is_repeated.val() { push_disable(gc, surface, elem, ArtifactKind::Other); } else { - push_stack(gc, elem, StackEntryKind::TableCell(cell.clone())); + push_stack(gc, elem, StackEntryKind::TableCell(cell.clone()))?; } return Ok(()); } else if let Some(grid) = elem.to_packed::() { let grid = grid.grid.clone().unwrap(); let ctx = GridCtx::new(grid); - push_stack(gc, elem, StackEntryKind::Grid(ctx)); + push_stack(gc, elem, StackEntryKind::Grid(ctx))?; return Ok(()); } else if let Some(cell) = elem.to_packed::() { // If there is no grid parent, this means a grid layouter is used @@ -242,7 +189,7 @@ pub fn handle_start( if cell.is_repeated.val() { push_disable(gc, surface, elem, ArtifactKind::Other); } else { - push_stack(gc, elem, StackEntryKind::GridCell(cell.clone())); + push_stack(gc, elem, StackEntryKind::GridCell(cell.clone()))?; } } return Ok(()); @@ -254,18 +201,19 @@ pub fn handle_start( Tag::P.into() } else if let Some(link) = elem.to_packed::() { let link_id = gc.tags.next_link_id(); - push_stack(gc, elem, StackEntryKind::Link(link_id, link.clone())); + push_stack(gc, elem, StackEntryKind::Link(link_id, link.clone()))?; return Ok(()); - } else if let Some(entry) = elem.to_packed::() { - let footnote_loc = entry.note.location().unwrap(); - push_stack(gc, elem, StackEntryKind::FootnoteEntry(footnote_loc)); + } else if let Some(_) = elem.to_packed::() { + gc.tags.logical_parents.insert(elem.location().unwrap(), elem.span()); return Ok(()); + } else if let Some(_) = elem.to_packed::() { + Tag::Note.into() } else if let Some(quote) = elem.to_packed::() { // TODO: should the attribution be handled somehow? if quote.block.val() { Tag::BlockQuote.into() } else { Tag::InlineQuote.into() } } else if let Some(raw) = elem.to_packed::() { if raw.block.val() { - push_stack(gc, elem, StackEntryKind::CodeBlock); + push_stack(gc, elem, StackEntryKind::CodeBlock)?; return Ok(()); } else { Tag::Code.into() @@ -273,7 +221,12 @@ pub fn handle_start( } else if let Some(_) = elem.to_packed::() { // If the raw element is inline, the content can be inserted directly. if gc.tags.stack.parent().is_some_and(|p| p.is_code_block()) { - push_stack(gc, elem, StackEntryKind::CodeBlockLine); + push_stack(gc, elem, StackEntryKind::CodeBlockLine)?; + } + return Ok(()); + } else if let Some(place) = elem.to_packed::() { + if place.float.val() { + gc.tags.logical_parents.insert(elem.location().unwrap(), elem.span()); } return Ok(()); } else if let Some(_) = elem.to_packed::() { @@ -317,18 +270,33 @@ pub fn handle_start( return Ok(()); }; - let tag = tag.with_location(Some(elem.span().into_raw())); - push_stack(gc, elem, StackEntryKind::Standard(tag)); - - Ok(()) + push_stack(gc, elem, StackEntryKind::Standard(tag)) } -fn push_stack(gc: &mut GlobalContext, elem: &Content, kind: StackEntryKind) { +fn push_stack( + gc: &mut GlobalContext, + elem: &Content, + kind: StackEntryKind, +) -> SourceResult<()> { let loc = elem.location().expect("elem to be locatable"); let span = elem.span(); - gc.tags - .stack - .push(StackEntry { loc, span, lang: None, kind, nodes: Vec::new() }); + let id = gc + .tags + .groups + .reserve_located(gc.options, loc, GroupKind::Tagged(span))?; + push_stack_entry(gc, Some(loc), span, id, kind); + Ok(()) +} + +fn push_stack_entry( + gc: &mut GlobalContext, + loc: Option, + span: Span, + id: GroupId, + kind: StackEntryKind, +) { + let entry = StackEntry { loc, span, id, kind }; + gc.tags.stack.push(entry); } fn push_disable( @@ -359,7 +327,7 @@ pub fn handle_end( return Ok(()); } - if let Some(entry) = gc.tags.stack.pop_if(|e| e.loc == loc) { + if let Some(entry) = gc.tags.stack.pop_if(|e| e.loc == Some(loc)) { // The tag nesting was properly closed. pop_stack(gc, entry); return Ok(()); @@ -369,15 +337,36 @@ pub fn handle_end( return Ok(()); } + if let Some(span) = gc.tags.logical_parents.remove(&loc) { + let kind = GroupKind::LogcialParent(span); + let id = gc.tags.groups.reserve_located(gc.options, loc, kind)?; + gc.tags.push(TagNode::Group(id)); + } + // Search for an improperly nested starting tag, that is being closed. let Some(idx) = (gc.tags.stack.iter().enumerate()) .rev() - .find_map(|(i, e)| (e.loc == loc).then_some(i)) + .find_map(|(i, e)| (e.loc == Some(loc)).then_some(i)) else { // The start tag isn't in the tag stack, just ignore the end tag. return Ok(()); }; + // Table/grid cells can only have overlapping tags if they are broken across + // multiple regions. In that case stash the unfinished stack entries, and + // push them back on when processing the logical children. + let entry = &gc.tags.stack[idx]; + if matches!(entry.kind, StackEntryKind::TableCell(_) | StackEntryKind::GridCell(_)) { + let group = gc.tags.groups.get_mut(entry.id); + let unfinished_stack = gc.tags.stack.stash_unfinished_stack(idx); + group.unfinished_stack.extend(unfinished_stack); + + let closed = gc.tags.stack.pop().unwrap(); + assert_eq!(closed.loc, Some(loc)); + pop_stack(gc, closed); + return Ok(()); + } + // There are overlapping tags in the tag tree. Figure whether breaking // up the current tag stack is semantically ok. let mut is_breakable = true; @@ -431,9 +420,11 @@ pub fn handle_end( broken_entries.push(StackEntry { loc: entry.loc, span: entry.span, - lang: None, + // Reserve a virtual group so it won't be combined with the original + // located group. + // TODO: should the location instead point to the second entry? + id: gc.tags.groups.reserve_virtual(GroupKind::Tagged(entry.span)), kind, - nodes: Vec::new(), }); pop_stack(gc, entry); } @@ -449,118 +440,163 @@ pub fn handle_end( } fn pop_stack(gc: &mut GlobalContext, entry: StackEntry) { - // Try to propagate the tag language to the parent tag, or the document. - // If successfull omit the language attribute on this tag. - let lang = entry.lang.and_then(|lang| { - let parent_lang = (gc.tags.stack.last_mut()) - .map(|e| &mut e.lang) - .unwrap_or(&mut gc.tags.doc_lang); - if parent_lang.is_none_or(|l| l == lang) { - *parent_lang = Some(lang); - return None; - } - Some(lang) - }); - - let contents = GroupContents { span: entry.span, lang, nodes: entry.nodes }; + let contents = GroupContents { id: entry.id }; let node = match entry.kind { - StackEntryKind::Standard(tag) => TagNode::group(tag, contents), - StackEntryKind::Outline(ctx) => ctx.build_outline(contents), + StackEntryKind::Standard(tag) => gc.tags.groups.init_tag(tag, contents), + StackEntryKind::LogicalChild => { + unreachable!("logical children are handled separately") + } + StackEntryKind::Outline(ctx) => ctx.build_outline(&mut gc.tags.groups, contents), StackEntryKind::OutlineEntry(outline_entry) => { - if let Some((outline_ctx, outline_nodes)) = gc.tags.stack.parent_outline() { - outline_ctx.insert(outline_nodes, outline_entry, contents); + if let Some((outline_ctx, group_id)) = gc.tags.stack.parent_outline() { + outline_ctx.insert( + &mut gc.tags.groups, + group_id, + outline_entry, + contents, + ); return; } else { // Avoid panicking, the nesting will be validated later. - TagNode::group(Tag::TOCI, contents) + gc.tags.groups.init_tag(Tag::TOCI, contents) } } - StackEntryKind::Table(ctx) => ctx.build_table(contents), + StackEntryKind::Table(ctx) => ctx.build_table(&mut gc.tags.groups, contents), StackEntryKind::TableCell(cell) => { if let Some(table_ctx) = gc.tags.stack.parent_table() { table_ctx.insert(&cell, contents); return; } else { // Avoid panicking, the nesting will be validated later. - TagNode::group(Tag::TD, contents) + gc.tags.groups.init_tag(Tag::TD, contents) } } - StackEntryKind::Grid(ctx) => ctx.build_grid(contents), + StackEntryKind::Grid(ctx) => ctx.build_grid(&mut gc.tags.groups, contents), StackEntryKind::GridCell(cell) => { if let Some(grid_ctx) = gc.tags.stack.parent_grid() { grid_ctx.insert(&cell, contents); return; } else { // Avoid panicking, the nesting will be validated later. - TagNode::group(Tag::Div, contents) + gc.tags.groups.init_tag(Tag::Div, contents) } } - StackEntryKind::List(list) => list.build_list(contents), + StackEntryKind::List(list) => list.build_list(&mut gc.tags.groups, contents), StackEntryKind::ListItemLabel => { let list_ctx = gc.tags.stack.parent_list().expect("parent list"); - list_ctx.push_label(contents); + list_ctx.push_label(&mut gc.tags.groups, contents); return; } StackEntryKind::ListItemBody => { let list_ctx = gc.tags.stack.parent_list().expect("parent list"); - list_ctx.push_body(contents); + list_ctx.push_body(&mut gc.tags.groups, contents); return; } StackEntryKind::BibEntry => { let list_ctx = gc.tags.stack.parent_list().expect("parent list"); - list_ctx.push_bib_entry(contents); + list_ctx.push_bib_entry(&mut gc.tags.groups, contents); return; } StackEntryKind::Figure(ctx) => { let tag = Tag::Figure(ctx.alt).with_bbox(ctx.bbox.to_krilla()); - TagNode::group(tag, contents) + gc.tags.groups.init_tag(tag, contents) } StackEntryKind::Formula(ctx) => { let tag = Tag::Formula(ctx.alt).with_bbox(ctx.bbox.to_krilla()); - TagNode::group(tag, contents) + gc.tags.groups.init_tag(tag, contents) } StackEntryKind::Link(_, _) => { - let mut node = TagNode::group(Tag::Link, contents); + let mut node = gc.tags.groups.init_tag(Tag::Link, contents); // Wrap link in reference tag if inside an outline entry. if gc.tags.stack.parent_outline_entry().is_some() { - node = TagNode::virtual_group(Tag::Reference, vec![node]); + node = gc.tags.groups.new_virtual(Tag::Reference, vec![node]); } node } - StackEntryKind::FootnoteRef(decl_loc) => { - // transparently insert all children. - gc.tags.extend(contents.nodes); - - let ctx = gc.tags.footnotes.entry(decl_loc).or_insert(FootnoteCtx::new()); - - // Only insert the footnote entry once after the first reference. - if !ctx.is_referenced { - ctx.is_referenced = true; - gc.tags.push(TagNode::FootnoteEntry(decl_loc)); - } - return; - } - StackEntryKind::FootnoteEntry(footnote_loc) => { - // Store footnotes separately so they can be inserted directly after - // the footnote reference in the reading order. - let tag = TagNode::group(Tag::Note, contents); - let ctx = gc.tags.footnotes.entry(footnote_loc).or_insert(FootnoteCtx::new()); - ctx.entry = Some(tag); - return; - } StackEntryKind::CodeBlock => { - TagNode::group(Tag::Code.with_placement(Some(kt::Placement::Block)), contents) + let tag = Tag::Code.with_placement(Some(kt::Placement::Block)); + gc.tags.groups.init_tag(tag, contents) } StackEntryKind::CodeBlockLine => { // If the raw element is a block, wrap each line in a BLSE, so the // individual lines are properly wrapped and indented when reflowed. - TagNode::group(Tag::P, contents) + gc.tags.groups.init_tag(Tag::P, contents) } }; gc.tags.push(node); } +pub struct ChildGroupHandle<'a, 'b> { + gc: &'b mut GlobalContext<'a>, + /// The index of the logical child inside the tag stack. It shouldn't change + /// since overlapping tags to the outside are not not possible for elements + /// which currently have logical children. + stack_idx: Option, +} + +impl Drop for ChildGroupHandle<'_, '_> { + fn drop(&mut self) { + if let Some(idx) = self.stack_idx { + let entry = self.gc.tags.stack.get(idx).expect("stack entry"); + assert!(matches!(entry.kind, StackEntryKind::LogicalChild)); + + // Stash the unfinished stack entries so they can be processed by + // the next logical child. + if idx + 1 < self.gc.tags.stack.len() { + let group = self.gc.tags.groups.get_mut(entry.id); + let unfinished_stack = self.gc.tags.stack.stash_unfinished_stack(idx); + group.unfinished_stack.extend(unfinished_stack); + } + + self.gc.tags.stack.pop(); + } + } +} + +impl<'a> ChildGroupHandle<'a, '_> { + pub fn gc<'s>(&'s mut self) -> &'s mut GlobalContext<'a> { + self.gc + } +} + +/// Handle children frames logically belonging to another element, because +/// [typst_library::layout::GroupItem::parent] has been set. All elements that +/// can have children set by this mechanism must be handled in [`handle_start`] +/// and must produce a located [`Group`], so the children can be inserted there. +/// +/// Currently the the frame parent is only set for: +/// - place elements [`PlaceElem`] +/// - footnote entries [`FootnoteEntry`] +/// - broken table/grid cells [`TableCell`]/[`GridCell`] +pub fn logical_child<'a, 'b>( + gc: &'b mut GlobalContext<'a>, + parent: Option, +) -> SourceResult> { + if gc.options.disable_tags || gc.tags.disable.is_some() { + return Ok(ChildGroupHandle { gc, stack_idx: None }); + } + let Some(parent_loc) = parent else { + return Ok(ChildGroupHandle { gc, stack_idx: None }); + }; + + let id = gc.tags.groups.reserve_located( + gc.options, + parent_loc, + GroupKind::LogicalChild, + )?; + let stack_idx = Some(gc.tags.stack.len()); + + // The entry is popped off the stack in the drop implementation. + push_stack_entry(gc, None, Span::detached(), id, StackEntryKind::LogicalChild); + + // Push the unfinished stack entries back on to be processed. + let group = gc.tags.groups.get_mut(id); + gc.tags.stack.extend(group.unfinished_stack.drain(..)); + + Ok(ChildGroupHandle { gc, stack_idx }) +} + pub fn page_start(gc: &mut GlobalContext, surface: &mut Surface) { if gc.options.disable_tags { return; diff --git a/crates/typst-pdf/src/tags/outline.rs b/crates/typst-pdf/src/tags/outline.rs index d96d826fc9..b1a43a93a2 100644 --- a/crates/typst-pdf/src/tags/outline.rs +++ b/crates/typst-pdf/src/tags/outline.rs @@ -2,7 +2,7 @@ use krilla::tagging::Tag; use typst_library::foundations::Packed; use typst_library::model::OutlineEntry; -use crate::tags::{GroupContents, TagNode}; +use crate::tags::{GroupContents, GroupId, Groups, TagNode}; #[derive(Clone, Debug)] pub struct OutlineCtx { @@ -16,7 +16,8 @@ impl OutlineCtx { pub fn insert( &mut self, - outline_nodes: &mut Vec, + groups: &mut Groups, + group_id: GroupId, entry: Packed, contents: GroupContents, ) { @@ -25,31 +26,35 @@ impl OutlineCtx { self.stack.resize_with(expected_len, OutlineSection::new); } else { while self.stack.len() > expected_len { - self.finish_section(outline_nodes); + self.finish_section(groups, group_id); } } - let section_entry = TagNode::group(Tag::TOCI, contents); - self.push(outline_nodes, section_entry); + let section_entry = groups.init_tag(Tag::TOCI, contents); + self.push(groups, group_id, section_entry); } - fn finish_section(&mut self, outline_nodes: &mut Vec) { - let sub_section = self.stack.pop().unwrap().into_tag(); - self.push(outline_nodes, sub_section); + fn finish_section(&mut self, groups: &mut Groups, group_id: GroupId) { + let sub_section = groups.new_virtual(Tag::TOC, self.stack.pop().unwrap().entries); + self.push(groups, group_id, sub_section); } - fn push(&mut self, outline_nodes: &mut Vec, entry: TagNode) { + fn push(&mut self, groups: &mut Groups, group_id: GroupId, entry: TagNode) { match self.stack.last_mut() { Some(section) => section.push(entry), - None => outline_nodes.push(entry), + None => groups.get_mut(group_id).nodes.push(entry), } } - pub fn build_outline(mut self, mut contents: GroupContents) -> TagNode { + pub fn build_outline( + mut self, + groups: &mut Groups, + contents: GroupContents, + ) -> TagNode { while !self.stack.is_empty() { - self.finish_section(&mut contents.nodes); + self.finish_section(groups, contents.id); } - TagNode::group(Tag::TOC, contents) + groups.init_tag(Tag::TOC, contents) } } @@ -66,8 +71,4 @@ impl OutlineSection { fn push(&mut self, entry: TagNode) { self.entries.push(entry); } - - fn into_tag(self) -> TagNode { - TagNode::virtual_group(Tag::TOC, self.entries) - } } diff --git a/crates/typst-pdf/src/tags/text.rs b/crates/typst-pdf/src/tags/text.rs index 38ebf7f612..72c6b5b113 100644 --- a/crates/typst-pdf/src/tags/text.rs +++ b/crates/typst-pdf/src/tags/text.rs @@ -197,16 +197,16 @@ impl ResolvedTextAttrs { && self.deco.is_some() } - pub fn build_node(self, children: Vec) -> Node { + pub fn resolve_nodes(self, accum: &mut Vec, children: Vec) { enum Prev { - Children(Vec), + Children(Vec), Group(kt::TagGroup), } impl Prev { fn into_nodes(self) -> Vec { match self { - Prev::Children(nodes) => nodes, + Prev::Children(ids) => ids.into_iter().map(Node::Leaf).collect(), Prev::Group(group) => vec![Node::Group(group)], } } @@ -235,12 +235,8 @@ impl ResolvedTextAttrs { } match prev { - Prev::Group(group) => Node::Group(group), - Prev::Children(nodes) => { - // This should not happen. It can only happen if an empty set of - // `ResolvedTextAttrs` was pushed into a `TagNode::Text`. - Node::Group(kt::TagGroup::with_children(Tag::Span, nodes)) - } + Prev::Group(group) => accum.push(Node::Group(group)), + Prev::Children(ids) => accum.extend(ids.into_iter().map(Node::Leaf)), } } } diff --git a/tests/ref/pdftags/footnote-tags-basic.yml b/tests/ref/pdftags/footnote-tags-basic.yml new file mode 100644 index 0000000000..c54226d27d --- /dev/null +++ b/tests/ref/pdftags/footnote-tags-basic.yml @@ -0,0 +1,27 @@ +- Tag: P + /K: + - Content: page=0 mcid=0 + - Tag: Lbl + /K: + - Tag: Link + /K: + - Tag: Span + /BaselineShift: 3.500 + /LineHeight: 6.000 + /K: + - Content: page=0 mcid=1 + - Annotation: page=0 index=0 + - Tag: Note + /K: + - Tag: Lbl + /K: + - Tag: Link + /K: + - Tag: Span + /BaselineShift: 2.975 + /LineHeight: 5.100 + /K: + - Content: page=0 mcid=3 + - Annotation: page=0 index=1 + - Content: page=0 mcid=4 + - Content: page=0 mcid=2 diff --git a/tests/ref/pdftags/footnote-tags-different-lang.yml b/tests/ref/pdftags/footnote-tags-different-lang.yml new file mode 100644 index 0000000000..3299cf5113 --- /dev/null +++ b/tests/ref/pdftags/footnote-tags-different-lang.yml @@ -0,0 +1,29 @@ +- Tag: P + /K: + - Content: page=0 mcid=0 + - Tag: Lbl + /K: + - Tag: Link + /K: + - Tag: Span + /BaselineShift: 3.500 + /LineHeight: 6.000 + /K: + - Content: page=0 mcid=1 + - Annotation: page=0 index=0 + - Tag: Note + /Lang: "de" + /K: + - Tag: Lbl + /Lang: "en" + /K: + - Tag: Link + /K: + - Tag: Span + /BaselineShift: 2.975 + /LineHeight: 5.100 + /K: + - Content: page=0 mcid=3 + - Annotation: page=0 index=1 + - Content: page=0 mcid=4 + - Content: page=0 mcid=2 diff --git a/tests/ref/pdftags/footnote-tags-ref-to-other-footnote.yml b/tests/ref/pdftags/footnote-tags-ref-to-other-footnote.yml new file mode 100644 index 0000000000..67e0cd8420 --- /dev/null +++ b/tests/ref/pdftags/footnote-tags-ref-to-other-footnote.yml @@ -0,0 +1,38 @@ +- Tag: P + /K: + - Content: page=0 mcid=0 + - Tag: Lbl + /K: + - Tag: Link + /K: + - Tag: Span + /BaselineShift: 3.500 + /LineHeight: 6.000 + /K: + - Content: page=0 mcid=1 + - Annotation: page=0 index=0 + - Tag: Note + /K: + - Tag: Lbl + /K: + - Tag: Link + /K: + - Tag: Span + /BaselineShift: 2.975 + /LineHeight: 5.100 + /K: + - Content: page=0 mcid=5 + - Annotation: page=0 index=2 + - Content: page=0 mcid=6 + - Content: page=0 mcid=2 + - Tag: Lbl + /K: + - Tag: Link + /K: + - Tag: Span + /BaselineShift: 3.500 + /LineHeight: 6.000 + /K: + - Content: page=0 mcid=3 + - Annotation: page=0 index=1 + - Content: page=0 mcid=4 diff --git a/tests/ref/pdftags/grid-tags-cell-breaking.yml b/tests/ref/pdftags/grid-tags-cell-breaking.yml new file mode 100644 index 0000000000..09a18f986e --- /dev/null +++ b/tests/ref/pdftags/grid-tags-cell-breaking.yml @@ -0,0 +1,33 @@ +- Tag: Div + /K: + - Tag: Div + /K: + - Tag: P + /K: + - Content: page=0 mcid=0 + - Content: page=0 mcid=1 + - Tag: P + /K: + - Content: page=0 mcid=2 + - Content: page=0 mcid=3 + - Content: page=0 mcid=4 + - Content: page=1 mcid=0 + - Content: page=1 mcid=1 + - Content: page=1 mcid=2 + - Content: page=1 mcid=3 + - Tag: Div + /K: + - Content: page=0 mcid=5 + - Content: page=0 mcid=6 + - Tag: Div + /K: + - Content: page=1 mcid=4 + - Tag: Div + /K: + - Content: page=1 mcid=5 + - Tag: Div + /K: + - Content: page=2 mcid=0 + - Tag: Div + /K: + - Content: page=2 mcid=1 diff --git a/tests/ref/pdftags/lang-tags-propagation.yml b/tests/ref/pdftags/lang-tags-propagation.yml index adf1b63a35..f4855d42ed 100644 --- a/tests/ref/pdftags/lang-tags-propagation.yml +++ b/tests/ref/pdftags/lang-tags-propagation.yml @@ -4,7 +4,7 @@ lang: "nl" /K: - Content: page=0 mcid=0 - Tag: L - /Lang: "de" + /Lang: "es" /Numbering: Circle /K: - Tag: LI @@ -13,6 +13,7 @@ lang: "nl" /K: - Content: page=0 mcid=1 - Tag: LBody + /Lang: "de" /K: - Tag: P /K: @@ -26,6 +27,7 @@ lang: "nl" /K: - Content: page=0 mcid=3 - Tag: LBody + /Lang: "de" /K: - Content: page=0 mcid=4 - Tag: LI @@ -34,6 +36,7 @@ lang: "nl" /K: - Content: page=0 mcid=5 - Tag: LBody + /Lang: "de" /K: - Content: page=0 mcid=6 - Tag: LI @@ -42,5 +45,6 @@ lang: "nl" /K: - Content: page=0 mcid=7 - Tag: LBody + /Lang: "de" /K: - Content: page=0 mcid=8 diff --git a/tests/ref/pdftags/layout-tags-placement-different-lang.yml b/tests/ref/pdftags/layout-tags-placement-different-lang.yml new file mode 100644 index 0000000000..2f894ca0a5 --- /dev/null +++ b/tests/ref/pdftags/layout-tags-placement-different-lang.yml @@ -0,0 +1,28 @@ +lang: "be" +--- +- Tag: Div + /K: + - Tag: Div + /K: + - Tag: P + /K: + - Content: page=0 mcid=1 + - Tag: Code + /Lang: "en" + /K: + - Content: page=0 mcid=0 + - Tag: Div + /Lang: "de" + /K: + - Tag: P + /K: + - Content: page=0 mcid=3 + - Content: page=0 mcid=2 + - Tag: P + /K: + - Content: page=0 mcid=4 + - Tag: Div + /Lang: "es" + /K: + - Content: page=0 mcid=5 + - Tag: Div diff --git a/tests/ref/pdftags/query-tags-duplicate-heading.yml b/tests/ref/pdftags/query-tags-duplicate-heading.yml new file mode 100644 index 0000000000..1aec44f80a --- /dev/null +++ b/tests/ref/pdftags/query-tags-duplicate-heading.yml @@ -0,0 +1,8 @@ +- Tag: H1 + /T: "Hi" + /K: + - Content: page=0 mcid=0 +- Tag: H1 + /T: "Hi" + /K: + - Content: page=0 mcid=1 diff --git a/tests/ref/pdftags/query-tags-duplicate-labelled-element.yml b/tests/ref/pdftags/query-tags-duplicate-labelled-element.yml new file mode 100644 index 0000000000..60f3d7b6ad --- /dev/null +++ b/tests/ref/pdftags/query-tags-duplicate-labelled-element.yml @@ -0,0 +1,24 @@ +- Tag: P + /K: + - Tag: Figure + /Alt: "Text saying: hello there" + /BBox: + page: 0 + left: 38.225 + top: 9.600 + right: 81.495 + bottom: 16.680 + /K: + - Content: page=0 mcid=0 +- Tag: P + /K: + - Tag: Figure + /Alt: "Text saying: hello there" + /BBox: + page: 0 + left: 38.225 + top: 28.180 + right: 81.495 + bottom: 35.260 + /K: + - Content: page=0 mcid=1 diff --git a/tests/ref/pdftags/table-tags-rowspan-split-1.yml b/tests/ref/pdftags/table-tags-rowspan-split-1.yml new file mode 100644 index 0000000000..f51def5596 --- /dev/null +++ b/tests/ref/pdftags/table-tags-rowspan-split-1.yml @@ -0,0 +1,34 @@ +- Tag: Table + /BorderColor: #000000 + /BorderThickness: 1.000 + /K: + - Tag: TR + /K: + - Tag: TD + /Headers: [] + /RowSpan: 3 + /BorderStyle: Solid + /K: + - Content: page=0 mcid=1 + - Content: page=0 mcid=2 + - Content: page=2 mcid=0 + - Content: page=2 mcid=1 + - Tag: TD + /Headers: [] + /BorderStyle: Solid + /K: + - Content: page=0 mcid=0 + - Tag: TR + /K: + - Tag: TD + /Headers: [] + /BorderStyle: Solid + /K: + - Content: page=1 mcid=0 + - Tag: TR + /K: + - Tag: TD + /Headers: [] + /BorderStyle: Solid + /K: + - Content: page=2 mcid=2 diff --git a/tests/ref/pdftags/table-tags-rowspan-split-2.yml b/tests/ref/pdftags/table-tags-rowspan-split-2.yml new file mode 100644 index 0000000000..60906f18f2 --- /dev/null +++ b/tests/ref/pdftags/table-tags-rowspan-split-2.yml @@ -0,0 +1,50 @@ +- Tag: Table + /BorderColor: #000000 + /BorderThickness: 1.000 + /K: + - Tag: TR + /K: + - Tag: TD + /Headers: [] + /BorderStyle: Solid + /K: + - Content: page=0 mcid=0 + - Tag: TD + /Headers: [] + /RowSpan: 3 + /BorderStyle: Solid + /K: + - Content: page=0 mcid=2 + - Content: page=0 mcid=3 + - Content: page=1 mcid=2 + - Content: page=2 mcid=0 + - Content: page=2 mcid=1 + - Tag: TD + /Headers: [] + /BorderStyle: Solid + /K: + - Content: page=0 mcid=1 + - Tag: TR + /K: + - Tag: TD + /Headers: [] + /BorderStyle: Solid + /K: + - Content: page=1 mcid=0 + - Tag: TD + /Headers: [] + /BorderStyle: Solid + /K: + - Content: page=1 mcid=1 + - Tag: TR + /K: + - Tag: TD + /Headers: [] + /BorderStyle: Solid + /K: + - Content: page=2 mcid=2 + - Tag: TD + /Headers: [] + /BorderStyle: Solid + /K: + - Content: page=2 mcid=3 diff --git a/tests/suite/pdftags/footnote.typ b/tests/suite/pdftags/footnote.typ new file mode 100644 index 0000000000..f4a55147c5 --- /dev/null +++ b/tests/suite/pdftags/footnote.typ @@ -0,0 +1,12 @@ +--- footnote-tags-basic pdftags --- +Footnote #footnote[Hi] in text. + +--- footnote-tags-different-lang pdftags --- +Footnote #footnote[ + // The footnote number is still in english + #set text(lang: "de") + Hallo +] in text. + +--- footnote-tags-ref-to-other-footnote pdftags --- +This #footnote[content] and #footnote(). diff --git a/tests/suite/pdftags/grid.typ b/tests/suite/pdftags/grid.typ index 0b514caee8..89eb158d3d 100644 --- a/tests/suite/pdftags/grid.typ +++ b/tests/suite/pdftags/grid.typ @@ -9,3 +9,19 @@ [b], grid.cell(x: 2, y: 1, colspan: 2, rowspan: 2, underline[text]), [b], ) + +--- grid-tags-cell-breaking pdftags --- +// The second paragraph contains marked content from page 1 and 2 +#set page(width: 5cm, height: 3cm) +#grid( + columns: 2, + row-gutter: 8pt, + [Lorem ipsum dolor sit amet. + + Aenean commodo ligula eget dolor. Aenean massa. Penatibus et magnis.], + [Text that is rather short], + [Fireflies], + [Critical], + [Decorum], + [Rampage], +) diff --git a/tests/suite/pdftags/lang.typ b/tests/suite/pdftags/lang.typ index 8a1313fe35..71ba50a694 100644 --- a/tests/suite/pdftags/lang.typ +++ b/tests/suite/pdftags/lang.typ @@ -12,7 +12,8 @@ Par 3. #set text(lang: "nl") A paragraph. -// language attributes are propagated to the parent (L) tag +// The list bullets are in spanish :) +#set text(lang: "es") - #text(lang: "de", "a") - #text(lang: "de", "b") - #text(lang: "de", "c") diff --git a/tests/suite/pdftags/layout.typ b/tests/suite/pdftags/layout.typ new file mode 100644 index 0000000000..dc4ad7e743 --- /dev/null +++ b/tests/suite/pdftags/layout.typ @@ -0,0 +1,24 @@ +--- layout-tags-placement-different-lang pdftags --- +#set text(lang: "de") +#grid( + columns: 2, + [ + #set text(lang: "be") + text + #place(float: true, top + left)[ + `a` + ] + ], + [ + text + #place(float: true, top + left)[ + #set text(lang: "fr") + text in grid + ] + text + ], + [ + #set text(lang: "es") + b + ] +) diff --git a/tests/suite/pdftags/query.typ b/tests/suite/pdftags/query.typ new file mode 100644 index 0000000000..d4ca292b02 --- /dev/null +++ b/tests/suite/pdftags/query.typ @@ -0,0 +1,25 @@ +--- query-tags-duplicate-heading pdftags --- +// This will display the heading with the same location a second time +#context query(heading).join() += Hi + +--- query-tags-duplicate-labelled-element pdftags --- +#figure(alt: "Text saying: hello there")[ + hello there +]
    + +#context query(
    ).at(0) + +--- query-tags-ambiguous-parent-place pdftags --- +// Error: 2-43 PDF/UA-1 error: ambiguous logical parent +// Hint: 2-43 please report this as a bug +#place(float: true, top + left)[something] + +#context query().join() + +--- query-tags-ambiguous-parent-footnote pdftags --- +// Error: 1:2-1:21 PDF/UA-1 error: ambiguous logical parent +// Hint: 1:2-1:21 please report this as a bug +#footnote[something] + +#context query().join() diff --git a/tests/suite/pdftags/table.typ b/tests/suite/pdftags/table.typ index 1a6bbcd0e6..235958d775 100644 --- a/tests/suite/pdftags/table.typ +++ b/tests/suite/pdftags/table.typ @@ -69,3 +69,22 @@ #set table(columns: (10pt, auto)) #show table: it => it.columns #table[A][B][C][D] + +--- table-tags-rowspan-split-1 pdftags --- +#set page(height: 6em) +#table( + rows: (4em, auto, 4em), + columns: 2, + table.cell(rowspan: 3, [a\ ] * 4), + [b], [c], [d], +) + +--- table-tags-rowspan-split-2 pdftags --- +#set page(height: 6em) +#table( + rows: (4em, auto, 4em), + columns: 3, + [a1], table.cell(rowspan: 3, [b\ ] * 5), [c1], + [a2], [c2], + [a3], [c3], +) From 4be175a9969ce387c8708e2dd936f31ea8296c24 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Mon, 8 Sep 2025 14:34:34 +0200 Subject: [PATCH 426/558] Infer document language from first toplevel set rule Otherwise fall back to the language of the first text item and then the default language. --- crates/typst-library/src/foundations/styles.rs | 10 ++++++++++ crates/typst-library/src/model/document.rs | 7 +++++++ crates/typst-pdf/src/convert.rs | 2 +- crates/typst-pdf/src/tags/context.rs | 4 ++-- crates/typst-realize/src/lib.rs | 13 +++++++++++-- ...ent-lang.yml => layout-tags-placement-float.yml} | 3 ++- tests/ref/pdftags/quote-dir-align.yml | 4 +++- tests/ref/pdftags/quote-dir-author-pos.yml | 4 +++- tests/suite/pdftags/layout.typ | 2 +- 9 files changed, 40 insertions(+), 9 deletions(-) rename tests/ref/pdftags/{layout-tags-placement-different-lang.yml => layout-tags-placement-float.yml} (95%) diff --git a/crates/typst-library/src/foundations/styles.rs b/crates/typst-library/src/foundations/styles.rs index fd2c1f34a9..ba997a925d 100644 --- a/crates/typst-library/src/foundations/styles.rs +++ b/crates/typst-library/src/foundations/styles.rs @@ -359,6 +359,16 @@ impl Property { self.elem == elem } + /// Retrieves a reference to the value of the given field from the property. + /// + /// Not possible if the value needs folding. + pub fn get_ref(&self, _: Field) -> Option<&E::Type> + where + E: RefableProperty, + { + if self.is(E::ELEM, I) { Some(self.value.downcast(E::ELEM, I)) } else { None } + } + /// Turn this property into prehashed style. pub fn wrap(self) -> LazyHash