From 6e0f48e192ddbd934d3aadd056810c86bcc3defd Mon Sep 17 00:00:00 2001 From: Igor Khanin Date: Wed, 28 May 2025 16:05:10 +0300 Subject: [PATCH 01/48] 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 91fa53f9a..15b4296eb 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 2638ce51b..e5e4cc19a 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 d5d584e2b..887e851f9 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 9f8acce87..cd90a08fe 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 02/48] 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 316617688..7d44cccc0 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 03/48] 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 6a57c85e8..fa9683416 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 e5e4cc19a..528f679cf 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 04/48] 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 ae64368d6..4ec76c253 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 34e66b99c..70838dd8c 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 05/48] 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 88252e5e3..04aaee944 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 06/48] 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 1b508a349..59858a9cb 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 148a49d02..189f6e6db 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 07/48] 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 5a137edbd..bec667d6e 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 08/48] 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 d82c3e4cd..320ed7d17 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 09/48] 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 a8e0eaeb3..7f481a23b 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 10/48] 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 04aaee944..46271ff22 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 11/48] 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 bbae06792..3620d4fda 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 12/48] 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 41f17d137..c5c81537b 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 0d235aec5..ca317abd0 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 40084e51b..a4d49b4f3 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 485d0edcf..7aa06e815 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 b90b78886..6f4b2b95c 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 580ba9e80..a6bb4fe38 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 ad47d29cd..dedfbb4e0 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 6938f6e57..1b2b3abc8 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 13/48] 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 fa9683416..eaeabbab3 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 14/48] 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 2cc66a261..f9f13c783 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 000000000..4c6b86841 --- /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 15/48] 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 4b70e06bc..30a4db7a7 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 16/48] 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 73d821019..53dfdf055 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 mut glyph = GlyphFragment::new(ctx, styles, accent.0, elem.span()); - let Accent(c) = elem.accent; - let mut glyph = GlyphFragment::new(ctx, styles, c, 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 e1d7d7c9d..90aad941e 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 59858a9cb..85101c486 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 f45035e27..6157d0c50 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 e62b63872..f2c9168c2 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 17/48] 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 4dda689df..270d8f0f3 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 18/48] 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 270d8f0f3..09cefd013 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 f2af93ceb..6eab35076 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 19/48] 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 59ac5b089..7ecbcfbaf 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 20/48] 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 30a4db7a7..347704b33 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 bc563b980..0f871e211 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 21/48] 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 347704b33..a9b3756a6 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 0f871e211..b4890e3c1 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 15b4296eb..4a36045ae 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 3e5b7d8bd..8136a25a3 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 258eb96f3..f9e345e70 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 21d5b18fc..54f832bae 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 d74432026..1868ca39b 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 acc257830..d505d2d03 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 9a77870af..73c4feff8 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 22/48] 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 53dfdf055..301606466 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 6d3caac45..2567349d0 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 85101c486..01fa6be4b 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 d678f8658..e509cecc7 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 c7f41488e..32f527198 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 6157d0c50..40f76da59 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 7ecbcfbaf..e191ec170 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 5b6bd40eb..a24113c81 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 zcmV;(1ULJY3C{_T7k^6#00000p5Yt~000D*Nklu*zK9KdmiZ$w`hP4o|t zmn3Qk8qpUfib6mQY64Smm?2)dC>diQI$+2)Ho(CeU`}=|-8#n2ZgNu?8>4K)Z7>)E z*`?jqUAjwKdcSN9w43?z#8OGa37yo`6QKR#_zrWL z1HY~g1PAY&Ay+14`0A`ujELgRLbx#R&z(VGdy+4#JiGR&lwrq0{Mr%2Y`rp92>*3n zl)AzuhTmI#7=J0lO^E~(!eePELb$hHl)lF4-Rd2?^-_lOOYlp#0d4!CVZH-D>t+Nw zoxk=SNVQ2AUfYe|2{%x@%LEk!XtV&P%zfFen!SyZg?m!BL}!3FkLaQkZumVc(rtCC zIxIJ1w^J+}3;(mR&23Q$>t1UHdNd;lMm5?QB!k%6OVrb}D|YyTL1;we1@xWFSmd@g;K1N$2)j)oa-dj|0J<~|t4PF*I! zE~NDLC)bl!gim1(yb1He3^zYDVYqNHTi(A6T>3U!u?~SHE5}ih(tl&4IuIUt9cG6a zuADw$*nhs5x>SH`TTU|%udc)f>z+I+lAXS=W**FPh8Mp0=)ViIz^PkmMO5*_Kx^{V z(I1QOg>A3E+^L_j?fHQR!ov$ZXEy>^7gAcN0JttRi(QA)jR-tHrMmgb)SWo>aEU@b zI6nNFyrV>Z3$Qd|P*{L!IfEHSvfA;ws!-HoIe$}Oo_g4~h|mD3o`GP+k`o9jmwXhV zaOLzUT@p=CBBAilSX*Z!$hAJaJOfabWhRv47X%&pUd@!l>Nx;c%Fn)PBH&Zq*M||^ z`GlbjRl|fj(Sl&z%y|8zVK+DcMr#q2GG17&kJTzB|V}0K>g|+Z*nQ zK?^s&oGi@gU97ro0nI_Zpnl~TfHv+VqJPWBxC{DKpRaF#p-if0j|(&3+4rFtsF%qW z$z*webs7CHOd8&#<{i~oV5>LaWqbKHz|D7eCe;G&4BXJWoP7z12HtX2CAt91B1#KO z0f(XKkil>tAeUn)lZG4JTxkaE-3V^sQ@je`np`e76VRqS`h3w4Wd2O^(uW#8{YX$4mk21?OKnmFw%XG(Q%5k< zS~8jdT@rs3kliSKxczB>`>HCHgF#>eDm8$sh2bvl7^M$89v_X)0AbsvD23f?QteR* mgSsR3pDPxQg=69WO!#jlK;%qV1-)JX0000^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-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 23/48] Numbering implementation refactor (#6122) --- crates/typst-library/src/model/numbering.rs | 667 ++++++++++---------- tests/suite/model/numbering.typ | 42 +- 2 files changed, 354 insertions(+), 355 deletions(-) diff --git a/crates/typst-library/src/model/numbering.rs b/crates/typst-library/src/model/numbering.rs index 320ed7d17..236ced361 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]. -/// -/// [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 = [ - ["α", "Α"], - ["β", "Β"], - ["γ", "Γ"], - ["δ", "Δ"], - ["ε", "Ε"], - ["ϛ", "Ϛ"], - ["ζ", "Ζ"], - ["η", "Η"], - ["θ", "Θ"], - ]; - - 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]) - } - }; - - 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); - - // `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 { - continue; - } - - if previous_has_number { - fmt.push_str(", "); - } - - 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); - } - // if we do not have thousan, we need to append 'ʹ' at the end. - if th == 0 { - fmt.push_str("ʹ"); - } - previous_has_number = true; - } - fmt -} - -/// Stringify a number using a base-N counting system with no zero digit. -/// -/// This is best explained by example. Suppose our digits are 'A', 'B', and 'C'. -/// We would get the following: +/// Consider the situation where ['I': 1, 'IV': 4, 'V': 5], /// /// ```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 => 'I' +/// 2 => 'II' +/// 3 => 'III' +/// 4 => 'IV' +/// 5 => 'V' +/// 6 => 'VI' +/// 7 => 'VII' +/// 8 => 'VIII' /// ``` /// -/// 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 { +/// where this is the start of the familiar Roman numeral system. +fn additive(symbols: &[(&str, u64)], mut n: u64) -> EcoString { + if n == 0 { + if let Some(&(symbol, 0)) = symbols.last() { + return symbol.into(); + } + return '0'.into(); + } + + let mut s = EcoString::new(); + for (symbol, weight) in symbols { + if *weight == 0 || *weight > n { + continue; + } + let reps = n / weight; + for _ in 0..reps { + s.push_str(symbol); + } + + n -= weight * reps; + if n == 0 { + return s; + } + } + s +} + +/// Stringify a number using a base-n (where n is the number of provided +/// symbols) system without a zero symbol. +/// +/// 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' +/// ... +/// ``` +/// +/// 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 a base-10 counting system with a zero digit. +/// Stringify a number using the symbols provided, defaulting to the arabic +/// representation when the number is greater than the number of symbols. /// -/// This function assumes that the digits occupy contiguous codepoints. -fn decimal(start: char, mut n: u64) -> EcoString { - if n == 0 { - return start.into(); +/// 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(); } - let mut cs = EcoString::new(); - while n > 0 { - cs.push(char::from_u32((start as u32) + ((n % 10) as u32)).unwrap()); - n /= 10; - } - cs.chars().rev().collect() + eco_format!("{n}") +} + +/// 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, +/// +/// ```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 symbols[0].into(); + } + let mut s = EcoString::new(); + while n != 0 { + s.push(symbols[(n % n_digits) as usize]); + n /= n_digits; + } + 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(); + } + 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 6af989ff1..2d6a3d6a6 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 24/48] 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 6616c3311..369df11ee 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 6f4461bd4..373230897 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 25/48] 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 ecd0d78a5..a68815806 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 26/48] 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 4a36045ae..a042b1640 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 887e851f9..13de402ba 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 27/48] 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 45f388ccd..5d7859a37 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 28/48] 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 8fea3a1f2..e5aa7e999 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 29/48] Improve number lexing (#5969) --- crates/typst-syntax/src/lexer.rs | 154 ++++++++++++++++-------------- 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, 118 insertions(+), 87 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 ac69eb616..7d363d7b5 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(); - } - } - - // Read the first part (integer or fractional depending on `first`). - self.s.eat_while(if base == 16 { - 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 the suffix. - let suffix_start = self.s.cursor(); - if !self.s.eat_if('%') { - self.s.eat_while(char::is_ascii_alphanumeric); - } - - let number = self.s.get(start..suffix_start); - let suffix = self.s.from(suffix_start); - - 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 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, }; - if suffix.is_empty() { - return kind; + // Read the initial digits. + if base == 16 { + self.s.eat_while(char::is_ascii_alphanumeric); + } else { + self.s.eat_while(char::is_ascii_digit); } - if !matches!( - suffix, - "pt" | "mm" | "cm" | "in" | "deg" | "rad" | "em" | "fr" | "%" - ) { - return self.error(eco_format!("invalid number suffix: {}", suffix)); + // 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 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); + } } - 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; - } + let number = self.s.from(start); + let suffix = self.s.eat_while(|c: char| c.is_ascii_alphanumeric() || c == '%'); - SyntaxKind::Numeric + 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}")), + }; + + 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}")), + } + }; + + // 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), + } } 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 30/48] 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 | 38 +- 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, 1165 insertions(+), 572 deletions(-) create mode 100644 crates/typst-syntax/src/lines.rs diff --git a/Cargo.lock b/Cargo.lock index a9b3756a6..b699d2450 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 b4890e3c1..b548245fa 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 4edb4c323..207bb7d09 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 9f017dc12..3d10bbc67 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 2da03d4d5..f63d34b63 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 438e09e43..cc355a3db 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 8136a25a3..a8f4a0c81 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 b210637a8..f4b219882 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 49cbd02c6..41b92ed65 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 d633c99ad..180dcdad5 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 31f8cd732..a04443bf4 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 aa14c5c77..d95f73844 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 6afb5baeb..d5b54a06c 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::UnequalLengths { expected_len, len, .. } => { - eco_format!( - "failed to parse CSV (found {len} instead of \ - {expected_len} fields in line {line})" - ) + ::csv::ErrorKind::Utf8 { .. } => { + LoadError::new(pos, msg, "file is not valid utf-8") } - _ => eco_format!("failed to parse CSV ({err})"), + ::csv::ErrorKind::UnequalLengths { expected_len, len, .. } => { + let err = + format!("found {len} instead of {expected_len} fields in line {line}"); + LoadError::new(pos, msg, 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 aa908cca4..7d0732ba0 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 c57e02888..67f4be834 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 32dadc799..91e6e4366 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 f04b2e746..a4252feca 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 e76c4e9cf..0023c5df5 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 3f48113e8..0edf1f901 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 51e3b03b0..114356575 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 d5c07424d..f2485e16b 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 f9e345e70..f5109798b 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 9bf1ead0d..1a3f6d474 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 263595bd4..c20f6a087 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 5e7b710fc..1249f88e9 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 000000000..fa1e77563 --- /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 6ff94c73f..514cb9a4a 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 84af04d2d..173488b01 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 4d08362cf..a34e38db5 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 fe2bd45ea..bc3e690b2 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 6f57ec458..046345bec 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 c8df1ff6e..9e433992d 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 b5c9c0892..57bfc1d5c 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 a4318a015..9d65da452 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 933f3c480..eed7db0ae 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 a8089052c..ad171c6ef 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 49b66ee56..382e444cc 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 73c4feff8..45c70c4b8 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 31/48] 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 | 496 +++++++++++++-- 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 | 487 +++++++++----- 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, 2453 insertions(+), 520 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 99b85eddb..42fe38dbe 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 7549673f1..d5da7e263 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 22d2a09ef..8db33df5e 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)?; - 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() - { - // 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![]); - skipped_region = true; + 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; } - // Reset the header height for this region. - // It will be re-calculated when laying out each header row. - self.header_height = Abs::zero(); - - if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { - if skipped_region { - // Simulate the footer again; the region's 'full' might have - // changed. - self.footer_height = self - .simulate_footer(footer, &self.regions, engine, disambiguator)? - .height; + 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); } } - // Header is unbreakable. + 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_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![], + 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; + + self.regions.size.y -= self.current.footer_height; + self.current.initial_after_repeats = self.regions.size.y; + } + + if let Some(footer) = &self.grid.footer { + if footer.repeated && skipped_region { + // Simulate the footer again; the region's 'full' might have + // changed. + 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; + } + } + + 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 5ab0417d8..02ea14813 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 83a89bf8a..f65641ff1 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 369df11ee..52621c647 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 bad25b474..baf6b7383 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, } -/// A possibly repeatable grid object. +impl Footer { + /// The footer's range of included rows. + #[inline] + pub fn range(&self) -> Range { + self.start..self.end + } +} + +/// 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 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 { - /// 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, - } - } - /// 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; + 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; - // 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) - } - }); + 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; + // 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; + 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 373230897..dcc77b0dc 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 3618b8f2f..b383ec27f 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 b346a8096..abe6423df 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 2c80d37de..ce43667e9 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 000000000..8a34ac170 --- /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 f7f1deb0a..c0b03f50a 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 229bce614..ea222ee88 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 10345cb06..cf98d4bc5 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 000000000..56bed6a57 --- /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 32/48] 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 | 796 +++++++++++++--------- 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 | 300 +------- 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, 731 insertions(+), 810 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 5ef820d07..6cafb9b00 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 ca723c0a5..935a86b38 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 301606466..159703b8e 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 90aad941e..a7f3cad5f 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 2567349d0..091f328f6 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 01fa6be4b..eb85eeb5d 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 + // 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_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, - } + pub fn baseline(&self) -> Abs { + self.ascent() + } + + /// The distance from the baseline to the top of the frame. + pub fn ascent(&self) -> Abs { + self.baseline.unwrap_or(self.size.y) + } + + /// The distance from the baseline to the bottom of the frame. + pub fn descent(&self) -> Abs { + self.size.y - self.ascent() } 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)); + 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 } - 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); - } - } - - 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); - } - } - - 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); - } - } - /// 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; + } + + 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; + } + } + + // 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); + } + + /// Vertically adjust the fragment's frame so that it is centered + /// on the axis. + 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, 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 GlyphFragment { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "GlyphFragment({:?})", self.c) - } -} - -#[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, -} - -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) - } - - /// 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)); - } -} - -impl Debug for VariantFragment { - 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 bf8235411..e0caf4179 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 e509cecc7..278b1343e 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 708a4443d..5fd22e578 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 32f527198..91b9b16af 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 600c130d4..1f88d2dd7 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